nest the whole sub-agent run under its Agent card (T-264)

A sub-agent's sidechain run used to spill loose into the main chain,
indistinguishable from main-thread items. Now:

- _sidechainFold routes every sidechain item to its owning Agent/Task
  tool-use by walking the parentUuid chain up to the Agent message it
  branches off (nearest-preceding Agent as fallback) — correct even for
  parallel agents.
- The run (prose / thinking / tool cards) nests in an "agent run"
  ClideHolderCard UNDER the Agent card, suppressed from the top level. The
  prompt still folds into the call (T-263); a successful sidechain tool
  result folds into its own tool card inside the run, so it isn't a
  separate step.
- When a run is shown, the Agent card's returned-result segment is dropped
  (it duplicates the run's final output, note E) — but kept when no run
  was captured, so output is never lost.

Tests: run nesting, returned-result dedup, parallel-run attachment (would
fail under nearest-preceding), and folded-result-not-double-counted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:26:09 +02:00
co-authored by Claude Opus 4.8
parent 5253b7b759
commit db6ecbd5df
5 changed files with 223 additions and 47 deletions
@@ -818,3 +818,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
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-266', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 08:47:43', '2026-06-08 08:47:43', '2026-06-08 08:47:43', NULL, '0eebe9e270a6a7a4b9cccfb9ffbe5dc4', 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-266', 'status', 'in_progress', 'done', NULL, '2026-06-08 09:04:12', '2026-06-08 09:04:12', '2026-06-08 09:04:12', NULL, 'fecc7bc78e18d5dd3c613d6583371fc3', 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-264', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 09:07:02', '2026-06-08 09:07:02', '2026-06-08 09:07:02', NULL, 'd82d792cf5ef8e77c581bea27f2cd8e3', 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-264', 'status', 'in_progress', 'done', NULL, '2026-06-08 09:25:50', '2026-06-08 09:25:50', '2026-06-08 09:25:50', NULL, '274e5d99e4db192a6b4cf6902f12df45', 1) ON CONFLICT(hash) DO NOTHING;
+15
View File
@@ -1957,3 +1957,18 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-'' and watch the list; (b) arrow-select an item and press Tab repeatedly.
Acceptance: ''-'' is treated as a normal command-name character (suggestions keep filtering through hyphens); Tab reliably completes the highlighted suggestion every time (insert via completeSlash). Add/extend unit tests in slash_commands_test.dart for hyphenated queries and a composer widget test for Tab-accept.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 09:20:00', '2026-06-08 09:20:00', NULL, 'a15f416969aef224d838bf28d016b82a', 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-264', 'story', 'T-267', 'Nest the whole sub-agent run under its Agent card (attribute sidechain via parentUuid)', 'Completion of T-263. T-263 folds the sub-agent PROMPT into the Agent card, but the sub-agent run itself — its prose, thinking, and tool calls — still renders inline in the MAIN conversation chain. A reader cannot tell where the sub-agent run ends and the main thread resumes; sidechain items are visually indistinguishable from main-thread items.
Root cause: the conversation is a flat list of ConversationItems rendered by _ConversationTurn (lib/builtin/claude/src/conversation_view.dart build ~L130, _ConversationTurn ~L182). isSidechain exists on every item (transcript_reader.dart ~L46) but no rendering/grouping logic reads it. activity_cluster.dart groups consecutive meta runs but is sidechain-agnostic.
Design: use the parentUuid link (parsed in T-263) to group an ENTIRE sidechain run as a nested region OWNED by its spawning Agent card. The run renders as a distinct, attributed, collapsible region (indent / attribution band / nested holder) under the Agent call rather than as loose cards in the main chain. Per the user rule from T-263, the run results stay VISIBLE (not folded away) this is about attribution and containment, not hiding.
Scope:
- Build a sidechain-aware grouping pass (extend or complement activity_cluster.groupConversation) that collects all items sharing a sidechain ancestry (via parentUuid chain back to the Agent tool_use) into one nested group.
- Render that group as a nested region under the Agent card (visual indent / attribution), keeping the sub-agent results first-class within it.
- Handle parallel agents (each run nests under its own card), nested sub-agents, and background agents.
- Orphan sidechain items (no resolvable parent) degrade to the current inline rendering but still attributed (see T-264/relabel work).
Depends on T-263 (parentUuid parsing + Agent card as container). Pairs with the sidechain-prose relabel ticket. Note the holder-card restyle + auto-scroll race is a separate design discussion (see the holder-card ticket).
Refs: D-78. Builds on T-263. Related: T-262, T-230 (activity card grouping).', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:40:25', '2026-06-08 09:25:50', NULL, 'e3afb1ab7a0530c0b8dbd5775cc3803d', 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's whole run** — its prose, thinking, and tool calls — now nests
in an **`agent run` holder under its Agent card** instead of spilling loose
into the main thread, so you can tell where the sub-agent's work begins and
ends. The run attaches via `parentUuid` (correct even for parallel agents),
and the redundant returned-result is no longer shown twice. (T-264)
- The folded **activity card** now reads as one **container that wraps its
sub-cards**, and you can collapse it by clicking anywhere on the holder's own
background — not just a top header that scrolls out of reach while a run
+139 -47
View File
@@ -94,7 +94,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, Set<String> foldedPromptUuids) {
List<ConversationItem> _visibleItems(List<ConversationItem> items, Set<String> ownedSidechainUuids) {
final hidden = widget.hiddenToolUseIds;
final auqIds = {
for (final it in items)
@@ -114,10 +114,12 @@ class _ConversationViewState extends State<ConversationView> {
}
bool drop(ConversationItem it) {
// T-263/T-264: a sidechain item owned by an Agent run folds into (prompt)
// or nests under (the run) its Agent card — suppress it from the top level
// so it doesn't also render loose in the main chain. Checked first because
// a run's items include AssistantToolUse / ToolResultMessage too.
if (it.isSidechain && ownedSidechainUuids.contains(it.uuid)) return true;
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
@@ -138,38 +140,77 @@ class _ConversationViewState extends State<ConversationView> {
];
}
/// Resolves which sidechain prompts fold into which Agent/Task card (T-263).
/// Resolves how a sub-agent (sidechain) run attaches to its spawning
/// Agent/Task card (T-263 + T-264).
///
/// 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) {
/// Every sidechain item is routed to its owning Agent tool-use by walking its
/// `parentUuid` chain up to the Agent message it branches off — robust when
/// several agents run in parallel in one turn — with the nearest preceding
/// Agent tool-use as a fallback. Returns:
/// - [ownedSidechainUuids]: sidechain item uuids to suppress from the top
/// level (they fold into, or nest under, their Agent card).
/// - [promptsByToolUseId]: the prompt(s) folded into the Agent CALL (T-263).
/// - [runByToolUseId]: the rest of the run — prose / thinking / tool cards —
/// nested in a holder UNDER the Agent card (T-264), with a successful
/// sidechain tool result left out (it folds into its own tool card).
({
Set<String> ownedSidechainUuids,
Map<String, List<UserMessage>> promptsByToolUseId,
Map<String, List<ConversationItem>> runByToolUseId,
}) _sidechainFold(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>>{};
// Envelope-level chain info (consistent across items sharing a uuid).
final parentByUuid = <String, String?>{};
final sidechainByUuid = <String, bool>{};
final toolUseIds = <String>{};
for (final it in items) {
parentByUuid[it.uuid] = it.parentUuid;
sidechainByUuid[it.uuid] = it.isSidechain;
if (it is AssistantToolUse) toolUseIds.add(it.toolUseId);
}
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
var cur = item.uuid;
final seen = <String>{};
while (seen.add(cur)) {
final parent = parentByUuid[cur];
if (parent == null) break;
final agent = agentByMsgUuid[parent];
if (agent != null) return agent; // chain roots at this Agent message
if (sidechainByUuid[parent] != true) break; // left the run's chain
cur = parent;
}
return nearest;
}
final owned = <String>{};
final prompts = <String, List<UserMessage>>{};
final run = <String, List<ConversationItem>>{};
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);
}
if (!it.isSidechain) continue;
if (it is UserMessage && it.injected) continue; // harness noise inside a run
final owner = resolveOwner(it, lastAgent);
if (owner == null) continue; // orphan — rendered inline + attributed
owned.add(it.uuid);
if (it is UserMessage) {
(prompts[owner.toolUseId] ??= <UserMessage>[]).add(it); // folded into the call
} else if (it is ToolResultMessage && !it.isError && toolUseIds.contains(it.toolUseId)) {
// A successful sidechain result folds into its own tool card inside the
// run — owned (suppressed up top) but not a standalone run item.
continue;
} else {
(run[owner.toolUseId] ??= <ConversationItem>[]).add(it);
}
}
return (foldedPromptUuids: folded, promptsByToolUseId: byToolUseId);
return (ownedSidechainUuids: owned, promptsByToolUseId: prompts, runByToolUseId: run);
}
void _onChanged() {
@@ -187,10 +228,11 @@ class _ConversationViewState extends State<ConversationView> {
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
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);
// T-263/T-264: resolve each sidechain run → owning Agent card before
// culling, so the run is suppressed up top and folded into / nested under
// its card.
final fold = _sidechainFold(allItems);
final items = _visibleItems(allItems, fold.ownedSidechainUuids);
if (items.isEmpty) {
return ColoredBox(
@@ -225,7 +267,8 @@ class _ConversationViewState extends State<ConversationView> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptFold.promptsByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
FoldedCluster(:final items) => _ActivityCard(
items: items,
@@ -233,7 +276,8 @@ class _ConversationViewState extends State<ConversationView> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptFold.promptsByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
};
},
@@ -263,6 +307,7 @@ class _ConversationTurn extends StatelessWidget {
this.toolUseById = const <String, AssistantToolUse>{},
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
this.runByToolUseId = const <String, List<ConversationItem>>{},
});
final ConversationItem item;
@@ -280,6 +325,10 @@ class _ConversationTurn extends StatelessWidget {
/// the Agent card can fold its prompt in (T-263).
final Map<String, List<UserMessage>> promptsByToolUseId;
/// Index from an Agent/Task toolUseId → the sidechain run items (prose,
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
final Map<String, List<ConversationItem>> runByToolUseId;
@override
Widget build(BuildContext context) {
final i = item;
@@ -410,21 +459,31 @@ 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);
// T-264: an Agent/Task call nests its whole sub-agent run in a holder below
// the card. When a run is shown, the returned-result segment would just
// duplicate the run's final output, so drop it (note E) — but keep it when
// there's no captured run, so the output is never lost.
final isAgent = _isAgentTool(t.name);
final runItems = isAgent ? (runByToolUseId[t.toolUseId] ?? const <ConversationItem>[]) : const <ConversationItem>[];
final hasRun = runItems.isNotEmpty;
// 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))),
if (succeeded && !(isAgent && hasRun)) 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
// its result + outcome check like any other merged card (T-262).
final outcome = toolUseOutcomes[t.toolUseId];
final ConversationCard card;
if (outcome != null) {
final color = outcome ? tokens.statusSuccess : tokens.statusError;
return ConversationCard(
card = ConversationCard(
variant: ConversationCardVariant.bordered,
accent: color,
borderColor: color,
@@ -437,23 +496,53 @@ class _ConversationTurn extends StatelessWidget {
body: toolInputBody(tokens, t.name, t.input),
extraSegments: segments,
);
} else {
// Per-tool body rendering (T-168): Bash → command block, Edit/Write →
// diff, Read/Grep/LS → path label, others → indented JSON. Always
// collapsible so a bulky write body doesn't dominate the scroll.
card = ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalFocus,
label: t.name,
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _toolUseSummary(t),
status: status,
body: toolInputBody(tokens, t.name, t.input),
extraSegments: segments,
);
}
// Per-tool body rendering (T-168): Bash → command block, Edit/Write → diff,
// Read/Grep/LS → path label, others → indented JSON. Always collapsible so
// a bulky write body doesn't dominate the scroll.
final body = toolInputBody(tokens, t.name, t.input);
final summary = _toolUseSummary(t);
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalFocus,
label: t.name,
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
collapsible: true,
collapsedByDefault: true,
collapsedSummary: summary,
status: status,
body: body,
extraSegments: segments,
if (!hasRun) return card;
// T-264: nest the sub-agent run in a holder UNDER the Agent card, so a
// reader can tell where the sub-agent work begins and ends. The run stays
// VISIBLE (not folded away) — this is attribution + containment.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
card,
Padding(
padding: const EdgeInsets.only(left: 12),
child: ClideHolderCard(
title: 'agent run',
collapsedSummary: _summarizeActivity(runItems.last),
stepLabel: runItems.length == 1 ? '1 step' : '${runItems.length} steps',
children: [
for (final r in runItems)
_ConversationTurn(
item: r,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId,
),
],
),
),
],
);
}
@@ -555,6 +644,7 @@ class _ActivityCard extends StatelessWidget {
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
required this.runByToolUseId,
});
final List<ConversationItem> items;
@@ -563,6 +653,7 @@ class _ActivityCard extends StatelessWidget {
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId;
@override
Widget build(BuildContext context) {
@@ -579,6 +670,7 @@ class _ActivityCard extends StatelessWidget {
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId,
),
],
);
@@ -330,6 +330,69 @@ void main() {
expect(find.text('PROMPT FOR B'), findsNothing);
});
testWidgets('the sub-agent run nests in a holder under its Agent card (T-264)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'explore'}),
UserMessage(uuid: 'p', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'go explore'),
// A sidechain tool call — part of the run, chained off the prompt.
AssistantToolUse(
uuid: 's1', timestamp: _t, isSidechain: true, parentUuid: 'p', toolUseId: 'sb', name: 'Bash', input: const {'command': 'grep widgets'}),
]);
expect(find.text('Task'), findsOneWidget);
// The run is a nested holder titled "agent run", collapsed by default —
// the run's Bash card is not loose in the main chain.
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsOneWidget);
expect(find.text('Bash'), findsNothing); // run card hidden while holder collapsed
await tester.tap(find.bySemanticsLabel('agent run, 1 step, collapsed'));
await tester.pumpAndSettle();
expect(find.text('Bash'), findsOneWidget); // the run's tool card, now nested + visible
});
testWidgets('the returned result is not duplicated when the run is shown (T-264 note E)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'x'}),
UserMessage(uuid: 'p', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'go'),
AssistantTextMessage(uuid: 's1', timestamp: _t, isSidechain: true, parentUuid: 'p', text: 'THE FINAL ANSWER'),
// The Task's returned result (main chain) — equals the run's final prose.
ToolResultMessage(uuid: 'tr', timestamp: _t, isSidechain: false, parentUuid: 'mA', toolUseId: 'tA', content: 'THE FINAL ANSWER', isError: false),
]);
// Expand the Agent card (its "result" segment would show here if kept)…
await tester.tap(find.bySemanticsLabel('Expand'));
await tester.pumpAndSettle();
// …and the nested run.
await tester.tap(find.bySemanticsLabel('agent run, 1 step, collapsed'));
await tester.pumpAndSettle();
// The answer appears once (in the run), not also as a folded result segment.
expect(find.textContaining('THE FINAL ANSWER'), findsOneWidget);
expect(find.text('result'), findsNothing); // no result sub-label on the Agent card
});
testWidgets('parallel agents: each run nests under its own card via parentUuid (T-264)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'A'}),
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'B'}),
AssistantToolUse(uuid: 'sA', timestamp: _t, isSidechain: true, parentUuid: 'mA', toolUseId: 'sbA', name: 'Bash', input: const {'command': 'CMD_A'}),
AssistantToolUse(uuid: 'sB', timestamp: _t, isSidechain: true, parentUuid: 'mB', toolUseId: 'sbB', name: 'Bash', input: const {'command': 'CMD_B'}),
]);
// Correct routing → two separate 1-step runs. A nearest-preceding heuristic
// would pool both under agent B as one 2-step run.
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNWidgets(2));
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
});
testWidgets('a successful sidechain result folds into its run tool card, not a separate step (T-264)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'x'}),
UserMessage(uuid: 'p', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'go'),
AssistantToolUse(uuid: 's1', timestamp: _t, isSidechain: true, parentUuid: 'p', toolUseId: 'sb', name: 'Bash', input: const {'command': 'ls'}),
ToolResultMessage(uuid: 'sr', timestamp: _t, isSidechain: true, parentUuid: 's1', toolUseId: 'sb', content: 'file listing', isError: false),
]);
// The tool call + its success result is ONE step in the run, not two.
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsOneWidget);
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
});
testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async {
await pumpWith(
tester,