claude: give each spawned subagent its own collapsing card (T-342)

A fan-out of N agents (Task/Agent) merged into one shared "Activity / N
steps" cluster — groupConversation folded an Agent spawn like any Bash/
Read call. Now an Agent spawn is a cluster boundary, rendering as its own
first-class collapsing card (reusing the existing sticky-agent path: folded
prompt T-263 + nested run T-264), while adjacent non-agent foldables keep
clustering into the normal Activity card.

Two changes:
- activity_cluster: a shared isAgentTool() predicate; _isFoldable returns
  false for agent spawns at every level (incl. L3), so parallel agents
  never merge. Only the grouping boundary changes; fold mechanics are
  unchanged.
- conversation_view: harden resolveOwner. Its nearest-preceding-agent
  fallback is safe with one agent but mis-routes under a parallel fan-out
  (an unattributable item lands in whichever agent was emitted last —
  a sibling's card). With >1 agent, drop the fallback so the item orphans
  (rendered inline) instead of cross-attributed. The T-338 direct route
  (parent_tool_use_id) still attributes interleaved items correctly.

Tests: two consecutive agents → two cards (not one cluster); agent breaks
a sibling cluster; agents first-class at L3; regression — consecutive
Bash still one cluster; interleaved parallel-agent runs route to their own
card; an unattributable item orphans instead of being swept into the last
agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:57:21 +02:00
co-authored by Claude Opus 4.8
parent ce200765cc
commit 4c33a85bf0
7 changed files with 135 additions and 4 deletions
@@ -155,4 +155,34 @@ void main() {
expect(out.map((g) => g.runtimeType.toString()), ['StickyItem', 'StickyItem', 'StickyItem']);
});
});
group('agent spawns are their own card (T-342)', () {
test('two consecutive Agent spawns yield two separate cards, not one cluster', () {
final groups = groupConversation([_tool('1', 'Task'), _tool('2', 'Task')], FoldLevel.tools);
expect(groups, hasLength(2));
expect(groups.every((g) => g is StickyItem), isTrue);
});
test('an agent spawn breaks an Activity cluster of sibling tools', () {
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _tool('2', 'Task'), _tool('3', 'Bash'), _result('3')], FoldLevel.tools);
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem', 'FoldedCluster']);
expect(((groups[1] as StickyItem).item as AssistantToolUse).name, 'Task');
});
test("the SDK 'Agent' tool is treated as an agent spawn too", () {
expect(groupConversation([_tool('1', 'Agent')], FoldLevel.tools).single, isA<StickyItem>());
});
test('agents stay first-class even at L3 (everything), so parallel agents never merge', () {
final groups = groupConversation([_tool('1', 'Task'), _tool('2', 'Task')], FoldLevel.everything);
expect(groups, hasLength(2));
expect(groups.every((g) => g is StickyItem), isTrue);
});
test('regression: consecutive Bash calls still form one Activity cluster', () {
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
expect(groups, hasLength(1));
expect((groups.single as FoldedCluster).items, hasLength(4));
});
});
}