diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts index e0cb3669..213742df 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -29,6 +29,11 @@ const RawJSONLinesBaseSchema = z.object({ uuid: z.string().optional(), parentUuid: z.string().nullable().optional(), isSidechain: z.boolean().optional(), + // The tool_use id of the Agent/Task tool_use that spawned this sidechain + // message, when present. Preserved (not just consumed) so downstream (web + // tracer) can group sidechain messages directly by this id rather than + // solely by exact-matching a sidechain root's prompt text. + parentToolUseId: z.string().optional(), isMeta: z.boolean().optional(), isCompactSummary: z.boolean().optional(), userType: z.string().optional(), diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 6114480a..08df739a 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -1041,6 +1041,63 @@ describe('SDKToLogConverter', () => { }) }) + describe('Sidechain parentToolUseId preservation (subagent trace grouping fix)', () => { + it('preserves parent_tool_use_id as parentToolUseId on sidechain user messages', () => { + const sdkMessage = { + type: 'user', + parent_tool_use_id: 'toolu_abc123', + message: { role: 'user', content: 'sidechain prompt' } + } as unknown as SDKUserMessage + + const logMessage = converter.convert(sdkMessage) as any + + expect(logMessage?.isSidechain).toBe(true) + expect(logMessage?.parentToolUseId).toBe('toolu_abc123') + }) + + it('preserves parent_tool_use_id on sidechain assistant messages (subagent turns)', () => { + const sdkMessage = { + type: 'assistant', + parent_tool_use_id: 'toolu_abc123', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'subagent reply' }] + } + } as unknown as SDKAssistantMessage + + const logMessage = converter.convert(sdkMessage) as any + + expect(logMessage?.isSidechain).toBe(true) + expect(logMessage?.parentToolUseId).toBe('toolu_abc123') + }) + + it('does not set parentToolUseId on non-sidechain (top-level) messages', () => { + const sdkMessage: SDKUserMessage = { + type: 'user', + message: { role: 'user', content: 'top-level message' } + } + + const logMessage = converter.convert(sdkMessage) as any + + expect(logMessage?.isSidechain).toBe(false) + expect(logMessage?.parentToolUseId).toBeUndefined() + }) + + it('preserves parentToolUseId on interrupted sidechain tool results', () => { + const logMessage = converter.generateInterruptedToolResult('toolu_child', 'toolu_parent') as any + + expect(logMessage?.isSidechain).toBe(true) + expect(logMessage?.parentToolUseId).toBe('toolu_parent') + }) + + it('does not set parentToolUseId on interrupted top-level tool results', () => { + const logMessage = converter.generateInterruptedToolResult('toolu_child') as any + + expect(logMessage?.isSidechain).toBe(false) + expect(logMessage?.parentToolUseId).toBeUndefined() + }) + }) + describe('Convenience function', () => { it('should convert single message without state', () => { const sdkMessage: SDKUserMessage = { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 7184fc5b..a86780fd 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -235,14 +235,22 @@ export class SDKToLogConverter { const timestamp = new Date().toISOString() let parentUuid = this.lastUuid; let isSidechain = false; + // Preserved (not just consumed) so the web tracer can group sidechain + // messages directly by the spawning Agent tool_use id, instead of relying + // solely on the SDK emitting a prompt-holding sidechain root to exact-match + // against. Some subagents (e.g. background/task_started) never emit that + // root, orphaning every child that only carries this id. + let parentToolUseId: string | undefined; if (sdkMessage.parent_tool_use_id) { isSidechain = true; + parentToolUseId = (sdkMessage as any).parent_tool_use_id; parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null; this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid); } const baseFields = { parentUuid: parentUuid, isSidechain: isSidechain, + parentToolUseId, userType: 'external' as const, cwd: this.context.cwd, sessionId: this.context.sessionId, @@ -506,6 +514,7 @@ export class SDKToLogConverter { const logMessage: RawJSONLines = { type: 'user', isSidechain: isSidechain, + parentToolUseId: parentToolUseId ?? undefined, uuid, message: { role: 'user', diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 2ca56e0b..a0bbcac8 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -333,6 +333,57 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('propagates parentToolUseId from a sidechain user output onto the normalized message (subagent trace grouping fix)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'u-orphan-child', + isSidechain: true, + parentToolUseId: 'toolu_broken_agent', + message: { content: 'orphaned subagent turn with no prompt-root' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + parentToolUseId: 'toolu_broken_agent', + }) + }) + + it('propagates parentToolUseId from a sidechain assistant output onto the normalized message (subagent trace grouping fix)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'u-orphan-child-2', + isSidechain: true, + parentToolUseId: 'toolu_broken_agent', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'subagent thinking' }] + } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + parentToolUseId: 'toolu_broken_agent', + }) + }) + it('keeps "No response requested." text in normalized output (filtered later by reducer)', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index ee1802fc..a3ad9d28 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -228,6 +228,7 @@ function normalizeAssistantOutput( const parentUUID = asString(data.parentUuid) ?? null const isSidechain = Boolean(data.isSidechain) const agentTimestamp = parseAgentTimestampMs(data.timestamp) + const parentToolUseId = asString(data.parentToolUseId) ?? null const message = isObject(data.message) ? data.message : null if (!message) return null @@ -269,6 +270,7 @@ function normalizeAssistantOutput( model, role: 'agent', isSidechain, + parentToolUseId, content: blocks, meta, agentTimestamp, @@ -294,6 +296,7 @@ function normalizeUserOutput( const parentUUID = asString(data.parentUuid) ?? null const isSidechain = Boolean(data.isSidechain) const agentTimestamp = parseAgentTimestampMs(data.timestamp) + const parentToolUseId = asString(data.parentToolUseId) ?? null const message = isObject(data.message) ? data.message : null if (!message) return null @@ -307,6 +310,7 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + parentToolUseId, content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }], agentTimestamp } @@ -327,6 +331,7 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + parentToolUseId, content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }], agentTimestamp } @@ -347,6 +352,7 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + parentToolUseId, content: [{ type: 'sidechain', uuid, parentUUID, prompt: textParts.join('\n\n') }], agentTimestamp } @@ -410,6 +416,7 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain, + parentToolUseId, content: blocks, meta, agentTimestamp diff --git a/web/src/chat/tracer.test.ts b/web/src/chat/tracer.test.ts index f57c321f..11cc1cc1 100644 --- a/web/src/chat/tracer.test.ts +++ b/web/src/chat/tracer.test.ts @@ -129,3 +129,95 @@ describe('traceMessages — Agent tool name (regression fix)', () => { expect(scAgentResult!.sidechainId).toBe('msg-agent') }) }) + +// --------------------------------------------------------------------------- +// parentToolUseId direct grouping — fixes the "prompt-root never arrives" +// regression (SDK drops the sidechain root as system/task_started or as a +// top-level parent_tool_use_id:null user message for some subagents; the +// child sidechain messages still all carry parentToolUseId directly). +// --------------------------------------------------------------------------- + +function makeSidechainChildMsg( + id: string, + parentToolUseId: string, + parentUUID: string | null = null, +): NormalizedMessage { + return { + id, + localId: null, + createdAt: 1_700_000_002_000, + role: 'agent', + isSidechain: true, + parentToolUseId, + content: [ + { type: 'text', text: `child of ${parentToolUseId}`, uuid: `uuid-${id}`, parentUUID }, + ], + } as NormalizedMessage +} + +describe('traceMessages — parentToolUseId direct grouping (broken subagent case)', () => { + it('groups an orphaned sidechain child directly via parentToolUseId when no prompt-root sidechain message exists', () => { + const agentMsg = makeToolCallMsg('msg-agent', 'Agent', 'investigate background task') + // No sidechain root carrying the prompt — SDK dropped it as system/task_started + // (filtered) or as a top-level parent_tool_use_id:null user message. The child + // still carries parentToolUseId pointing at the Agent tool_use's id (tc-msg-agent). + const orphanChild = makeSidechainChildMsg('sc-child', 'tc-msg-agent') + + const result = traceMessages([agentMsg, orphanChild]) + const grouped = result.find(m => m.id === 'sc-child') + expect(grouped).toBeDefined() + expect(grouped!.sidechainId).toBe('msg-agent') + }) + + it('groups every descendant independently by parentToolUseId, even when their own parentUuid chain is broken', () => { + const agentMsg = makeToolCallMsg('msg-agent', 'Agent', 'investigate background task') + // First descendant has no resolvable parentUuid (chain seed was lost on resume). + const child1 = makeSidechainChildMsg('sc-child-1', 'tc-msg-agent', null) + // Second descendant chains to the first via parentUuid *and* still carries + // parentToolUseId directly, per real SDK behaviour (every sidechain message + // repeats parent_tool_use_id, not just the root). + const child2 = makeSidechainChildMsg('sc-child-2', 'tc-msg-agent', 'uuid-sc-child-1') + + const result = traceMessages([agentMsg, child1, child2]) + expect(result.find(m => m.id === 'sc-child-1')!.sidechainId).toBe('msg-agent') + expect(result.find(m => m.id === 'sc-child-2')!.sidechainId).toBe('msg-agent') + }) + + it('still falls back to prompt-root matching when parentToolUseId is absent (old stored messages)', () => { + const prompt = 'legacy prompt without parentToolUseId' + const agentMsg = makeToolCallMsg('msg-agent', 'Agent', prompt) + const sidechainRoot = makeSidechainRootMsg('sc-root', prompt) + + const result = traceMessages([agentMsg, sidechainRoot]) + expect(result.find(m => m.id === 'sc-root')!.sidechainId).toBe('msg-agent') + }) + + it('groups nested subagents: a grandchild resolves to its mid-level (sidechain) Agent tool_use', () => { + // Top-level Agent spawns a mid-level subagent; the mid-level subagent is + // itself a sidechain message that carries its own Agent tool_use spawning a + // grandchild. The indexing pass covers tool-calls inside sidechains, so each + // level groups under the correct parent. + const topAgent = makeToolCallMsg('msg-top', 'Agent', 'top-level task') + const midAgent = makeAgentMsg({ + id: 'msg-mid', + isSidechain: true, + parentToolUseId: 'tc-msg-top', + content: [ + { + type: 'tool-call', + id: 'tc-msg-mid', + name: 'Agent', + input: { prompt: 'nested task', subagent_type: 'general-purpose' }, + description: null, + uuid: 'uuid-msg-mid', + parentUUID: null, + }, + ], + }) + const grandchild = makeSidechainChildMsg('sc-grandchild', 'tc-msg-mid') + + const result = traceMessages([topAgent, midAgent, grandchild]) + expect(result.find(m => m.id === 'msg-mid')!.sidechainId).toBe('msg-top') + expect(result.find(m => m.id === 'sc-grandchild')!.sidechainId).toBe('msg-mid') + }) +}) diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts index 41c7523b..82c2dba6 100644 --- a/web/src/chat/tracer.ts +++ b/web/src/chat/tracer.ts @@ -8,6 +8,7 @@ export type TracedMessage = NormalizedMessage & { type TracerState = { promptToTaskId: Map + toolUseIdToTaskId: Map uuidToSidechainId: Map orphanMessages: Map } @@ -28,6 +29,11 @@ function getParentUuid(message: NormalizedMessage): string | null { return null } +function getParentToolUseId(message: NormalizedMessage): string | null { + if (message.role !== 'agent') return null + return message.parentToolUseId ?? null +} + function processOrphans(state: TracerState, parentUuid: string, sidechainId: string): TracedMessage[] { const results: TracedMessage[] = [] const orphans = state.orphanMessages.get(parentUuid) @@ -53,17 +59,19 @@ function processOrphans(state: TracerState, parentUuid: string, sidechainId: str export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { const state: TracerState = { promptToTaskId: new Map(), + toolUseIdToTaskId: new Map(), uuidToSidechainId: new Map(), orphanMessages: new Map() } const results: TracedMessage[] = [] - // Index Task/Agent prompts (including those inside sidechains). + // Index Task/Agent prompts and tool_use ids (including those inside sidechains). for (const message of messages) { if (message.role !== 'agent') continue for (const content of message.content) { if (content.type !== 'tool-call' || !isSubagentToolName(content.name)) continue + state.toolUseIdToTaskId.set(content.id, message.id) const input = content.input if (!isObject(input) || typeof input.prompt !== 'string') continue state.promptToTaskId.set(input.prompt, message.id) @@ -79,9 +87,20 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { const uuid = getMessageUuid(message) const parentUuid = getParentUuid(message) - // Sidechain root matching (prompt == Task.prompt). + // Preferred: every sidechain message (root and descendants alike) carries + // parentToolUseId directly from the SDK — group by that id first. This is + // robust even when the SDK never emits a prompt-holding sidechain root + // (e.g. background/task_started subagents), which otherwise orphans the + // entire subtree under the legacy prompt-match/parentUuid-chain logic below. let sidechainId: string | undefined - if (message.role === 'agent') { + const parentToolUseId = getParentToolUseId(message) + if (parentToolUseId) { + sidechainId = state.toolUseIdToTaskId.get(parentToolUseId) + } + + // Fallback: sidechain root matching (prompt == Task.prompt). Only needed + // for messages stored before parentToolUseId existed. + if (!sidechainId && message.role === 'agent') { for (const content of message.content) { if (content.type !== 'sidechain') continue const taskId = state.promptToTaskId.get(content.prompt) diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 0c0130e6..44161111 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -127,6 +127,12 @@ export type NormalizedMessage = ({ localId: string | null createdAt: number isSidechain: boolean + // The tool_use id of the Agent/Task tool_use that spawned this sidechain + // message (SDK's parent_tool_use_id, preserved end-to-end). The tracer + // groups sidechain messages under their parent Agent card by this id + // directly, falling back to prompt exact-match only for older stored + // messages that predate this field. + parentToolUseId?: string | null meta?: unknown usage?: UsageData status?: MessageStatus