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:
@@ -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