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:
@@ -814,3 +814,4 @@ Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to
|
||||
|
||||
Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', NULL, '2026-06-08 08:34:24', '2026-06-08 08:34:24', '2026-06-08 08:34:24', NULL, '499355d06a5e7618a1c32e63a6cd1bcd', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-277', 'parent_id', NULL, 'T-276', NULL, '2026-06-08 08:34:47', '2026-06-08 08:34:47', '2026-06-08 08:34:47', NULL, '67d78cbf1e92166d60e45b3f51ed440e', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-263', 'status', 'in_progress', 'done', NULL, '2026-06-08 08:45:23', '2026-06-08 08:45:23', '2026-06-08 08:45:23', NULL, '7e18bb85a36603663881ebd55d77d308', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1846,3 +1846,30 @@ Scope:
|
||||
Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to a label (e.g. ''ui-tweak''); file that migration as a follow-up — do NOT auto-close T-276 (it''s marked never-close).
|
||||
|
||||
Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', 'backlog', 'medium', NULL, NULL, 'D-3', '2026-06-08 08:32:30', '2026-06-08 08:34:47', NULL, 'da5dd0bb879b9d088e009c735700c935', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-263', 'story', 'T-267', 'Fold sub-agent prompt into the Agent card; relabel from you to agent prompt', 'When Claude launches a sub-agent (the Agent/Task tool), the sub-agent conversation runs as a SIDECHAIN. The prompt Claude wrote for that sub-agent comes through the transcript as a UserMessage with isSidechain=true but injected=false. The conversation view does not look at isSidechain, so it renders that prompt with the blue "you" label exactly like real user input — FALSELY implying the user sent it. It sits as a standalone block below the Agent tool-use card. This is misleading: the user did not write that prompt.
|
||||
|
||||
Confirmed facts from the code: every transcript envelope carries BOTH isSidechain AND parentUuid (transcript_reader.dart ~L547 parses isSidechain but never USES it; parentUuid is dropped entirely). The Agent/Task tool-use is rendered by _toolUse in lib/builtin/claude/src/conversation_view.dart (only AskUserQuestion is special-cased today). The "you" vs "context" label is chosen in conversation_view.dart (~L200): UserMessage with injected=true → "context" (muted); else → "you" (blue, tokens.globalFocus). isSidechain is currently never read by any rendering/grouping logic.
|
||||
|
||||
Design (confirmed with user):
|
||||
1. Never label a sidechain prompt "you". A UserMessage with isSidechain=true is an AGENT PROMPT, not user input — label it "agent prompt" with muted / de-emphasized styling (same precedent as injected "context" messages, D-78). Never the blue "you" treatment.
|
||||
2. Fold the prompt INTO the Agent tool-use card above it, COLLAPSED BY DEFAULT. The Agent card becomes the container that owns its prompt: expand the card to reveal the prompt; collapsed (default) it is hidden. The standalone prompt block is SUPPRESSED from the list — same fold-a-standalone-item-into-its-card pattern as T-262.
|
||||
3. Prompt folds into the CALL, not the results. Only the prompt folds into the collapsed Agent call card. The sub-agent work (its thinking / tool calls / results) stays VISIBLE as the results chain after the call — not buried. Shape: [collapsed Agent call, owning the prompt] then [the run results], with no misleading "you" block anywhere. Agent prompts are valuable enough to keep with the call rather than fold into the subsequent results chain.
|
||||
4. Link the prompt to the RIGHT Agent card via parentUuid. Parse parentUuid (currently dropped) so each sidechain prompt attaches to its spawning Agent tool-use. This matters when several agents run in parallel in one turn — a positional "nearest preceding Agent" heuristic would misattach; the parent link is the robust route. Heuristic only as a fallback when the link cannot be resolved.
|
||||
|
||||
Touch points:
|
||||
- transcript_reader.dart: capture parentUuid from the envelope and expose it on ConversationItem (it is present in the JSONL, just not parsed).
|
||||
- conversation_view.dart: relabel sidechain UserMessage to "agent prompt" (stop the "you" path); fold it into the Agent card body; special-case the Agent/Task tool name (today only AskUserQuestion is special-cased); suppress the standalone block.
|
||||
- activity_cluster.dart: ensure the suppressed prompt is not double-counted in grouping; leave the sidechain results in the chain.
|
||||
|
||||
Edge cases:
|
||||
- Parallel agents in one turn → each prompt attaches to its own card (the reason for parentUuid).
|
||||
- Orphan prompt (no resolvable parent) → still render as "agent prompt", muted/collapsed, never "you".
|
||||
- Nested / background sub-agents.
|
||||
|
||||
Tests: unit/widget coverage for sidechain-prompt relabel (never "you"), folding into the Agent card (collapsed hides, expand reveals), suppression of the standalone block, parentUuid attachment with parallel agents, and the orphan fallback. Add a golden for the Agent card with a folded prompt.
|
||||
|
||||
Refs: D-78 (interaction zone / display-only conversation widgets; injected/context muting precedent). Related: T-262 (fold-standalone-item-into-its-card pattern).
|
||||
|
||||
IMPLEMENTATION NOTE (E — Agent-card layered ordering, from streamlining analysis):
|
||||
|
||||
With this ticket (fold prompt) and T-262 (merge success result) both landing, the Agent/Task card ends up owning multiple segments: the tool INPUT, the folded PROMPT, and — via T-262 — the sub-agents final returned RESULT (the Task ToolResultMessage). Define a deliberate layered order when expanded (e.g. call/input → prompt → returned result) with clear sub-labels/dividers so the Agent card stays readable and does not become a kitchen sink. Coordinate with T-262 (result merge) and T-264 (nesting the whole run): the nested run region vs the returned-result segment must not duplicate the sub-agent output.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:34:02', '2026-06-08 08:45:23', NULL, '561ac6a3308d08c0da27bcb23eb21cc0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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);
|
||||
|
||||
@@ -144,6 +144,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Changed
|
||||
|
||||
- A **sub-agent prompt** is no longer mislabelled as your input: a sidechain
|
||||
prompt now reads as a muted **`agent prompt`** (never the blue `you`) and folds
|
||||
into its **Agent/Task card**, collapsed by default. The prompt attaches to the
|
||||
right card via `parentUuid`, so parallel agents in one turn stay correctly
|
||||
paired. The sub-agent's work stays visible after the call. (T-263)
|
||||
- A successful tool call now renders as **one merged card** instead of a
|
||||
separate call + result pair: a green check sits at the header's right edge and
|
||||
the output folds in as a colorized code block (Read → file grammar, Bash →
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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>{},
|
||||
|
||||
@@ -282,6 +282,54 @@ void main() {
|
||||
expect(find.text('thanks'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a sidechain prompt folds into its Agent card; never labelled "you" (T-263)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(
|
||||
uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore the codebase'}),
|
||||
UserMessage(uuid: 'p1', timestamp: _t, isSidechain: true, text: 'find all the widgets'),
|
||||
]);
|
||||
// Never the blue "you", and no standalone block (folded → suppressed).
|
||||
expect(find.text('you'), findsNothing);
|
||||
expect(find.text('agent prompt'), findsNothing); // not a standalone card here
|
||||
expect(find.text('Task'), findsOneWidget);
|
||||
// Collapsed by default: the prompt is hidden.
|
||||
expect(find.text('find all the widgets'), findsNothing);
|
||||
|
||||
// Expand the Agent card → a "prompt" segment reveals the folded prompt.
|
||||
await tester.tap(find.bySemanticsLabel('Expand'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('prompt'), findsOneWidget); // segment sub-label
|
||||
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, [
|
||||
UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions'),
|
||||
]);
|
||||
expect(find.text('you'), findsNothing);
|
||||
expect(find.text('agent prompt'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: each prompt attaches to its own card via parentUuid (T-263)', (tester) async {
|
||||
// Document order scrambles the prompts so a nearest-preceding heuristic
|
||||
// would misattach BOTH to agent B; parentUuid must route them correctly.
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'agent A'}),
|
||||
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'agent B'}),
|
||||
UserMessage(uuid: 'pB', timestamp: _t, isSidechain: true, parentUuid: 'mB', text: 'PROMPT FOR B'),
|
||||
UserMessage(uuid: 'pA', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'PROMPT FOR A'),
|
||||
]);
|
||||
// Two collapsed Agent cards; the first Expand caret belongs to card A.
|
||||
expect(find.text('you'), findsNothing);
|
||||
await tester.tap(find.bySemanticsLabel('Expand').first);
|
||||
await tester.pumpAndSettle();
|
||||
// Only card A is expanded → its prompt (A) shows; B's stays folded away.
|
||||
// Nearest-preceding would have put A's prompt under B, revealing nothing.
|
||||
expect(find.text('PROMPT FOR A'), findsOneWidget);
|
||||
expect(find.text('PROMPT FOR B'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
|
||||
@@ -361,6 +361,23 @@ void main() {
|
||||
final items = parseAll([raw]);
|
||||
expect(items.first.isSidechain, isTrue);
|
||||
});
|
||||
|
||||
test('parentUuid is parsed; empty parentUuid normalises to null (T-263)', () {
|
||||
final withParent = envelope(
|
||||
type: 'user',
|
||||
uuid: 'u8',
|
||||
parentUuid: 'msg-A',
|
||||
message: {'role': 'user', 'content': 'a sidechain prompt'},
|
||||
);
|
||||
final withoutParent = envelope(
|
||||
type: 'user',
|
||||
uuid: 'u9',
|
||||
message: {'role': 'user', 'content': 'top-level'}, // parentUuid defaults to ''
|
||||
);
|
||||
final items = parseAll([withParent, withoutParent]);
|
||||
expect(items[0].parentUuid, 'msg-A');
|
||||
expect(items[1].parentUuid, isNull); // '' → null
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,25 @@ void main() {
|
||||
),
|
||||
),
|
||||
),
|
||||
GoldenTestScenario(
|
||||
name: 'agent / expanded (call → prompt → result layering, T-263)',
|
||||
child: _wrap(
|
||||
f,
|
||||
const ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: Color(0xFF4C9AFF),
|
||||
label: 'Task',
|
||||
status: ConversationCardStatus.success,
|
||||
collapsible: true,
|
||||
collapsedByDefault: false,
|
||||
body: Text('{ "description": "explore the codebase" }', textDirection: TextDirection.ltr),
|
||||
extraSegments: [
|
||||
CardSegment(label: 'prompt', child: Text('find all the widgets and summarise', textDirection: TextDirection.ltr)),
|
||||
CardSegment(label: 'result', child: Text('found 42 widgets', textDirection: TextDirection.ltr)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
GoldenTestScenario(
|
||||
name: 'error / header mark (call card stays separate from the red result card)',
|
||||
child: _wrap(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 43 KiB |
Reference in New Issue
Block a user