From 2323e6ec47fd15df067ce43c0b7eae1433f29e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 03:15:52 +0800 Subject: [PATCH] Keep subagent progress readable in desktop sessions Desktop sessions can receive task tools, background-agent lifecycle events, and child tool stream events in the same turn. The UI now keeps root Agent cards separate from task-management tools, preserves parent links when later stream events omit them, and surfaces completed background-agent result text from task notifications. Constraint: Existing desktop session history can contain task-notification XML and live WS events with incomplete parent metadata. Rejected: Render local_agent lifecycle messages as standalone cards | duplicates the Agent tool card and makes ownership unclear. Confidence: high Scope-risk: moderate Directive: Do not collapse Agent and TaskCreate/TaskUpdate root tools into one generic group without checking multi-agent screenshots. Tested: SKIP_INSTALL=1 PRESERVE_TAURI_TARGET=1 ./desktop/scripts/build-macos-arm64.sh Tested: Real gpt-5.5 desktop E2E sessions under /tmp/cc-haha-subagent-e2e-20260519-025454 for 3 subagents, child tools, result dialog, and background Bash completion. Not-tested: Full bun run verify gate. --- .../src/components/chat/MessageList.test.tsx | 108 ++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 11 +- desktop/src/components/chat/ToolCallGroup.tsx | 3 +- desktop/src/stores/chatStore.test.ts | 50 +++++++- desktop/src/stores/chatStore.ts | 51 ++++++++- desktop/src/types/chat.ts | 1 + src/cli/print.ts | 4 + src/entrypoints/sdk/coreSchemas.ts | 1 + src/server/__tests__/sessions.test.ts | 3 +- src/server/__tests__/ws-memory-events.test.ts | 63 ++++++++++ src/server/services/sessionService.ts | 3 + src/server/ws/handler.ts | 57 ++++++--- src/utils/sdkEventQueue.ts | 1 + 13 files changed, 332 insertions(+), 24 deletions(-) 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