attribute subagent output to the task that spawned it

the harness stamps every message a subagent produces with parent_tool_use_id.
the sidecar wrote it outgoing and nothing ever read it coming back, so a
subagent's prose and tool calls were spliced into the main transcript as if the
agent you are talking to had produced them — and worse, its deltas were appended
to the same text buffer, so two voices were concatenated inside one bubble.

both buffering layers (stream-parser's textBuffer and turn-stream's buffer) are
now maps keyed by parent, and parentToolUseId rides on ChatEvent, ServerMessage
and Message. useChat nests parented output under the Task row that spawned it;
ToolActivity draws the trace inside the expanded panel.

background tasks get the same treatment from the other end: task:started and
task:notification were two unrelated fake assistant bubbles minutes apart, and
are now one role:'task' row correlated by taskId that appears pending and
resolves in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 01:48:21 +00:00
co-authored by Claude Opus 5
parent 96ceb3aca6
commit 7b6ca5f4ca
10 changed files with 470 additions and 144 deletions
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { Volume2, Loader2, Square } from 'lucide-react';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash } from 'lucide-react';
import type { ChatMessage } from '../types';
import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
@@ -13,7 +13,9 @@ import { CopyButton } from './CopyButton';
const sanitizeSchema = {
...defaultSchema,
tagNames: (defaultSchema.tagNames ?? []).filter((tag) => tag !== 'script' && tag !== 'iframe' && tag !== 'object' && tag !== 'embed' && tag !== 'form'),
tagNames: (defaultSchema.tagNames ?? []).filter(
(tag) => tag !== 'script' && tag !== 'iframe' && tag !== 'object' && tag !== 'embed' && tag !== 'form',
),
attributes: {
...defaultSchema.attributes,
'*': (defaultSchema.attributes?.['*'] ?? []).filter((attr) => typeof attr === 'string' && !attr.startsWith('on')),
@@ -80,8 +82,14 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
const url = getRawUrl(audioPath, audioRoot);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setState('idle'); };
audio.onerror = () => { audioRef.current = null; setState('idle'); };
audio.onended = () => {
audioRef.current = null;
setState('idle');
};
audio.onerror = () => {
audioRef.current = null;
setState('idle');
};
await audio.play();
setState('playing');
} catch {
@@ -140,7 +148,10 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
<div className="max-w-[85%]">
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
<div className="chat-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
>
{injectImages(assistantText)}
</ReactMarkdown>
</div>
@@ -169,6 +180,9 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
</div>
);
case 'task':
return <TaskActivity message={message} />;
case 'error':
return (
<div className="flex justify-start group">
@@ -183,6 +197,40 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
}
};
/**
* A background task (run_in_background / Monitor), start to finish, in one row. It renders pending the
* moment the task starts and resolves in place when the notification lands — which can be long after the
* turn that started it ended. It used to be two unrelated assistant bubbles a screenful apart, because
* both sides discarded the taskId that relates them.
*/
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
const { status, summary, description, taskType } = message;
const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
const tone =
status === 'completed'
? 'text-green-600'
: status === 'failed'
? 'text-red-600'
: status === 'stopped'
? 'text-duck-dark/50'
: 'text-amber-500';
return (
<div className="my-1 flex items-start gap-2 px-3 py-1.5 rounded-md bg-duck-dark/5 text-sm">
<Icon className={`h-4 w-4 shrink-0 mt-0.5 ${tone} ${status ? '' : 'animate-pulse'}`} />
<div className="min-w-0 flex-1">
<div className="text-duck-dark/80">
{description || 'Background task'}
{taskType && <span className="text-duck-dark/40 text-xs ml-2">{taskType}</span>}
</div>
<div className="text-duck-dark/50 text-xs">
{status ? summary || `Task ${status}.` : 'Running in the background…'}
</div>
</div>
</div>
);
};
type StreamingBubbleProps = {
text: string;
};
@@ -13,6 +13,7 @@ function firstMessageKey(m: ChatMessage | undefined): string {
if (!m) return '';
if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`;
if (m.role === 'tool') return `t:${m.toolCallId}`;
if (m.role === 'task') return `k:${m.taskId}`;
return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`;
}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react';
import type { ChatMessage } from '../types';
import { CopyButton } from './CopyButton';
@@ -18,6 +18,7 @@ const toolIcons: Record<string, typeof FileText> = {
Glob: Search,
WebFetch: Globe,
WebSearch: Globe,
Task: Bot,
};
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
@@ -35,6 +36,13 @@ function getToolSummary(toolName: string, toolInput: Record<string, unknown>): s
return (toolInput.url as string) ?? '';
case 'WebSearch':
return (toolInput.query as string) ?? '';
case 'Task': {
// `description` is the short label; the prompt is the essay. Showing the essay made every subagent
// row look identical for its first eighty characters.
const description = (toolInput.description as string) ?? '';
const type = (toolInput.subagent_type as string) ?? '';
return type ? `${description} · ${type}` : description;
}
default:
return (
Object.values(toolInput)
@@ -55,6 +63,7 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
const summary = getToolSummary(message.toolName, message.toolInput);
const pending = message.output === undefined;
const isError = message.isError === true;
const children = message.children ?? [];
return (
<div className="my-1">
@@ -66,7 +75,12 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
<span className="shrink-0">
<span className="shrink-0 flex items-center gap-2">
{children.length > 0 && (
<span className="text-duck-dark/40 text-[10px]">
{children.length} step{children.length === 1 ? '' : 's'}
</span>
)}
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
@@ -79,9 +93,13 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
<div className="flex items-center justify-between mb-1">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Input</div>
<CopyButton
text={message.toolName === 'Bash'
? (message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)
: Object.entries(message.toolInput).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join('\n')}
text={
message.toolName === 'Bash'
? ((message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2))
: Object.entries(message.toolInput)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join('\n')
}
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
/>
</div>
@@ -98,11 +116,21 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
)}
</div>
{children.length > 0 && (
<div className="rounded-md border-l-2 border-duck-teal/30 pl-2">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Subagent</div>
<SubagentTrace messages={children} />
</div>
)}
{message.output !== undefined && (
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
<div className="flex items-center justify-between mb-1">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Output</div>
<CopyButton text={message.output} className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100" />
<CopyButton
text={message.output}
className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100"
/>
</div>
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
</div>
@@ -113,6 +141,24 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
);
};
/**
* What a subagent did, in order — its own tool calls nested one level further, its prose as plain text.
* Deliberately not markdown-rendered: this is a trace, and it sits inside an already-nested panel.
*/
const SubagentTrace = ({ messages }: { messages: ChatMessage[] }) => (
<div className="space-y-1">
{messages.map((m, i) =>
m.role === 'tool' ? (
<ToolActivity key={m.toolCallId || i} message={m} />
) : m.role === 'assistant' ? (
<div key={m.id ?? i} className="text-duck-dark/60 whitespace-pre-wrap px-3 py-1">
{m.text}
</div>
) : null,
)}
</div>
);
type ToolOutputProps = {
toolName: string;
output: string;
@@ -132,7 +178,11 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
<>
<pre
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' : 'text-duck-dark/70'
isBash
? 'bg-gray-900 text-green-400'
: isError
? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300'
: 'text-duck-dark/70'
}`}
>
{displayText}
@@ -29,9 +29,25 @@ export type ChatMessage =
toolCallId: string;
output?: string;
isError?: boolean;
/**
* For a `Task` call: everything the subagent said and did, in order. Kept nested rather than
* appended to the transcript because a subagent's work is not the conversation — flattened, its
* prose read as the agent you are talking to having said it, and its file edits looked like yours.
*/
children?: ChatMessage[];
}
| { role: 'result'; cost: MessageCost }
| { role: 'error'; text: string };
| { role: 'error'; text: string }
| {
// A background task (run_in_background / Monitor). One row for its whole life: it appears pending
// and resolves in place, rather than as two unrelated bubbles minutes apart.
role: 'task';
taskId: string;
description: string;
taskType?: string;
status?: 'completed' | 'failed' | 'stopped';
summary?: string;
};
export type TaskInfo = {
taskName: string;
@@ -42,10 +58,16 @@ export type TaskInfo = {
export type ServerMessage =
| { type: 'session:init'; sessionId: string; model: string; cwd: string; context?: string; contextId?: string }
| { type: 'assistant:text'; text: string }
| { type: 'assistant:delta'; text: string }
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
| { type: 'assistant:text'; text: string; parentToolUseId?: string }
| { type: 'assistant:delta'; text: string; parentToolUseId?: string }
| {
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
parentToolUseId?: string;
}
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; parentToolUseId?: string }
| { type: 'result'; sessionId: string; cost: MessageCost }
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
| { type: 'error'; message: string; errorCode?: string }
@@ -66,6 +88,7 @@ export type Message = {
toolInput?: Record<string, unknown>;
output?: string;
isError?: boolean;
parentToolUseId?: string;
};
export type SlashCommand = {
+117 -51
View File
@@ -138,6 +138,60 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
}
/**
* Apply `fn` to the children of the tool call `parentId`, wherever it sits in the transcript. A subagent
* runs for minutes, so its parent Task row is rarely the last message by the time output arrives.
*/
function withChildren(prev: ChatMessage[], parentId: string, fn: (children: ChatMessage[]) => ChatMessage[]) {
let found = false;
const next = prev.map((m) => {
if (found || m.role !== 'tool' || m.toolCallId !== parentId) return m;
found = true;
return { ...m, children: fn(m.children ?? []) };
});
// The Task row itself never arrived (a replay that starts mid-subagent). Dropping the output would be
// worse than showing it unattributed, so fall through to the top level.
return found ? next : null;
}
/**
* Rebuild the nested transcript from the flat `Message[]` a resume replays. The wire format stays flat
* — officer folds messages in arrival order and stamps each with its `parentToolUseId` — so the nesting
* is reconstructed here rather than stored twice.
*/
function rebuildTranscript(messages: Message[]): ChatMessage[] {
const top: ChatMessage[] = [];
const byToolCallId = new Map<string, Extract<ChatMessage, { role: 'tool' }>>();
for (const m of messages) {
let converted: ChatMessage;
if (m.role === 'user') {
converted = { role: 'user', text: m.text || '' };
} else if (m.role === 'tool') {
const call: Extract<ChatMessage, { role: 'tool' }> = {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
if (call.toolCallId) byToolCallId.set(call.toolCallId, call);
converted = call;
} else {
converted = { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' };
}
// A subagent's output belongs under the Task row that spawned it. If that row is missing (pruned
// from the replay window) it goes top-level — unattributed beats dropped.
const parent = m.parentToolUseId ? byToolCallId.get(m.parentToolUseId) : undefined;
if (parent) parent.children = [...(parent.children ?? []), converted];
else top.push(converted);
}
return top;
}
function handleMessage(data: unknown) {
const msg = data as ServerMessage;
@@ -169,40 +223,57 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
case 'assistant:delta':
// A subagent's deltas are deliberately not streamed. Two speakers cannot share one cursor, and the
// complete `assistant:text` that follows lands in the Task row a moment later regardless.
if (msg.parentToolUseId) break;
streamingRef.current += msg.text;
flushStreaming();
break;
case 'assistant:text':
case 'assistant:text': {
const parent = msg.parentToolUseId;
if (parent) {
setMessages(
(prev) =>
withChildren(prev, parent, (kids) => [
...kids,
{ role: 'assistant', id: crypto.randomUUID(), text: msg.text },
]) ?? [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }],
);
break;
}
if (streamingRef.current) {
commitStreaming();
} else {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]);
}
break;
}
case 'tool:start':
case 'tool:start': {
toolCallsInTurnRef.current = true;
setMessages((prev) => [
...prev,
{
role: 'tool',
toolName: msg.toolName,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId,
},
]);
break;
case 'tool:result':
const call: ChatMessage = {
role: 'tool',
toolName: msg.toolName,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId,
};
const parent = msg.parentToolUseId;
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
parent ? (withChildren(prev, parent, (kids) => [...kids, call]) ?? [...prev, call]) : [...prev, call],
);
break;
}
case 'tool:result': {
const patch = (m: ChatMessage): ChatMessage =>
m.role === 'tool' && m.toolCallId === msg.toolCallId ? { ...m, output: msg.output, isError: msg.isError } : m;
const parent = msg.parentToolUseId;
setMessages((prev) =>
parent ? (withChildren(prev, parent, (kids) => kids.map(patch)) ?? prev.map(patch)) : prev.map(patch),
);
break;
}
case 'result': {
commitStreaming();
@@ -220,35 +291,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
}
case 'sync:messages':
case 'sync:messages': {
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
// Convert Message[] to ChatMessage[]
const chatMessages = msg.messages.map((m): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
if (chatMessages.length > 0) setHasStarted(true);
const transcript = rebuildTranscript(msg.messages);
setMessages(transcript);
if (transcript.length > 0) setHasStarted(true);
setIsGenerating(msg.isGenerating);
if (msg.streamingText) {
streamingRef.current = msg.streamingText;
flushStreaming();
}
break;
}
case 'error':
commitStreaming();
@@ -271,21 +326,32 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
case 'task:started':
setMessages((prev) => [
...prev,
{ role: 'assistant', id: crypto.randomUUID(), text: `⏳ Background task started — ${msg.description}` },
{ role: 'task', taskId: msg.taskId, description: msg.description, taskType: msg.taskType },
]);
break;
case 'task:notification': {
// The fix in action: a background task's completion arriving after the turn ended.
const icon = msg.status === 'completed' ? '✅' : msg.status === 'failed' ? '❌' : '⏹️';
setMessages((prev) => [
...prev,
{
role: 'assistant',
id: crypto.randomUUID(),
text: `${icon} Background task ${msg.status}${msg.summary}`,
},
]);
// A background task's completion, arriving after the turn ended — the whole point of the
// persistent worker. Resolved onto the row that announced it rather than appended as a second
// bubble: the two used to be minutes and a screenful of scrollback apart, with the taskId that
// relates them thrown away on both sides.
setMessages((prev) => {
const at = prev.findIndex((m) => m.role === 'task' && m.taskId === msg.taskId);
// No announcing row — a replay that starts after `task:started` was pruned. Stand one up.
if (at < 0) {
return [
...prev,
{ role: 'task', taskId: msg.taskId, description: msg.summary, status: msg.status, summary: msg.summary },
];
}
const next = [...prev];
next[at] = {
...(next[at] as Extract<ChatMessage, { role: 'task' }>),
status: msg.status,
summary: msg.summary,
};
return next;
});
break;
}
}