mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(cli,web): group orphaned subagent trace by parentToolUseId (#1175)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user