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
@@ -3186,3 +3186,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:55', '2026-06-10 13:53:55', '2026-06-10 13:53:55', NULL, '72773d8d91af176305bd6c1b7d5d65be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:54:02', '2026-06-10 13:54:02', '2026-06-10 13:54:02', NULL, '5cb488441033ac5a781d4b7d91dbda6f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'ready', 'done', NULL, '2026-06-10 14:03:10', '2026-06-10 14:03:10', '2026-06-10 14:03:10', NULL, '96365bc3ef7dbf505448be2dad16e4b6', 2) ON CONFLICT(hash) DO NOTHING;
+9
View File
@@ -2582,3 +2582,12 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:55:30', NULL, 'd4022c088022495e2a0bda625a45c636', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 14:03:10', NULL, 'ee474dfe790eb6e72bd6a7381cd900e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+9
View File
@@ -16,6 +16,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Fixed
- **Sub-agent prompts no longer render as a blue "you" card.** In live
(stream-json) sessions a sub-agent's spawning prompt is tagged with
`parent_tool_use_id` rather than the transcript's `isSidechain`/`parentUuid`,
so it slipped past the sidechain fold and showed as a top-level user turn. The
parser now treats `parent_tool_use_id` as a sidechain marker and routes the
item straight to its Agent card by tool-use id. (T-338)
### Added
- **Clickable file references in the Claude conversation.** Workspace file paths
@@ -182,6 +182,13 @@ class _ConversationViewState extends State<ConversationView> {
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
};
// Stream-json tags sidechain items with the spawning Agent's tool-use id
// directly (T-338), so map Agent cards by tool-use id for a direct lookup
// that doesn't depend on the (transcript-only) parentUuid chain.
final agentByToolUseId = <String, AssistantToolUse>{
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.toolUseId: it,
};
// Envelope-level chain info (consistent across items sharing a uuid).
final parentByUuid = <String, String?>{};
final sidechainByUuid = <String, bool>{};
@@ -193,6 +200,13 @@ class _ConversationViewState extends State<ConversationView> {
}
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
// Direct route: stream-json hands us the spawning Agent's tool-use id on
// the item itself (T-338) — no chain to walk.
final byTool = item.parentToolUseId;
if (byTool != null) {
final agent = agentByToolUseId[byTool];
if (agent != null) return agent;
}
var cur = item.uuid;
final seen = <String>{};
while (seen.add(cur)) {
+38 -7
View File
@@ -39,7 +39,7 @@ import 'dart:isolate';
/// Discriminated union of conversation items the reader can emit.
sealed class ConversationItem {
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid});
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid, this.parentToolUseId});
final String uuid;
final DateTime timestamp;
@@ -50,6 +50,14 @@ sealed class ConversationItem {
/// branches off the assistant message that issued its spawning Agent/Task
/// tool-use, so this links the prompt to the right Agent card (T-263).
final String? parentUuid;
/// The `parent_tool_use_id` from the stream-json wire (T-338): the tool-use
/// id of the Agent/Task call that spawned this sub-agent message. Stream-json
/// tags every sidechain item with it — the transcript JSONL instead uses
/// [isSidechain] + [parentUuid]. When present it routes the item straight to
/// its Agent card by tool-use id, no uuid-chain walk needed, and on its own
/// marks the item as a sidechain message.
final String? parentToolUseId;
}
/// A user-typed message (plain text, possibly multi-part).
@@ -59,6 +67,7 @@ final class UserMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.text,
this.injected = false,
});
@@ -82,6 +91,7 @@ final class ToolResultMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.toolUseId,
required this.content,
required this.isError,
@@ -102,6 +112,7 @@ final class AssistantTextMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.text,
});
@@ -118,6 +129,7 @@ final class AssistantThinkingMessage extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.thinking,
});
@@ -134,6 +146,7 @@ final class AssistantToolUse extends ConversationItem {
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.toolUseId,
required this.name,
required this.input,
@@ -555,9 +568,14 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
if (_skipTypes.contains(type)) return;
final uuid = envelope['uuid'] as String? ?? '';
final isSidechain = envelope['isSidechain'] as bool? ?? false;
final rawParent = envelope['parentUuid'] as String?;
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
// Stream-json tags sub-agent messages with `parent_tool_use_id` (the spawning
// Agent/Task tool-use), not the transcript's isSidechain/parentUuid (T-338).
// Treat its presence as a sidechain marker so the fold + de-emphasis kick in.
final rawParentTool = envelope['parent_tool_use_id'] as String?;
final parentToolUseId = (rawParentTool != null && rawParentTool.isNotEmpty) ? rawParentTool : null;
final isSidechain = (envelope['isSidechain'] as bool? ?? false) || parentToolUseId != null;
DateTime timestamp;
try {
@@ -568,9 +586,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
switch (type) {
case 'user':
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
case 'assistant':
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
_extractAssistantStatus(envelope, status);
default:
break; // unknown type — degrade gracefully
@@ -596,6 +614,7 @@ void _parseUserInto(
DateTime timestamp,
bool isSidechain,
String? parentUuid,
String? parentToolUseId,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -609,7 +628,14 @@ void _parseUserInto(
if (content is String) {
if (content.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: content, injected: injected));
out.add(UserMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: content,
injected: injected));
}
return;
}
@@ -629,6 +655,7 @@ void _parseUserInto(
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
@@ -648,6 +675,7 @@ void _parseAssistantInto(
DateTime timestamp,
bool isSidechain,
String? parentUuid,
String? parentToolUseId,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -661,12 +689,14 @@ void _parseAssistantInto(
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) {
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: text));
out.add(AssistantTextMessage(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, text: text));
}
case 'thinking':
final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) {
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, thinking: thinking));
out.add(AssistantThinkingMessage(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, thinking: thinking));
}
case 'tool_use':
final rawInput = item['input'];
@@ -675,6 +705,7 @@ void _parseAssistantInto(
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
@@ -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);
});
});
// -------------------------------------------------------------------------