fold sub-agent prompt into the Agent card; relabel you → agent prompt (T-263)

A sidechain sub-agent prompt was rendered with the blue "you" label,
falsely implying the user typed it. Now:

- transcript_reader parses parentUuid (was dropped) onto every
  ConversationItem.
- conversation_view resolves each sidechain prompt to its spawning
  Agent/Task card via parentUuid (nearest-preceding Agent as fallback),
  folds the prompt into that card as a collapsed "prompt" segment, and
  suppresses the standalone block. Layered order when expanded:
  call → prompt → result (note E).
- A sidechain UserMessage never gets the "you" treatment: folded into its
  card, or — when orphaned — rendered as a muted standalone "agent prompt".

Tests: parentUuid parse, fold + suppression, parallel-agent attachment
(would fail under a nearest-preceding heuristic), orphan relabel, and a
golden for the Agent card's call → prompt → result layering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:45:48 +02:00
co-authored by Claude Opus 4.8
parent c6bebcb1cb
commit a7a7b04a05
9 changed files with 227 additions and 32 deletions
+86 -25
View File
@@ -93,7 +93,7 @@ class _ConversationViewState extends State<ConversationView> {
/// (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
/// the useful answer).
List<ConversationItem> _visibleItems(List<ConversationItem> items) {
List<ConversationItem> _visibleItems(List<ConversationItem> items, Set<String> foldedPromptUuids) {
final hidden = widget.hiddenToolUseIds;
final auqIds = {
for (final it in items)
@@ -114,6 +114,9 @@ class _ConversationViewState extends State<ConversationView> {
bool drop(ConversationItem 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 (auqIds.contains(it.toolUseId)) return true; // AUQ result echo — noise
// T-262: a successful result whose paired tool-use is going to render
@@ -134,6 +137,40 @@ class _ConversationViewState extends State<ConversationView> {
];
}
/// Resolves which sidechain prompts fold into which Agent/Task card (T-263).
///
/// A sidechain prompt's owner is the Agent tool-use its `parentUuid` branches
/// off — robust when several agents run in parallel in one turn. Falls back to
/// the nearest preceding Agent tool-use when the link can't be resolved.
/// Returns the prompt uuids to suppress and the prompts grouped by the owning
/// tool-use id (so the card can fold them).
({Set<String> foldedPromptUuids, Map<String, List<UserMessage>> promptsByToolUseId}) _agentPromptFold(List<ConversationItem> items) {
final agentByMsgUuid = <String, AssistantToolUse>{
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
};
final folded = <String>{};
final byToolUseId = <String, List<UserMessage>>{};
AssistantToolUse? lastAgent;
for (final it in items) {
if (it is AssistantToolUse && _isAgentTool(it.name)) {
lastAgent = it;
continue;
}
// Only a text user message authored inside a sidechain is an agent
// prompt; harness-injected messages and tool results are not.
if (it is UserMessage && it.isSidechain && !it.injected) {
final viaParent = it.parentUuid != null ? agentByMsgUuid[it.parentUuid] : null;
final owner = viaParent ?? lastAgent;
if (owner != null) {
folded.add(it.uuid);
(byToolUseId[owner.toolUseId] ??= <UserMessage>[]).add(it);
}
}
}
return (foldedPromptUuids: folded, promptsByToolUseId: byToolUseId);
}
void _onChanged() {
if (!mounted) return;
setState(() {});
@@ -148,7 +185,11 @@ class _ConversationViewState extends State<ConversationView> {
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final items = _visibleItems(widget.controller.items);
final allItems = widget.controller.items;
// T-263: resolve sidechain prompts → owning Agent card before culling, so
// the standalone prompt block is suppressed and folded into its card.
final promptFold = _agentPromptFold(allItems);
final items = _visibleItems(allItems, promptFold.foldedPromptUuids);
if (items.isEmpty) {
return ColoredBox(
@@ -161,7 +202,7 @@ class _ConversationViewState extends State<ConversationView> {
// fold a successful result in. Built from the full item list (not the
// visible one — the success result is suppressed from `items`).
final resultByToolUseId = <String, ToolResultMessage>{
for (final it in widget.controller.items)
for (final it in allItems)
if (it is ToolResultMessage) it.toolUseId: it,
};
@@ -183,6 +224,7 @@ class _ConversationViewState extends State<ConversationView> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptFold.promptsByToolUseId,
),
FoldedCluster(:final items) => _ActivityCard(
items: items,
@@ -190,6 +232,7 @@ class _ConversationViewState extends State<ConversationView> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptFold.promptsByToolUseId,
),
};
},
@@ -206,6 +249,10 @@ class _ConversationViewState extends State<ConversationView> {
/// used for the "claude" message card's stripe + label.
const claudeAccent = Color(0xFFD97757);
/// The tool names that launch a sub-agent (sidechain). Claude Code emits
/// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263).
bool _isAgentTool(String name) => name == 'Task' || name == 'Agent';
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({
@@ -214,6 +261,7 @@ class _ConversationTurn extends StatelessWidget {
this.toolUseOutcomes = const <String, bool>{},
this.toolUseById = const <String, AssistantToolUse>{},
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
});
final ConversationItem item;
@@ -227,30 +275,35 @@ class _ConversationTurn extends StatelessWidget {
/// successful result into one merged card (T-262).
final Map<String, ToolResultMessage> resultByToolUseId;
/// Index from an Agent/Task toolUseId → the sidechain prompt(s) it owns, so
/// the Agent card can fold its prompt in (T-263).
final Map<String, List<UserMessage>> promptsByToolUseId;
@override
Widget build(BuildContext context) {
final i = item;
return switch (i) {
UserMessage() => i.injected
// Harness-injected (skill load / command expansion / system
// reminder) — not typed by the user, so de-emphasise: a muted,
// collapsed "context" card rather than the "you" accent (D-78).
? ConversationCard(
variant: ConversationCardVariant.bare,
accent: tokens.globalTextMuted,
label: 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
)
: ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
// Harness-injected (skill load / command expansion / system reminder) and
// sidechain agent prompts are both NOT typed by the user, so de-emphasise:
// a muted, collapsed card rather than the blue "you" accent (D-78). A
// sidechain prompt here is an orphan one (its Agent card couldn't be
// resolved) — folded prompts are suppressed upstream (T-263).
UserMessage() when i.injected || i.isSidechain => ConversationCard(
variant: ConversationCardVariant.bare,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
UserMessage() => ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
AssistantTextMessage() => ConversationCard(
accent: claudeAccent,
label: 'claude',
@@ -356,8 +409,13 @@ class _ConversationTurn extends StatelessWidget {
final result = resultByToolUseId[t.toolUseId];
final succeeded = result != null && !result.isError;
final status = result == null ? ConversationCardStatus.none : (result.isError ? ConversationCardStatus.error : ConversationCardStatus.success);
final segments =
succeeded ? [CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)))] : const <CardSegment>[];
// 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.
final segments = <CardSegment>[
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
if (succeeded) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
];
// A resolved permission-prompted call: collapsed, green if approved / red
// if denied — a quiet record of what was permitted (D-78). It still folds
@@ -495,6 +553,7 @@ class _ActivityCard extends StatefulWidget {
required this.toolUseOutcomes,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
});
final List<ConversationItem> items;
@@ -502,6 +561,7 @@ class _ActivityCard extends StatefulWidget {
final Map<String, bool> toolUseOutcomes;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@override
State<_ActivityCard> createState() => _ActivityCardState();
@@ -570,6 +630,7 @@ class _ActivityCardState extends State<_ActivityCard> {
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.toolUseById,
resultByToolUseId: widget.resultByToolUseId,
promptsByToolUseId: widget.promptsByToolUseId,
),
],
),
+24 -7
View File
@@ -39,11 +39,17 @@ 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});
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid});
final String uuid;
final DateTime timestamp;
final bool isSidechain;
/// The uuid of this record's predecessor in its chain (from the transcript
/// envelope's `parentUuid`), or null when absent/empty. A sidechain prompt
/// 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;
}
/// A user-typed message (plain text, possibly multi-part).
@@ -52,6 +58,7 @@ final class UserMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.text,
this.injected = false,
});
@@ -74,6 +81,7 @@ final class ToolResultMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.toolUseId,
required this.content,
required this.isError,
@@ -93,6 +101,7 @@ final class AssistantTextMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.text,
});
@@ -108,6 +117,7 @@ final class AssistantThinkingMessage extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.thinking,
});
@@ -123,6 +133,7 @@ final class AssistantToolUse extends ConversationItem {
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
required this.toolUseId,
required this.name,
required this.input,
@@ -545,6 +556,8 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
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;
DateTime timestamp;
try {
@@ -555,9 +568,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
switch (type) {
case 'user':
_parseUserInto(envelope, uuid, timestamp, isSidechain, out);
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
case 'assistant':
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, out);
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
_extractAssistantStatus(envelope, status);
default:
break; // unknown type — degrade gracefully
@@ -582,6 +595,7 @@ void _parseUserInto(
String uuid,
DateTime timestamp,
bool isSidechain,
String? parentUuid,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -595,7 +609,7 @@ void _parseUserInto(
if (content is String) {
if (content.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content, injected: injected));
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: content, injected: injected));
}
return;
}
@@ -614,6 +628,7 @@ void _parseUserInto(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
@@ -623,7 +638,7 @@ void _parseUserInto(
}
}
if (textParts.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n'), injected: injected));
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: textParts.join('\n'), injected: injected));
}
}
@@ -632,6 +647,7 @@ void _parseAssistantInto(
String uuid,
DateTime timestamp,
bool isSidechain,
String? parentUuid,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
@@ -645,12 +661,12 @@ void _parseAssistantInto(
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) {
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: text));
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: text));
}
case 'thinking':
final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) {
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, thinking: thinking));
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, thinking: thinking));
}
case 'tool_use':
final rawInput = item['input'];
@@ -658,6 +674,7 @@ void _parseAssistantInto(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},