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:
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -533,6 +533,36 @@ void main() {
|
||||
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: interleaved run items route by parentToolUseId to their own card (T-342)', (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'}),
|
||||
// Sidechain prose for the two agents, interleaved + tagged with the
|
||||
// owning agent's tool-use id (T-338 direct route).
|
||||
AssistantTextMessage(uuid: 'rB', timestamp: _t, isSidechain: true, parentToolUseId: 'tB', text: 'FROM B'),
|
||||
AssistantTextMessage(uuid: 'rA', timestamp: _t, isSidechain: true, parentToolUseId: 'tA', text: 'FROM A'),
|
||||
]);
|
||||
// Each agent gets its own 1-step run, not one pooled 2-step run under the
|
||||
// last-emitted agent — proof the interleaved items routed by their own
|
||||
// parentToolUseId (pooling would show one "2 steps" run, zero "1 step").
|
||||
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNWidgets(2));
|
||||
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: an unattributable sidechain item orphans, not swept into the last agent (T-342)', (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'}),
|
||||
// No parentToolUseId and no rooted parentUuid chain — unattributable.
|
||||
AssistantTextMessage(uuid: 'lost', timestamp: _t, isSidechain: true, text: 'UNROUTED PROSE'),
|
||||
]);
|
||||
// With >1 agent the nearest-agent fallback is dropped, so this orphans and
|
||||
// renders inline as "agent" prose instead of being filed under agent B.
|
||||
expect(find.text('UNROUTED PROSE'), findsOneWidget); // visible inline, not hidden in a collapsed run
|
||||
expect(find.text('agent'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNothing); // neither agent gained a run from it
|
||||
});
|
||||
|
||||
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'}),
|
||||
|
||||
Reference in New Issue
Block a user