diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 2ff9da52..93e97723 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -771,6 +771,68 @@ describe('MessageList nested tool calls', () => {
expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1'])
})
+ it('keeps task-management tools from downgrading dispatched agents into a mixed tool tree', () => {
+ const messages: UIMessage[] = [
+ {
+ id: 'tool-task-create',
+ type: 'tool_use',
+ toolName: 'TaskCreate',
+ toolUseId: 'task-create-1',
+ input: { subject: 'Review recent changes' },
+ timestamp: 1,
+ },
+ {
+ id: 'tool-task-update',
+ type: 'tool_use',
+ toolName: 'TaskUpdate',
+ toolUseId: 'task-update-1',
+ input: { id: '1', status: 'in_progress' },
+ timestamp: 2,
+ },
+ {
+ id: 'tool-agent-a',
+ type: 'tool_use',
+ toolName: 'Agent',
+ toolUseId: 'agent-a',
+ input: { description: 'Review desktop impact' },
+ timestamp: 3,
+ },
+ {
+ id: 'tool-agent-b',
+ type: 'tool_use',
+ toolName: 'Agent',
+ toolUseId: 'agent-b',
+ input: { description: 'Review runtime impact' },
+ timestamp: 4,
+ },
+ {
+ id: 'tool-agent-child-bash',
+ type: 'tool_use',
+ toolName: 'Bash',
+ toolUseId: 'agent-a-bash',
+ input: { command: 'git status --short' },
+ timestamp: 5,
+ parentToolUseId: 'agent-a',
+ },
+ ]
+
+ const { renderItems, childToolCallsByParent } = buildRenderModel(messages)
+ const toolGroups = renderItems.filter((item) => item.kind === 'tool_group')
+
+ expect(toolGroups).toHaveLength(2)
+ expect(toolGroups[0]?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
+ 'TaskCreate',
+ 'TaskUpdate',
+ ])
+ expect(toolGroups[1]?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
+ 'Agent',
+ 'Agent',
+ ])
+ expect(childToolCallsByParent.get('agent-a')?.map((toolCall) => toolCall.toolUseId)).toEqual([
+ 'agent-a-bash',
+ ])
+ })
+
it('keeps later nested tool calls under their parent after an interleaved user message', () => {
const messages: UIMessage[] = [
{
@@ -967,6 +1029,52 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'View result' })).toBeNull()
})
+ it('shows completed background agent result from the terminal task notification', () => {
+ const resultText = '后台 agent 已经完成:定位到 parentToolUseId 丢失并补齐了 live 事件链。'
+
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: [
+ {
+ id: 'tool-agent',
+ type: 'tool_use',
+ toolName: 'Agent',
+ toolUseId: 'agent-1',
+ input: { description: '排查 subagent UI' },
+ timestamp: 1,
+ },
+ {
+ id: 'result-agent',
+ type: 'tool_result',
+ toolUseId: 'agent-1',
+ content:
+ "Async agent launched successfully.\nagentId: a29934b04b20ed564 (internal ID - do not mention to user. Use SendMessage with to: 'a29934b04b20ed564' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.",
+ isError: false,
+ timestamp: 2,
+ },
+ ],
+ agentTaskNotifications: {
+ 'agent-1': {
+ taskId: 'agent-task-1',
+ toolUseId: 'agent-1',
+ status: 'completed',
+ summary: 'Agent "排查 subagent UI" completed',
+ result: resultText,
+ },
+ },
+ }),
+ },
+ })
+
+ render()
+
+ expect(screen.getByText('Done')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: 'View result' }))
+
+ expect(within(screen.getByRole('dialog')).getByText(resultText)).toBeTruthy()
+ })
+
it('renders copy controls for user messages and scopes assistant copy to a single reply', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index abb44c35..e88fdf57 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -383,6 +383,15 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
pendingToolCalls = []
}
}
+ const appendRootToolCall = (toolCall: ToolCall) => {
+ const nextIsAgent = toolCall.toolName === 'Agent'
+ const pendingIsAgentGroup = pendingToolCalls.every((pendingToolCall) => pendingToolCall.toolName === 'Agent')
+
+ if (pendingToolCalls.length > 0 && pendingIsAgentGroup !== nextIsAgent) {
+ flushGroup()
+ }
+ pendingToolCalls.push(toolCall)
+ }
for (const msg of messages) {
if (msg.type === 'tool_use') {
@@ -418,7 +427,7 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
flushGroup()
items.push({ kind: 'message', message: msg })
} else {
- pendingToolCalls.push(msg)
+ appendRootToolCall(msg)
}
} else {
flushGroup()
diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx
index ada7d953..3fdb5649 100644
--- a/desktop/src/components/chat/ToolCallGroup.tsx
+++ b/desktop/src/components/chat/ToolCallGroup.tsx
@@ -506,6 +506,7 @@ function AgentCallCard({
const statusClassName = getAgentStatusClassName(status)
const statusLabel = getAgentStatusLabel(status, t)
const taskSummary = agentTaskNotification?.summary?.trim() || ''
+ const taskResult = agentTaskNotification?.result?.trim() || ''
const errorText =
status === 'failed'
? taskSummary || (result?.isError ? getAgentErrorSummary(result.content) : '')
@@ -516,7 +517,7 @@ function AgentCallCard({
result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content)
? extractAgentDisplayText(result.content).trim()
: ''
- const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '')
+ const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskResult || taskSummary : '')
const outputSummary = previewText ? getAgentOutputSummary(previewText) : ''
const description = typeof input.description === 'string' ? input.description : ''
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index 8f227742..2434e28f 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -573,7 +573,7 @@ describe('chatStore history mapping', () => {
id: 'task-notification',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
- content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command & agent done\nC:\\Temp\\bg.output\n',
+ content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command & agent done\nDetailed result & next step\nC:\\Temp\\bg.output\n',
},
])
@@ -583,6 +583,7 @@ describe('chatStore history mapping', () => {
toolUseId: 'toolu_bg',
status: 'completed',
summary: 'Background command & agent done',
+ result: 'Detailed result & next step',
outputFile: 'C:\\Temp\\bg.output',
},
})
@@ -967,6 +968,49 @@ describe('chatStore history mapping', () => {
])
})
+ it('retains live parent linkage when only content_start carries the parent id', () => {
+ useChatStore.setState({
+ sessions: {
+ [TEST_SESSION_ID]: makeSession(),
+ },
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'content_start',
+ blockType: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'tool-1',
+ parentToolUseId: 'agent-1',
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'tool_use_complete',
+ toolName: 'Read',
+ toolUseId: 'tool-1',
+ input: { file_path: 'src/App.tsx' },
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'tool_result',
+ toolUseId: 'tool-1',
+ content: 'ok',
+ isError: false,
+ })
+
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
+ {
+ type: 'tool_use',
+ toolUseId: 'tool-1',
+ parentToolUseId: 'agent-1',
+ },
+ {
+ type: 'tool_result',
+ toolUseId: 'tool-1',
+ parentToolUseId: 'agent-1',
+ },
+ ])
+ })
+
it('refreshes merged slash commands when a live CLI update omits project commands', async () => {
const cliCommand = { name: 'builtin-help', description: 'Built-in command' }
const projectCommand = { name: 'project-probe', description: 'Project custom command' }
@@ -1229,6 +1273,7 @@ describe('chatStore history mapping', () => {
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Agent "修复异常处理" completed',
+ result: '修复了异常处理并补充了回归覆盖。',
output_file: '/tmp/agent-output.txt',
},
})
@@ -1242,6 +1287,7 @@ describe('chatStore history mapping', () => {
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent "修复异常处理" completed',
+ result: '修复了异常处理并补充了回归覆盖。',
outputFile: '/tmp/agent-output.txt',
})
})
@@ -1323,6 +1369,7 @@ describe('chatStore history mapping', () => {
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Found and fixed localStorage corruption.',
+ result: 'Root cause was a stale session cache entry.',
output_file: '/tmp/agent-output.txt',
usage: {
total_tokens: 2400,
@@ -1345,6 +1392,7 @@ describe('chatStore history mapping', () => {
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications['agent-tool-1']).toMatchObject({
status: 'completed',
summary: 'Found and fixed localStorage corruption.',
+ result: 'Root cause was a stale session cache entry.',
outputFile: '/tmp/agent-output.txt',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index d6007d6d..921985d3 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -144,6 +144,7 @@ type ChatStore = {
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
const pendingTaskToolUseIdsBySession = new Map>()
+const pendingToolParentUseIdsBySession = new Map>()
function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void {
const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set()
@@ -162,6 +163,34 @@ function consumePendingTaskToolUseId(sessionId: string, toolUseId: string): bool
function clearPendingTaskToolUseIds(sessionId: string): void {
pendingTaskToolUseIdsBySession.delete(sessionId)
}
+
+function rememberPendingToolParentUseId(
+ sessionId: string,
+ toolUseId: string | null | undefined,
+ parentToolUseId: string | undefined,
+): void {
+ if (!toolUseId || !parentToolUseId) return
+ const parentUseIds = pendingToolParentUseIdsBySession.get(sessionId) ?? new Map()
+ parentUseIds.set(toolUseId, parentToolUseId)
+ pendingToolParentUseIdsBySession.set(sessionId, parentUseIds)
+}
+
+function getPendingToolParentUseId(sessionId: string, toolUseId: string): string | undefined {
+ return pendingToolParentUseIdsBySession.get(sessionId)?.get(toolUseId)
+}
+
+function consumePendingToolParentUseId(sessionId: string, toolUseId: string): string | undefined {
+ const parentUseIds = pendingToolParentUseIdsBySession.get(sessionId)
+ if (!parentUseIds) return undefined
+ const parentToolUseId = parentUseIds.get(toolUseId)
+ parentUseIds.delete(toolUseId)
+ if (parentUseIds.size === 0) pendingToolParentUseIdsBySession.delete(sessionId)
+ return parentToolUseId
+}
+
+function clearPendingToolParentUseIds(sessionId: string): void {
+ pendingToolParentUseIdsBySession.delete(sessionId)
+}
const AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS = 160
let msgCounter = 0
@@ -473,6 +502,7 @@ export const useChatStore = create((set, get) => ({
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
}
clearPendingTaskToolUseIds(sessionId)
+ clearPendingToolParentUseIds(sessionId)
wsManager.disconnect(sessionId)
set((s) => {
const { [sessionId]: _, ...rest } = s.sessions
@@ -790,6 +820,7 @@ export const useChatStore = create((set, get) => ({
clearMessages: (sessionId) => {
clearPendingTaskToolUseIds(sessionId)
+ clearPendingToolParentUseIds(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle' })) }))
},
@@ -856,6 +887,7 @@ export const useChatStore = create((set, get) => ({
activeThinkingId: null,
}))
} else if (msg.blockType === 'tool_use') {
+ rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId)
update(() => ({
activeToolUseId: msg.toolUseId ?? null,
activeToolName: msg.toolName ?? null,
@@ -909,11 +941,14 @@ export const useChatStore = create((set, get) => ({
case 'tool_use_complete': {
const session = get().sessions[sessionId]
const toolName = msg.toolName || session?.activeToolName || 'unknown'
+ const toolUseId = msg.toolUseId || session?.activeToolUseId || ''
+ const parentToolUseId = msg.parentToolUseId ?? getPendingToolParentUseId(sessionId, toolUseId)
+ rememberPendingToolParentUseId(sessionId, toolUseId, parentToolUseId)
update((s) => ({
messages: [...s.messages, {
id: nextId(), type: 'tool_use', toolName,
- toolUseId: msg.toolUseId || s.activeToolUseId || '',
- input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
+ toolUseId,
+ input: msg.input, timestamp: Date.now(), parentToolUseId,
}],
activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
}))
@@ -926,11 +961,13 @@ export const useChatStore = create((set, get) => ({
break
}
- case 'tool_result':
+ case 'tool_result': {
+ const pendingParentToolUseId = consumePendingToolParentUseId(sessionId, msg.toolUseId)
+ const parentToolUseId = msg.parentToolUseId ?? pendingParentToolUseId
update((s) => ({
messages: [...s.messages, {
id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
- content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
+ content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId,
}],
chatState: 'thinking', activeThinkingId: null,
}))
@@ -938,6 +975,7 @@ export const useChatStore = create((set, get) => ({
useCLITaskStore.getState().refreshTasks(sessionId)
}
break
+ }
case 'permission_request':
notifyDesktop({
@@ -1122,6 +1160,7 @@ export const useChatStore = create((set, get) => ({
}))
clearPendingDelta(sessionId)
clearPendingTaskToolUseIds(sessionId)
+ clearPendingToolParentUseIds(sessionId)
useCLITaskStore.getState().clearTasks(sessionId)
useSessionStore.getState().updateSessionTitle(sessionId, 'New Session')
useTabStore.getState().updateTabTitle(sessionId, 'New Session')
@@ -1203,6 +1242,7 @@ export const useChatStore = create((set, get) => ({
typeof data.tool_use_id === 'string' && data.tool_use_id.trim()
? data.tool_use_id
: null
+ const taskResult = readNonEmptyString(data, 'result')
const taskStatus = data.status
if (taskEvent) {
const now = Date.now()
@@ -1228,6 +1268,7 @@ export const useChatStore = create((set, get) => ({
toolUseId,
status: taskStatus,
summary: taskEvent.summary,
+ result: taskResult,
outputFile: taskEvent.outputFile,
usage: taskEvent.usage,
},
@@ -1547,12 +1588,14 @@ function extractTaskNotification(content: unknown): AgentTaskNotification | null
const taskId = readXmlTag(xml, 'task-id') || toolUseId
const summary = readXmlTag(xml, 'summary')
+ const result = readXmlTag(xml, 'result')
const outputFile = readXmlTag(xml, 'output-file')
return {
taskId,
toolUseId,
status,
...(summary ? { summary } : {}),
+ ...(result ? { result } : {}),
...(outputFile ? { outputFile } : {}),
}
}
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index cb8b24b6..8503dd65 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -155,6 +155,7 @@ export type AgentTaskNotification = {
toolUseId: string
status: 'completed' | 'failed' | 'stopped'
summary?: string
+ result?: string
outputFile?: string
usage?: BackgroundAgentTaskUsage
timestamp?: string
diff --git a/src/cli/print.ts b/src/cli/print.ts
index 9f320b90..1845f6d5 100644
--- a/src/cli/print.ts
+++ b/src/cli/print.ts
@@ -2032,6 +2032,9 @@ function runHeadlessStreaming(
const summaryMatch = notificationText.match(
/([^<]+)<\/summary>/,
)
+ const resultMatch = notificationText.match(
+ /([\s\S]*?)<\/result>/,
+ )
const isValidStatus = (
s: string | undefined,
@@ -2077,6 +2080,7 @@ function runHeadlessStreaming(
status,
output_file: outputFileMatch?.[1] ?? '',
summary: summaryMatch?.[1] ?? '',
+ result: resultMatch?.[1],
usage:
totalTokensMatch && toolUsesMatch
? {
diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts
index 2d4dadda..e20a749e 100644
--- a/src/entrypoints/sdk/coreSchemas.ts
+++ b/src/entrypoints/sdk/coreSchemas.ts
@@ -1700,6 +1700,7 @@ export const SDKTaskNotificationMessageSchema = lazySchema(() =>
status: z.enum(['completed', 'failed', 'stopped']),
output_file: z.string(),
summary: z.string(),
+ result: z.string().optional(),
usage: z
.object({
total_tokens: z.number(),
diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts
index e7bf617c..27ab6deb 100644
--- a/src/server/__tests__/sessions.test.ts
+++ b/src/server/__tests__/sessions.test.ts
@@ -1732,7 +1732,7 @@ describe('Sessions API', () => {
makeUserEntry('Hello'),
makeAssistantEntry('World'),
makeUserEntry(
- '\nbg-1\ntoolu_bg\nfailed\nBackground command failed & stopped\nC:\\Temp\\bg.output\n',
+ '\nbg-1\ntoolu_bg\nfailed\nBackground command failed & stopped\nStack trace & failed assertion\nC:\\Temp\\bg.output\n',
crypto.randomUUID(),
),
makeAssistantEntry('internal task response'),
@@ -1753,6 +1753,7 @@ describe('Sessions API', () => {
toolUseId: 'toolu_bg',
status: 'failed',
summary: 'Background command failed & stopped',
+ result: 'Stack trace & failed assertion',
outputFile: 'C:\\Temp\\bg.output',
timestamp: expect.any(String),
},
diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts
index 4941182d..2bb0ba01 100644
--- a/src/server/__tests__/ws-memory-events.test.ts
+++ b/src/server/__tests__/ws-memory-events.test.ts
@@ -253,6 +253,69 @@ describe('WebSocket goal command events', () => {
})
describe('WebSocket stream event translation', () => {
+ it('keeps subagent parent linkage when later stream events omit the parent id', () => {
+ const sessionId = `subagent-parent-${crypto.randomUUID()}`
+
+ expect(translateCliMessage({
+ type: 'stream_event',
+ parent_tool_use_id: 'agent-1',
+ event: {
+ type: 'content_block_start',
+ index: 0,
+ content_block: { type: 'tool_use', id: 'read-1', name: 'Read' },
+ },
+ }, sessionId)).toEqual([
+ {
+ type: 'content_start',
+ blockType: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'read-1',
+ parentToolUseId: 'agent-1',
+ },
+ ])
+
+ expect(translateCliMessage({
+ type: 'stream_event',
+ event: {
+ type: 'content_block_delta',
+ index: 0,
+ delta: { type: 'input_json_delta', partial_json: '{"file_path":"src/App.tsx"}' },
+ },
+ }, sessionId)).toEqual([
+ { type: 'content_delta', toolInput: '{"file_path":"src/App.tsx"}' },
+ ])
+
+ expect(translateCliMessage({
+ type: 'stream_event',
+ event: { type: 'content_block_stop', index: 0 },
+ }, sessionId)).toEqual([
+ {
+ type: 'tool_use_complete',
+ toolName: 'Read',
+ toolUseId: 'read-1',
+ input: { file_path: 'src/App.tsx' },
+ parentToolUseId: 'agent-1',
+ },
+ ])
+
+ expect(translateCliMessage({
+ type: 'user',
+ message: {
+ content: [
+ { type: 'tool_result', tool_use_id: 'read-1', content: 'ok' },
+ ],
+ },
+ }, sessionId)).toEqual([
+ {
+ type: 'tool_result',
+ toolUseId: 'read-1',
+ content: 'ok',
+ isError: false,
+ parentToolUseId: 'agent-1',
+ },
+ ])
+ })
+
it('keeps DeepSeek-style thinking blocks in thinking state until text starts', () => {
const sessionId = `deepseek-thinking-${crypto.randomUUID()}`
diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts
index b4db4fcd..05525a7e 100644
--- a/src/server/services/sessionService.ts
+++ b/src/server/services/sessionService.ts
@@ -89,6 +89,7 @@ export type SessionTaskNotification = {
toolUseId: string
status: 'completed' | 'failed' | 'stopped'
summary?: string
+ result?: string
outputFile?: string
timestamp?: string
}
@@ -533,12 +534,14 @@ export class SessionService {
const taskId = this.readXmlTag(xml, 'task-id') || toolUseId
const summary = this.readXmlTag(xml, 'summary')
+ const result = this.readXmlTag(xml, 'result')
const outputFile = this.readXmlTag(xml, 'output-file')
return {
taskId,
toolUseId,
status,
...(summary ? { summary } : {}),
+ ...(result ? { result } : {}),
...(outputFile ? { outputFile } : {}),
...(timestamp ? { timestamp } : {}),
}
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index dbcc77a9..66adc585 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -710,11 +710,12 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s
type SessionStreamState = {
hasReceivedStreamEvents: boolean
activeBlockTypes: Map
- activeToolBlocks: Map
+ activeToolBlocks: Map
pendingLocalCommand?: { name: string; args: string }
/** Tool blocks whose input JSON failed to parse in content_block_stop.
* The assistant message carries the complete input — defer to that. */
pendingToolBlocks: Map
+ toolParentUseIds: Map
lastApiError?: {
message: string
code: string
@@ -732,6 +733,7 @@ function getStreamState(sessionId: string): SessionStreamState {
activeToolBlocks: new Map(),
pendingLocalCommand: undefined,
pendingToolBlocks: new Map(),
+ toolParentUseIds: new Map(),
lastApiError: undefined,
}
sessionStreamStates.set(sessionId, state)
@@ -739,6 +741,31 @@ function getStreamState(sessionId: string): SessionStreamState {
return state
}
+function cliParentToolUseId(cliMsg: any): string | undefined {
+ return typeof cliMsg.parent_tool_use_id === 'string' && cliMsg.parent_tool_use_id.length > 0
+ ? cliMsg.parent_tool_use_id
+ : undefined
+}
+
+function rememberToolParentUseId(
+ streamState: SessionStreamState,
+ toolUseId: string | undefined,
+ parentToolUseId: string | undefined,
+): void {
+ if (!toolUseId || !parentToolUseId) return
+ streamState.toolParentUseIds.set(toolUseId, parentToolUseId)
+}
+
+function consumeToolParentUseId(
+ streamState: SessionStreamState,
+ toolUseId: string | undefined,
+): string | undefined {
+ if (!toolUseId) return undefined
+ const parentToolUseId = streamState.toolParentUseIds.get(toolUseId)
+ streamState.toolParentUseIds.delete(toolUseId)
+ return parentToolUseId
+}
+
/** Clean up stream state when session disconnects */
function cleanupStreamState(sessionId: string) {
sessionStreamStates.delete(sessionId)
@@ -928,6 +955,7 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
if (block.type === 'tool_use' && streamState.pendingToolBlocks.has(block.id)) {
const pending = streamState.pendingToolBlocks.get(block.id)!
streamState.pendingToolBlocks.delete(block.id)
+ rememberToolParentUseId(streamState, block.id, pending.parentToolUseId)
messages.push({
type: 'tool_use_complete',
toolName: pending.toolName || block.name,
@@ -944,15 +972,14 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
messages.push({ type: 'content_start', blockType: 'text' })
messages.push({ type: 'content_delta', text: block.text })
} else if (block.type === 'tool_use') {
+ const parentToolUseId = cliParentToolUseId(cliMsg)
+ rememberToolParentUseId(streamState, block.id, parentToolUseId)
messages.push({
type: 'tool_use_complete',
toolName: block.name,
toolUseId: block.id,
input: block.input,
- parentToolUseId:
- typeof cliMsg.parent_tool_use_id === 'string'
- ? cliMsg.parent_tool_use_id
- : undefined,
+ parentToolUseId,
})
}
}
@@ -996,15 +1023,15 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) {
for (const block of cliMsg.message.content) {
if (block.type === 'tool_result') {
+ const rememberedParentToolUseId = consumeToolParentUseId(streamState, block.tool_use_id)
+ const parentToolUseId =
+ cliParentToolUseId(cliMsg) ?? rememberedParentToolUseId
messages.push({
type: 'tool_result',
toolUseId: block.tool_use_id,
content: block.content,
isError: !!block.is_error,
- parentToolUseId:
- typeof cliMsg.parent_tool_use_id === 'string'
- ? cliMsg.parent_tool_use_id
- : undefined,
+ parentToolUseId,
})
}
}
@@ -1030,22 +1057,21 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
const index = event.index ?? 0
if (contentBlock.type === 'tool_use') {
+ const parentToolUseId = cliParentToolUseId(cliMsg)
streamState.activeBlockTypes.set(index, 'tool_use')
// Track tool info so content_block_stop can emit complete data
streamState.activeToolBlocks.set(index, {
toolName: contentBlock.name || '',
toolUseId: contentBlock.id || '',
inputJson: '',
+ parentToolUseId,
})
return [{
type: 'content_start',
blockType: 'tool_use',
toolName: contentBlock.name,
toolUseId: contentBlock.id,
- parentToolUseId:
- typeof cliMsg.parent_tool_use_id === 'string'
- ? cliMsg.parent_tool_use_id
- : undefined,
+ parentToolUseId,
}]
}
@@ -1088,13 +1114,12 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
streamState.activeToolBlocks.delete(index)
if (toolBlock) {
const parentToolUseId =
- typeof cliMsg.parent_tool_use_id === 'string'
- ? cliMsg.parent_tool_use_id
- : undefined
+ cliParentToolUseId(cliMsg) ?? toolBlock.parentToolUseId
let parsedInput = null
try { parsedInput = JSON.parse(toolBlock.inputJson) } catch {}
if (parsedInput !== null) {
+ rememberToolParentUseId(streamState, toolBlock.toolUseId, parentToolUseId)
return [{
type: 'tool_use_complete',
toolName: toolBlock.toolName,
diff --git a/src/utils/sdkEventQueue.ts b/src/utils/sdkEventQueue.ts
index 2cf5ac08..abdb112f 100644
--- a/src/utils/sdkEventQueue.ts
+++ b/src/utils/sdkEventQueue.ts
@@ -46,6 +46,7 @@ type TaskNotificationSdkEvent = {
status: 'completed' | 'failed' | 'stopped'
output_file: string
summary: string
+ result?: string
usage?: {
total_tokens: number
tool_uses: number