diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index c97e2b8e..cb8c6364 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 015d8340..fcd5b0b2 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c8825f..9fb4bffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index ca93d212..8de230f8 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -94,7 +94,7 @@ class _ConversationViewState extends State { /// (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 _visibleItems(List items, Set foldedPromptUuids) { + List _visibleItems(List items, Set ownedSidechainUuids) { final hidden = widget.hiddenToolUseIds; final auqIds = { for (final it in items) @@ -114,10 +114,12 @@ class _ConversationViewState extends State { } 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 { ]; } - /// 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 foldedPromptUuids, Map> promptsByToolUseId}) _agentPromptFold(List 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 ownedSidechainUuids, + Map> promptsByToolUseId, + Map> runByToolUseId, + }) _sidechainFold(List items) { final agentByMsgUuid = { for (final it in items) if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it, }; - final folded = {}; - final byToolUseId = >{}; + // Envelope-level chain info (consistent across items sharing a uuid). + final parentByUuid = {}; + final sidechainByUuid = {}; + final toolUseIds = {}; + 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 = {}; + 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 = {}; + final prompts = >{}; + final run = >{}; 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] ??= []).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] ??= []).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] ??= []).add(it); } } - return (foldedPromptUuids: folded, promptsByToolUseId: byToolUseId); + return (ownedSidechainUuids: owned, promptsByToolUseId: prompts, runByToolUseId: run); } void _onChanged() { @@ -187,10 +228,11 @@ class _ConversationViewState extends State { 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 { 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 { 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 {}, this.resultByToolUseId = const {}, this.promptsByToolUseId = const >{}, + this.runByToolUseId = const >{}, }); final ConversationItem item; @@ -280,6 +325,10 @@ class _ConversationTurn extends StatelessWidget { /// the Agent card can fold its prompt in (T-263). final Map> 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> 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 []) : const []; + 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 = [ for (final p in promptsByToolUseId[t.toolUseId] ?? const []) 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 items; @@ -563,6 +653,7 @@ class _ActivityCard extends StatelessWidget { final Map toolUseById; final Map resultByToolUseId; final Map> promptsByToolUseId; + final Map> runByToolUseId; @override Widget build(BuildContext context) { @@ -579,6 +670,7 @@ class _ActivityCard extends StatelessWidget { toolUseById: toolUseById, resultByToolUseId: resultByToolUseId, promptsByToolUseId: promptsByToolUseId, + runByToolUseId: runByToolUseId, ), ], ); diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 48265170..6a5c7250 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -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,