fold sub-agent prompt into the Agent card; relabel you → agent prompt (T-263)

A sidechain sub-agent prompt was rendered with the blue "you" label,
falsely implying the user typed it. Now:

- transcript_reader parses parentUuid (was dropped) onto every
  ConversationItem.
- conversation_view resolves each sidechain prompt to its spawning
  Agent/Task card via parentUuid (nearest-preceding Agent as fallback),
  folds the prompt into that card as a collapsed "prompt" segment, and
  suppresses the standalone block. Layered order when expanded:
  call → prompt → result (note E).
- A sidechain UserMessage never gets the "you" treatment: folded into its
  card, or — when orphaned — rendered as a muted standalone "agent prompt".

Tests: parentUuid parse, fold + suppression, parallel-agent attachment
(would fail under a nearest-preceding heuristic), orphan relabel, and a
golden for the Agent card's call → prompt → result layering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:45:48 +02:00
co-authored by Claude Opus 4.8
parent c6bebcb1cb
commit a7a7b04a05
9 changed files with 227 additions and 32 deletions
@@ -814,3 +814,4 @@ Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to
Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', NULL, '2026-06-08 08:34:24', '2026-06-08 08:34:24', '2026-06-08 08:34:24', NULL, '499355d06a5e7618a1c32e63a6cd1bcd', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-277', 'parent_id', NULL, 'T-276', NULL, '2026-06-08 08:34:47', '2026-06-08 08:34:47', '2026-06-08 08:34:47', NULL, '67d78cbf1e92166d60e45b3f51ed440e', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-263', 'status', 'in_progress', 'done', NULL, '2026-06-08 08:45:23', '2026-06-08 08:45:23', '2026-06-08 08:45:23', NULL, '7e18bb85a36603663881ebd55d77d308', 1) ON CONFLICT(hash) DO NOTHING;
+27
View File
@@ -1846,3 +1846,30 @@ Scope:
Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to a label (e.g. ''ui-tweak''); file that migration as a follow-up do NOT auto-close T-276 (it''s marked never-close).
Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', 'backlog', 'medium', NULL, NULL, 'D-3', '2026-06-08 08:32:30', '2026-06-08 08:34:47', NULL, 'da5dd0bb879b9d088e009c735700c935', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-263', 'story', 'T-267', 'Fold sub-agent prompt into the Agent card; relabel from you to agent prompt', 'When Claude launches a sub-agent (the Agent/Task tool), the sub-agent conversation runs as a SIDECHAIN. The prompt Claude wrote for that sub-agent comes through the transcript as a UserMessage with isSidechain=true but injected=false. The conversation view does not look at isSidechain, so it renders that prompt with the blue "you" label exactly like real user input — FALSELY implying the user sent it. It sits as a standalone block below the Agent tool-use card. This is misleading: the user did not write that prompt.
Confirmed facts from the code: every transcript envelope carries BOTH isSidechain AND parentUuid (transcript_reader.dart ~L547 parses isSidechain but never USES it; parentUuid is dropped entirely). The Agent/Task tool-use is rendered by _toolUse in lib/builtin/claude/src/conversation_view.dart (only AskUserQuestion is special-cased today). The "you" vs "context" label is chosen in conversation_view.dart (~L200): UserMessage with injected=true "context" (muted); else "you" (blue, tokens.globalFocus). isSidechain is currently never read by any rendering/grouping logic.
Design (confirmed with user):
1. Never label a sidechain prompt "you". A UserMessage with isSidechain=true is an AGENT PROMPT, not user input label it "agent prompt" with muted / de-emphasized styling (same precedent as injected "context" messages, D-78). Never the blue "you" treatment.
2. Fold the prompt INTO the Agent tool-use card above it, COLLAPSED BY DEFAULT. The Agent card becomes the container that owns its prompt: expand the card to reveal the prompt; collapsed (default) it is hidden. The standalone prompt block is SUPPRESSED from the list same fold-a-standalone-item-into-its-card pattern as T-262.
3. Prompt folds into the CALL, not the results. Only the prompt folds into the collapsed Agent call card. The sub-agent work (its thinking / tool calls / results) stays VISIBLE as the results chain after the call not buried. Shape: [collapsed Agent call, owning the prompt] then [the run results], with no misleading "you" block anywhere. Agent prompts are valuable enough to keep with the call rather than fold into the subsequent results chain.
4. Link the prompt to the RIGHT Agent card via parentUuid. Parse parentUuid (currently dropped) so each sidechain prompt attaches to its spawning Agent tool-use. This matters when several agents run in parallel in one turn a positional "nearest preceding Agent" heuristic would misattach; the parent link is the robust route. Heuristic only as a fallback when the link cannot be resolved.
Touch points:
- transcript_reader.dart: capture parentUuid from the envelope and expose it on ConversationItem (it is present in the JSONL, just not parsed).
- conversation_view.dart: relabel sidechain UserMessage to "agent prompt" (stop the "you" path); fold it into the Agent card body; special-case the Agent/Task tool name (today only AskUserQuestion is special-cased); suppress the standalone block.
- activity_cluster.dart: ensure the suppressed prompt is not double-counted in grouping; leave the sidechain results in the chain.
Edge cases:
- Parallel agents in one turn each prompt attaches to its own card (the reason for parentUuid).
- Orphan prompt (no resolvable parent) still render as "agent prompt", muted/collapsed, never "you".
- Nested / background sub-agents.
Tests: unit/widget coverage for sidechain-prompt relabel (never "you"), folding into the Agent card (collapsed hides, expand reveals), suppression of the standalone block, parentUuid attachment with parallel agents, and the orphan fallback. Add a golden for the Agent card with a folded prompt.
Refs: D-78 (interaction zone / display-only conversation widgets; injected/context muting precedent). Related: T-262 (fold-standalone-item-into-its-card pattern).
IMPLEMENTATION NOTE (E Agent-card layered ordering, from streamlining analysis):
With this ticket (fold prompt) and T-262 (merge success result) both landing, the Agent/Task card ends up owning multiple segments: the tool INPUT, the folded PROMPT, and via T-262 the sub-agents final returned RESULT (the Task ToolResultMessage). Define a deliberate layered order when expanded (e.g. call/input prompt returned result) with clear sub-labels/dividers so the Agent card stays readable and does not become a kitchen sink. Coordinate with T-262 (result merge) and T-264 (nesting the whole run): the nested run region vs the returned-result segment must not duplicate the sub-agent output.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:34:02', '2026-06-08 08:45:23', NULL, '561ac6a3308d08c0da27bcb23eb21cc0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -144,6 +144,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- A **sub-agent prompt** is no longer mislabelled as your input: a sidechain
prompt now reads as a muted **`agent prompt`** (never the blue `you`) and folds
into its **Agent/Task card**, collapsed by default. The prompt attaches to the
right card via `parentUuid`, so parallel agents in one turn stay correctly
paired. The sub-agent's work stays visible after the call. (T-263)
- A successful tool call now renders as **one merged card** instead of a
separate call + result pair: a green check sits at the header's right edge and
the output folds in as a colorized code block (Read → file grammar, Bash →
+86 -25
View File
@@ -93,7 +93,7 @@ class _ConversationViewState extends State<ConversationView> {
/// (its tool-use *and* result echo are noise — the prompt + the logged answer
/// cover it), and any permission-prompted tool-use (keep its result — that's
/// the useful answer).
List<ConversationItem> _visibleItems(List<ConversationItem> items) {
List<ConversationItem> _visibleItems(List<ConversationItem> items, Set<String> foldedPromptUuids) {
final hidden = widget.hiddenToolUseIds;
final auqIds = {
for (final it in items)
@@ -114,6 +114,9 @@ class _ConversationViewState extends State<ConversationView> {
bool drop(ConversationItem it) {
if (it is AssistantToolUse) return toolUseDropped(it);
// T-263: a sidechain agent prompt that folded into its Agent card is
// suppressed here so it doesn't also render as a standalone block.
if (it is UserMessage) return foldedPromptUuids.contains(it.uuid);
if (it is ToolResultMessage) {
if (auqIds.contains(it.toolUseId)) return true; // AUQ result echo — noise
// T-262: a successful result whose paired tool-use is going to render
@@ -134,6 +137,40 @@ class _ConversationViewState extends State<ConversationView> {
];
}
/// Resolves which sidechain prompts fold into which Agent/Task card (T-263).
///
/// A sidechain prompt's owner is the Agent tool-use its `parentUuid` branches
/// off — robust when several agents run in parallel in one turn. Falls back to
/// the nearest preceding Agent tool-use when the link can't be resolved.
/// Returns the prompt uuids to suppress and the prompts grouped by the owning
/// tool-use id (so the card can fold them).
({Set<String> foldedPromptUuids, Map<String, List<UserMessage>> promptsByToolUseId}) _agentPromptFold(List<ConversationItem> items) {
final agentByMsgUuid = <String, AssistantToolUse>{
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
};
final folded = <String>{};
final byToolUseId = <String, List<UserMessage>>{};
AssistantToolUse? lastAgent;
for (final it in items) {
if (it is AssistantToolUse && _isAgentTool(it.name)) {
lastAgent = it;
continue;
}
// Only a text user message authored inside a sidechain is an agent
// prompt; harness-injected messages and tool results are not.
if (it is UserMessage && it.isSidechain && !it.injected) {
final viaParent = it.parentUuid != null ? agentByMsgUuid[it.parentUuid] : null;
final owner = viaParent ?? lastAgent;
if (owner != null) {
folded.add(it.uuid);
(byToolUseId[owner.toolUseId] ??= <UserMessage>[]).add(it);
}
}
}
return (foldedPromptUuids: folded, promptsByToolUseId: byToolUseId);
}
void _onChanged() {
if (!mounted) return;
setState(() {});
@@ -148,7 +185,11 @@ class _ConversationViewState extends State<ConversationView> {
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final items = _visibleItems(widget.controller.items);
final allItems = widget.controller.items;
// T-263: resolve sidechain prompts → owning Agent card before culling, so
// the standalone prompt block is suppressed and folded into its card.
final promptFold = _agentPromptFold(allItems);
final items = _visibleItems(allItems, promptFold.foldedPromptUuids);
if (items.isEmpty) {
return ColoredBox(
@@ -161,7 +202,7 @@ class _ConversationViewState extends State<ConversationView> {
// fold a successful result in. Built from the full item list (not the
// visible one — the success result is suppressed from `items`).
final resultByToolUseId = <String, ToolResultMessage>{
for (final it in widget.controller.items)
for (final it in allItems)
if (it is ToolResultMessage) it.toolUseId: it,
};
@@ -183,6 +224,7 @@ class _ConversationViewState extends State<ConversationView> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptFold.promptsByToolUseId,
),
FoldedCluster(:final items) => _ActivityCard(
items: items,
@@ -190,6 +232,7 @@ class _ConversationViewState extends State<ConversationView> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptFold.promptsByToolUseId,
),
};
},
@@ -206,6 +249,10 @@ class _ConversationViewState extends State<ConversationView> {
/// used for the "claude" message card's stripe + label.
const claudeAccent = Color(0xFFD97757);
/// The tool names that launch a sub-agent (sidechain). Claude Code emits
/// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263).
bool _isAgentTool(String name) => name == 'Task' || name == 'Agent';
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({
@@ -214,6 +261,7 @@ class _ConversationTurn extends StatelessWidget {
this.toolUseOutcomes = const <String, bool>{},
this.toolUseById = const <String, AssistantToolUse>{},
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
});
final ConversationItem item;
@@ -227,30 +275,35 @@ class _ConversationTurn extends StatelessWidget {
/// successful result into one merged card (T-262).
final Map<String, ToolResultMessage> resultByToolUseId;
/// Index from an Agent/Task toolUseId → the sidechain prompt(s) it owns, so
/// the Agent card can fold its prompt in (T-263).
final Map<String, List<UserMessage>> promptsByToolUseId;
@override
Widget build(BuildContext context) {
final i = item;
return switch (i) {
UserMessage() => i.injected
// Harness-injected (skill load / command expansion / system
// reminder) — not typed by the user, so de-emphasise: a muted,
// collapsed "context" card rather than the "you" accent (D-78).
? ConversationCard(
variant: ConversationCardVariant.bare,
accent: tokens.globalTextMuted,
label: 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
)
: ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
// Harness-injected (skill load / command expansion / system reminder) and
// sidechain agent prompts are both NOT typed by the user, so de-emphasise:
// a muted, collapsed card rather than the blue "you" accent (D-78). A
// sidechain prompt here is an orphan one (its Agent card couldn't be
// resolved) — folded prompts are suppressed upstream (T-263).
UserMessage() when i.injected || i.isSidechain => ConversationCard(
variant: ConversationCardVariant.bare,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
UserMessage() => ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
AssistantTextMessage() => ConversationCard(
accent: claudeAccent,
label: 'claude',
@@ -356,8 +409,13 @@ class _ConversationTurn extends StatelessWidget {
final result = resultByToolUseId[t.toolUseId];
final succeeded = result != null && !result.isError;
final status = result == null ? ConversationCardStatus.none : (result.isError ? ConversationCardStatus.error : ConversationCardStatus.success);
final segments =
succeeded ? [CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)))] : const <CardSegment>[];
// T-263: an Agent/Task card folds its sub-agent prompt(s) in. Layered order
// when expanded (note E): call input (body) → prompt → returned result.
final segments = <CardSegment>[
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
if (succeeded) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
];
// A resolved permission-prompted call: collapsed, green if approved / red
// if denied — a quiet record of what was permitted (D-78). It still folds
@@ -495,6 +553,7 @@ class _ActivityCard extends StatefulWidget {
required this.toolUseOutcomes,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
});
final List<ConversationItem> items;
@@ -502,6 +561,7 @@ class _ActivityCard extends StatefulWidget {
final Map<String, bool> toolUseOutcomes;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@override
State<_ActivityCard> createState() => _ActivityCardState();
@@ -570,6 +630,7 @@ class _ActivityCardState extends State<_ActivityCard> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.toolUseById,
resultByToolUseId: widget.resultByToolUseId,
promptsByToolUseId: widget.promptsByToolUseId,
),
],
),
+24 -7
View File
@@ -39,11 +39,17 @@ import 'dart:isolate';
/// Discriminated union of conversation items the reader can emit.
sealed class ConversationItem {
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain});
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid});
final String uuid;
final DateTime timestamp;
final bool isSidechain;
/// The uuid of this record's predecessor in its chain (from the transcript
/// envelope's `parentUuid`), or null when absent/empty. A sidechain prompt
/// branches off the assistant message that issued its spawning Agent/Task
/// tool-use, so this links the prompt to the right Agent card (T-263).
final String? parentUuid;
}
/// A user-typed message (plain text, possibly multi-part).
@@ -52,6 +58,7 @@ final class UserMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.text,
this.injected = false,
});
@@ -74,6 +81,7 @@ final class ToolResultMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.toolUseId,
required this.content,
required this.isError,
@@ -93,6 +101,7 @@ final class AssistantTextMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.text,
});
@@ -108,6 +117,7 @@ final class AssistantThinkingMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.thinking,
});
@@ -123,6 +133,7 @@ final class AssistantToolUse extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.toolUseId,
required this.name,
required this.input,
@@ -545,6 +556,8 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
final uuid = envelope['uuid'] as String? ?? '';
final isSidechain = envelope['isSidechain'] as bool? ?? false;
final rawParent = envelope['parentUuid'] as String?;
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
DateTime timestamp;
try {
@@ -555,9 +568,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
switch (type) {
case 'user':
_parseUserInto(envelope, uuid, timestamp, isSidechain, out);
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
case 'assistant':
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, out);
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
_extractAssistantStatus(envelope, status);
default:
break; // unknown type — degrade gracefully
@@ -582,6 +595,7 @@ void _parseUserInto(
String uuid,
DateTime timestamp,
bool isSidechain,
String? parentUuid,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -595,7 +609,7 @@ void _parseUserInto(
if (content is String) {
if (content.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content, injected: injected));
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: content, injected: injected));
}
return;
}
@@ -614,6 +628,7 @@ void _parseUserInto(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
@@ -623,7 +638,7 @@ void _parseUserInto(
}
}
if (textParts.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n'), injected: injected));
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: textParts.join('\n'), injected: injected));
}
}
@@ -632,6 +647,7 @@ void _parseAssistantInto(
String uuid,
DateTime timestamp,
bool isSidechain,
String? parentUuid,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -645,12 +661,12 @@ void _parseAssistantInto(
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) {
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: text));
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: text));
}
case 'thinking':
final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) {
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, thinking: thinking));
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, thinking: thinking));
}
case 'tool_use':
final rawInput = item['input'];
@@ -658,6 +674,7 @@ void _parseAssistantInto(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
@@ -282,6 +282,54 @@ void main() {
expect(find.text('thanks'), findsOneWidget);
});
testWidgets('a sidechain prompt folds into its Agent card; never labelled "you" (T-263)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(
uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore the codebase'}),
UserMessage(uuid: 'p1', timestamp: _t, isSidechain: true, text: 'find all the widgets'),
]);
// Never the blue "you", and no standalone block (folded → suppressed).
expect(find.text('you'), findsNothing);
expect(find.text('agent prompt'), findsNothing); // not a standalone card here
expect(find.text('Task'), findsOneWidget);
// Collapsed by default: the prompt is hidden.
expect(find.text('find all the widgets'), findsNothing);
// Expand the Agent card → a "prompt" segment reveals the folded prompt.
await tester.tap(find.bySemanticsLabel('Expand'));
await tester.pumpAndSettle();
expect(find.text('prompt'), findsOneWidget); // segment sub-label
expect(find.text('find all the widgets'), findsOneWidget);
});
testWidgets('an orphan sidechain prompt renders as muted "agent prompt", never "you" (T-263)', (tester) async {
// No Agent tool-use to attach to → stays standalone, but relabelled.
await pumpWith(tester, [
UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions'),
]);
expect(find.text('you'), findsNothing);
expect(find.text('agent prompt'), findsOneWidget);
});
testWidgets('parallel agents: each prompt attaches to its own card via parentUuid (T-263)', (tester) async {
// Document order scrambles the prompts so a nearest-preceding heuristic
// would misattach BOTH to agent B; parentUuid must route them correctly.
await pumpWith(tester, [
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'agent A'}),
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'agent B'}),
UserMessage(uuid: 'pB', timestamp: _t, isSidechain: true, parentUuid: 'mB', text: 'PROMPT FOR B'),
UserMessage(uuid: 'pA', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'PROMPT FOR A'),
]);
// Two collapsed Agent cards; the first Expand caret belongs to card A.
expect(find.text('you'), findsNothing);
await tester.tap(find.bySemanticsLabel('Expand').first);
await tester.pumpAndSettle();
// Only card A is expanded → its prompt (A) shows; B's stays folded away.
// Nearest-preceding would have put A's prompt under B, revealing nothing.
expect(find.text('PROMPT FOR A'), findsOneWidget);
expect(find.text('PROMPT FOR B'), findsNothing);
});
testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async {
await pumpWith(
tester,
@@ -361,6 +361,23 @@ void main() {
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
});
test('parentUuid is parsed; empty parentUuid normalises to null (T-263)', () {
final withParent = envelope(
type: 'user',
uuid: 'u8',
parentUuid: 'msg-A',
message: {'role': 'user', 'content': 'a sidechain prompt'},
);
final withoutParent = envelope(
type: 'user',
uuid: 'u9',
message: {'role': 'user', 'content': 'top-level'}, // parentUuid defaults to ''
);
final items = parseAll([withParent, withoutParent]);
expect(items[0].parentUuid, 'msg-A');
expect(items[1].parentUuid, isNull); // '' → null
});
});
// -------------------------------------------------------------------------
@@ -51,6 +51,25 @@ void main() {
),
),
),
GoldenTestScenario(
name: 'agent / expanded (call → prompt → result layering, T-263)',
child: _wrap(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Task',
status: ConversationCardStatus.success,
collapsible: true,
collapsedByDefault: false,
body: Text('{ "description": "explore the codebase" }', textDirection: TextDirection.ltr),
extraSegments: [
CardSegment(label: 'prompt', child: Text('find all the widgets and summarise', textDirection: TextDirection.ltr)),
CardSegment(label: 'result', child: Text('found 42 widgets', textDirection: TextDirection.ltr)),
],
),
),
),
GoldenTestScenario(
name: 'error / header mark (call card stays separate from the red result card)',
child: _wrap(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 43 KiB