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:
@@ -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', '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-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', '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;
|
||||||
|
|||||||
@@ -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.
|
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);
|
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);
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- 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
|
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
|
background — not just a top header that scrolls out of reach while a run
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
/// (its tool-use *and* result echo are noise — the prompt + the logged answer
|
/// (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
|
/// cover it), and any permission-prompted tool-use (keep its result — that's
|
||||||
/// the useful answer).
|
/// 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 hidden = widget.hiddenToolUseIds;
|
||||||
final auqIds = {
|
final auqIds = {
|
||||||
for (final it in items)
|
for (final it in items)
|
||||||
@@ -114,10 +114,12 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool drop(ConversationItem it) {
|
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);
|
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 (it is ToolResultMessage) {
|
||||||
if (auqIds.contains(it.toolUseId)) return true; // AUQ result echo — noise
|
if (auqIds.contains(it.toolUseId)) return true; // AUQ result echo — noise
|
||||||
// T-262: a successful result whose paired tool-use is going to render
|
// 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
|
/// Every sidechain item is routed to its owning Agent tool-use by walking its
|
||||||
/// off — robust when several agents run in parallel in one turn. Falls back to
|
/// `parentUuid` chain up to the Agent message it branches off — robust when
|
||||||
/// the nearest preceding Agent tool-use when the link can't be resolved.
|
/// several agents run in parallel in one turn — with the nearest preceding
|
||||||
/// Returns the prompt uuids to suppress and the prompts grouped by the owning
|
/// Agent tool-use as a fallback. Returns:
|
||||||
/// tool-use id (so the card can fold them).
|
/// - [ownedSidechainUuids]: sidechain item uuids to suppress from the top
|
||||||
({Set<String> foldedPromptUuids, Map<String, List<UserMessage>> promptsByToolUseId}) _agentPromptFold(List<ConversationItem> items) {
|
/// 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>{
|
final agentByMsgUuid = <String, AssistantToolUse>{
|
||||||
for (final it in items)
|
for (final it in items)
|
||||||
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
|
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
|
||||||
};
|
};
|
||||||
final folded = <String>{};
|
// Envelope-level chain info (consistent across items sharing a uuid).
|
||||||
final byToolUseId = <String, List<UserMessage>>{};
|
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;
|
AssistantToolUse? lastAgent;
|
||||||
for (final it in items) {
|
for (final it in items) {
|
||||||
if (it is AssistantToolUse && _isAgentTool(it.name)) {
|
if (it is AssistantToolUse && _isAgentTool(it.name)) {
|
||||||
lastAgent = it;
|
lastAgent = it;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Only a text user message authored inside a sidechain is an agent
|
if (!it.isSidechain) continue;
|
||||||
// prompt; harness-injected messages and tool results are not.
|
if (it is UserMessage && it.injected) continue; // harness noise inside a run
|
||||||
if (it is UserMessage && it.isSidechain && !it.injected) {
|
final owner = resolveOwner(it, lastAgent);
|
||||||
final viaParent = it.parentUuid != null ? agentByMsgUuid[it.parentUuid] : null;
|
if (owner == null) continue; // orphan — rendered inline + attributed
|
||||||
final owner = viaParent ?? lastAgent;
|
owned.add(it.uuid);
|
||||||
if (owner != null) {
|
if (it is UserMessage) {
|
||||||
folded.add(it.uuid);
|
(prompts[owner.toolUseId] ??= <UserMessage>[]).add(it); // folded into the call
|
||||||
(byToolUseId[owner.toolUseId] ??= <UserMessage>[]).add(it);
|
} 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() {
|
void _onChanged() {
|
||||||
@@ -187,10 +228,11 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final allItems = widget.controller.items;
|
final allItems = widget.controller.items;
|
||||||
// T-263: resolve sidechain prompts → owning Agent card before culling, so
|
// T-263/T-264: resolve each sidechain run → owning Agent card before
|
||||||
// the standalone prompt block is suppressed and folded into its card.
|
// culling, so the run is suppressed up top and folded into / nested under
|
||||||
final promptFold = _agentPromptFold(allItems);
|
// its card.
|
||||||
final items = _visibleItems(allItems, promptFold.foldedPromptUuids);
|
final fold = _sidechainFold(allItems);
|
||||||
|
final items = _visibleItems(allItems, fold.ownedSidechainUuids);
|
||||||
|
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
@@ -225,7 +267,8 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptFold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
|
runByToolUseId: fold.runByToolUseId,
|
||||||
),
|
),
|
||||||
FoldedCluster(:final items) => _ActivityCard(
|
FoldedCluster(:final items) => _ActivityCard(
|
||||||
items: items,
|
items: items,
|
||||||
@@ -233,7 +276,8 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
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.toolUseById = const <String, AssistantToolUse>{},
|
||||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||||
|
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
||||||
});
|
});
|
||||||
|
|
||||||
final ConversationItem item;
|
final ConversationItem item;
|
||||||
@@ -280,6 +325,10 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
/// the Agent card can fold its prompt in (T-263).
|
/// the Agent card can fold its prompt in (T-263).
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i = item;
|
final i = item;
|
||||||
@@ -410,21 +459,31 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
final result = resultByToolUseId[t.toolUseId];
|
final result = resultByToolUseId[t.toolUseId];
|
||||||
final succeeded = result != null && !result.isError;
|
final succeeded = result != null && !result.isError;
|
||||||
final status = result == null ? ConversationCardStatus.none : (result.isError ? ConversationCardStatus.error : ConversationCardStatus.success);
|
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
|
// 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.
|
// when expanded (note E): call input (body) → prompt → returned result.
|
||||||
final segments = <CardSegment>[
|
final segments = <CardSegment>[
|
||||||
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
|
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
|
||||||
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
|
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
|
// A resolved permission-prompted call: collapsed, green if approved / red
|
||||||
// if denied — a quiet record of what was permitted (D-78). It still folds
|
// 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).
|
// its result + outcome check like any other merged card (T-262).
|
||||||
final outcome = toolUseOutcomes[t.toolUseId];
|
final outcome = toolUseOutcomes[t.toolUseId];
|
||||||
|
final ConversationCard card;
|
||||||
if (outcome != null) {
|
if (outcome != null) {
|
||||||
final color = outcome ? tokens.statusSuccess : tokens.statusError;
|
final color = outcome ? tokens.statusSuccess : tokens.statusError;
|
||||||
return ConversationCard(
|
card = ConversationCard(
|
||||||
variant: ConversationCardVariant.bordered,
|
variant: ConversationCardVariant.bordered,
|
||||||
accent: color,
|
accent: color,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
@@ -437,23 +496,53 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
body: toolInputBody(tokens, t.name, t.input),
|
body: toolInputBody(tokens, t.name, t.input),
|
||||||
extraSegments: segments,
|
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
|
if (!hasRun) return card;
|
||||||
// a bulky write body doesn't dominate the scroll.
|
// T-264: nest the sub-agent run in a holder UNDER the Agent card, so a
|
||||||
final body = toolInputBody(tokens, t.name, t.input);
|
// reader can tell where the sub-agent work begins and ends. The run stays
|
||||||
final summary = _toolUseSummary(t);
|
// VISIBLE (not folded away) — this is attribution + containment.
|
||||||
return ConversationCard(
|
return Column(
|
||||||
variant: ConversationCardVariant.bordered,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
accent: tokens.globalFocus,
|
children: [
|
||||||
label: t.name,
|
card,
|
||||||
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
|
Padding(
|
||||||
collapsible: true,
|
padding: const EdgeInsets.only(left: 12),
|
||||||
collapsedByDefault: true,
|
child: ClideHolderCard(
|
||||||
collapsedSummary: summary,
|
title: 'agent run',
|
||||||
status: status,
|
collapsedSummary: _summarizeActivity(runItems.last),
|
||||||
body: body,
|
stepLabel: runItems.length == 1 ? '1 step' : '${runItems.length} steps',
|
||||||
extraSegments: segments,
|
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.toolUseById,
|
||||||
required this.resultByToolUseId,
|
required this.resultByToolUseId,
|
||||||
required this.promptsByToolUseId,
|
required this.promptsByToolUseId,
|
||||||
|
required this.runByToolUseId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ConversationItem> items;
|
final List<ConversationItem> items;
|
||||||
@@ -563,6 +653,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
|
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -579,6 +670,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
|
runByToolUseId: runByToolUseId,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -330,6 +330,69 @@ void main() {
|
|||||||
expect(find.text('PROMPT FOR B'), findsNothing);
|
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 {
|
testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async {
|
||||||
await pumpWith(
|
await pumpWith(
|
||||||
tester,
|
tester,
|
||||||
|
|||||||
Reference in New Issue
Block a user