fix(cli,web): group orphaned subagent trace by parentToolUseId (#1175)

This commit is contained in:
Junmo Kim
2026-07-26 23:01:46 +08:00
committed by GitHub
parent d90bde0b88
commit deb5d63695
8 changed files with 249 additions and 3 deletions
+5
View File
@@ -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(),
@@ -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 = {
@@ -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',
+51
View File
@@ -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',
+7
View File
@@ -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
+92
View File
@@ -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')
})
})
+22 -3
View File
@@ -8,6 +8,7 @@ export type TracedMessage = NormalizedMessage & {
type TracerState = {
promptToTaskId: Map<string, string>
toolUseIdToTaskId: Map<string, string>
uuidToSidechainId: Map<string, string>
orphanMessages: Map<string, NormalizedMessage[]>
}
@@ -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)
+6
View File
@@ -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