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:
@@ -3,6 +3,9 @@ import { processLine, createParseState, parseStream } from './stream-parser';
|
||||
import type { ChatEvent } from '../../api/chat/types';
|
||||
import type { StreamParserCallbacks, ParseState } from './stream-parser';
|
||||
|
||||
/** The main agent's accumulated deltas — buffers are keyed by parent_tool_use_id, '' being the agent. */
|
||||
const buffered = (state: ParseState, parent = '') => state.textBuffers.get(parent);
|
||||
|
||||
function makeCallbacks(): { events: ChatEvent[]; sessionIds: string[]; callbacks: StreamParserCallbacks } {
|
||||
const events: ChatEvent[] = [];
|
||||
const sessionIds: string[] = [];
|
||||
@@ -41,7 +44,7 @@ describe('processLine', () => {
|
||||
});
|
||||
processLine(line, state, callbacks);
|
||||
expect(events).toEqual([{ type: 'delta', text: 'hello' }]);
|
||||
expect(state.textBuffer).toBe('hello');
|
||||
expect(buffered(state)).toBe('hello');
|
||||
});
|
||||
|
||||
test('accumulates text buffer across deltas', () => {
|
||||
@@ -54,13 +57,13 @@ describe('processLine', () => {
|
||||
});
|
||||
processLine(mkDelta('hello '), state, callbacks);
|
||||
processLine(mkDelta('world'), state, callbacks);
|
||||
expect(state.textBuffer).toBe('hello world');
|
||||
expect(buffered(state)).toBe('hello world');
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('handles assistant text block — clears text buffer', () => {
|
||||
const state = createParseState();
|
||||
state.textBuffer = 'partial';
|
||||
state.textBuffers.set('', 'partial');
|
||||
const { events, callbacks } = makeCallbacks();
|
||||
const line = JSON.stringify({
|
||||
type: 'assistant',
|
||||
@@ -68,12 +71,12 @@ describe('processLine', () => {
|
||||
});
|
||||
processLine(line, state, callbacks);
|
||||
expect(events).toEqual([{ type: 'text', text: 'full response' }]);
|
||||
expect(state.textBuffer).toBe('');
|
||||
expect(buffered(state)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles assistant tool_use block — flushes text buffer first', () => {
|
||||
const state = createParseState();
|
||||
state.textBuffer = 'thinking...';
|
||||
state.textBuffers.set('', 'thinking...');
|
||||
const { events, callbacks } = makeCallbacks();
|
||||
const line = JSON.stringify({
|
||||
type: 'assistant',
|
||||
@@ -86,7 +89,7 @@ describe('processLine', () => {
|
||||
{ type: 'text', text: 'thinking...' },
|
||||
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'sqlite', toolInput: { query: 'SELECT 1' } },
|
||||
]);
|
||||
expect(state.textBuffer).toBe('');
|
||||
expect(buffered(state)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles assistant tool_use without prior text buffer', () => {
|
||||
@@ -99,9 +102,7 @@ describe('processLine', () => {
|
||||
},
|
||||
});
|
||||
processLine(line, state, callbacks);
|
||||
expect(events).toEqual([
|
||||
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'email_db', toolInput: {} },
|
||||
]);
|
||||
expect(events).toEqual([{ type: 'tool:start', toolCallId: 'call_1', toolName: 'email_db', toolInput: {} }]);
|
||||
});
|
||||
|
||||
test('handles user tool_result with string content', () => {
|
||||
@@ -114,9 +115,7 @@ describe('processLine', () => {
|
||||
},
|
||||
});
|
||||
processLine(line, state, callbacks);
|
||||
expect(events).toEqual([
|
||||
{ type: 'tool:result', toolCallId: 'call_1', output: 'result text', isError: false },
|
||||
]);
|
||||
expect(events).toEqual([{ type: 'tool:result', toolCallId: 'call_1', output: 'result text', isError: false }]);
|
||||
});
|
||||
|
||||
test('handles user tool_result with array content', () => {
|
||||
@@ -140,9 +139,7 @@ describe('processLine', () => {
|
||||
},
|
||||
});
|
||||
processLine(line, state, callbacks);
|
||||
expect(events).toEqual([
|
||||
{ type: 'tool:result', toolCallId: 'call_2', output: 'line 1\nline 2', isError: true },
|
||||
]);
|
||||
expect(events).toEqual([{ type: 'tool:result', toolCallId: 'call_2', output: 'line 1\nline 2', isError: true }]);
|
||||
});
|
||||
|
||||
test('handles system init — captures session id', () => {
|
||||
@@ -155,7 +152,7 @@ describe('processLine', () => {
|
||||
|
||||
test('handles result — sets gotResult, emits cost', () => {
|
||||
const state = createParseState();
|
||||
state.textBuffer = 'trailing';
|
||||
state.textBuffers.set('', 'trailing');
|
||||
const { events, sessionIds, callbacks } = makeCallbacks();
|
||||
const line = JSON.stringify({
|
||||
type: 'result',
|
||||
@@ -170,13 +167,13 @@ describe('processLine', () => {
|
||||
expect(sessionIds).toEqual(['sess_xyz']);
|
||||
expect(events).toEqual([
|
||||
{ type: 'text', text: 'trailing' },
|
||||
{ type: 'result', cost: { inputTokens: 100, outputTokens: 50, totalUSD: 0.003 } },
|
||||
{ type: 'result', cost: { inputTokens: 100, outputTokens: 50, totalUSD: 0.003 }, claudeSessionId: 'sess_xyz' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles error result — flushes buffer and emits error', () => {
|
||||
const state = createParseState();
|
||||
state.textBuffer = 'partial';
|
||||
state.textBuffers.set('', 'partial');
|
||||
const { events, callbacks } = makeCallbacks();
|
||||
const line = JSON.stringify({ type: 'result', is_error: true, result: 'something broke' });
|
||||
processLine(line, state, callbacks);
|
||||
@@ -193,7 +190,87 @@ describe('processLine', () => {
|
||||
const line = JSON.stringify({ type: 'result', is_error: false });
|
||||
processLine(line, state, callbacks);
|
||||
expect(events).toEqual([
|
||||
{ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } },
|
||||
{ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 }, claudeSessionId: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Subagent attribution ──
|
||||
//
|
||||
// The harness stamps every message a subagent produces with `parent_tool_use_id` — the id of the Task
|
||||
// tool call that spawned it. Nothing read it, so a subagent's prose arrived as a top-level assistant
|
||||
// message and its tool calls looked like the main agent's. Worse, its deltas landed in the one shared
|
||||
// text buffer, so a flush emitted both voices concatenated into a single message.
|
||||
|
||||
test('stamps subagent output with its parent Task call', () => {
|
||||
const state = createParseState();
|
||||
const { events, callbacks } = makeCallbacks();
|
||||
processLine(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
parent_tool_use_id: 'call_task',
|
||||
message: { content: [{ type: 'text', text: 'searched 40 files' }] },
|
||||
}),
|
||||
state,
|
||||
callbacks,
|
||||
);
|
||||
processLine(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
parent_tool_use_id: 'call_task',
|
||||
message: { content: [{ type: 'tool_result', tool_use_id: 'call_grep', content: 'hit', is_error: false }] },
|
||||
}),
|
||||
state,
|
||||
callbacks,
|
||||
);
|
||||
expect(events).toEqual([
|
||||
{ type: 'text', text: 'searched 40 files', parentToolUseId: 'call_task' },
|
||||
{ type: 'tool:result', toolCallId: 'call_grep', output: 'hit', isError: false, parentToolUseId: 'call_task' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('the main agent and a subagent buffer their deltas separately', () => {
|
||||
const state = createParseState();
|
||||
const { events, callbacks } = makeCallbacks();
|
||||
const delta = (text: string, parent?: string) =>
|
||||
JSON.stringify({
|
||||
type: 'stream_event',
|
||||
...(parent ? { parent_tool_use_id: parent } : {}),
|
||||
event: { type: 'content_block_delta', delta: { type: 'text_delta', text } },
|
||||
});
|
||||
|
||||
processLine(delta('I will '), state, callbacks);
|
||||
processLine(delta('reading the ', 'call_task'), state, callbacks);
|
||||
processLine(delta('delegate.'), state, callbacks);
|
||||
processLine(delta('config now.', 'call_task'), state, callbacks);
|
||||
|
||||
expect(buffered(state)).toBe('I will delegate.');
|
||||
expect(buffered(state, 'call_task')).toBe('reading the config now.');
|
||||
|
||||
// And a tool call by the subagent flushes only the subagent's sentence.
|
||||
processLine(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
parent_tool_use_id: 'call_task',
|
||||
message: { content: [{ type: 'tool_use', id: 'call_read', name: 'Read', input: {} }] },
|
||||
}),
|
||||
state,
|
||||
callbacks,
|
||||
);
|
||||
expect(events.at(-2)).toEqual({ type: 'text', text: 'reading the config now.', parentToolUseId: 'call_task' });
|
||||
expect(buffered(state)).toBe('I will delegate.');
|
||||
});
|
||||
|
||||
test('a result flushes every buffer, subagents included', () => {
|
||||
const state = createParseState();
|
||||
const { events, callbacks } = makeCallbacks();
|
||||
state.textBuffers.set('', 'main said this');
|
||||
state.textBuffers.set('call_task', 'subagent said this');
|
||||
|
||||
processLine(JSON.stringify({ type: 'result', is_error: false }), state, callbacks);
|
||||
|
||||
expect(events.slice(0, 2)).toEqual([
|
||||
{ type: 'text', text: 'main said this' },
|
||||
{ type: 'text', text: 'subagent said this', parentToolUseId: 'call_task' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user