fold sub-agent prompts via parent_tool_use_id (T-338)

In live stream-json sessions a sub-agent's spawning prompt is tagged
with parent_tool_use_id, not the transcript JSONL's isSidechain +
parentUuid. The parser ignored that field, so the prompt parsed as a
main-thread user turn and rendered as a blue "you" card above the
Activity Agent card instead of folding into it.

Carry parent_tool_use_id onto ConversationItem; its presence now marks
the item as a sidechain message. The sidechain fold resolves ownership
directly by tool-use id (no transcript-only uuid chain to walk), so the
prompt folds into its Agent card and the run nests under it as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:03:50 +02:00
co-authored by Claude Opus 4.8
parent 026e331146
commit dea3e70e49
7 changed files with 113 additions and 7 deletions
@@ -430,6 +430,24 @@ void main() {
expect(find.text('find all the widgets'), findsOneWidget);
});
testWidgets('a stream-json prompt folds via parentToolUseId, not "you" (T-338)', (tester) async {
// The live stream-json wire flags sub-agent prompts with parent_tool_use_id
// (the Task tool-use id) and NO isSidechain/parentUuid — the prompt must
// still fold into the Agent card rather than render as a blue "you" turn.
await pumpWith(tester, [
AssistantToolUse(uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore'}),
UserMessage(uuid: 'sp', timestamp: _t, isSidechain: true, parentToolUseId: 'task1', text: 'find all the widgets'),
]);
expect(find.text('you'), findsNothing);
expect(find.text('agent prompt'), findsNothing);
expect(find.text('Task'), findsOneWidget);
expect(find.text('find all the widgets'), findsNothing); // folded, collapsed
await tester.tap(find.bySemanticsLabel('Task, 1 step, collapsed'));
await tester.pumpAndSettle();
expect(find.text('find all the widgets'), findsOneWidget);
});
testWidgets('an orphan sidechain prompt renders as muted "agent prompt", never "you" (T-263)', (tester) async {
// No Agent tool-use to attach to → stays standalone, but relabelled.
await pumpWith(tester, [
@@ -378,6 +378,30 @@ void main() {
expect(items[0].parentUuid, 'msg-A');
expect(items[1].parentUuid, isNull); // '' → null
});
test('stream-json parent_tool_use_id marks a sidechain message (T-338)', () {
// The stream-json wire tags sub-agent messages with parent_tool_use_id and
// NO isSidechain flag — we must treat it as a sidechain item anyway.
final raw = envelope(
type: 'user',
uuid: 'sp',
message: {'role': 'user', 'content': 'go explore the codebase'},
)..['parent_tool_use_id'] = 'toolu_task1';
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
expect(items.first.parentToolUseId, 'toolu_task1');
});
test('empty parent_tool_use_id normalises to null and stays main-thread (T-338)', () {
final raw = envelope(
type: 'user',
uuid: 'mt',
message: {'role': 'user', 'content': 'a normal turn'},
)..['parent_tool_use_id'] = '';
final items = parseAll([raw]);
expect(items.first.isSidechain, isFalse);
expect(items.first.parentToolUseId, isNull);
});
});
// -------------------------------------------------------------------------