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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 03:15:52 +08:00
parent f2e3a31021
commit 2323e6ec47
13 changed files with 332 additions and 24 deletions

View File

@ -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(<MessageList />)
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, {

View File

@ -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()

View File

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

View File

@ -573,7 +573,7 @@ describe('chatStore history mapping', () => {
id: 'task-notification',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command &amp; agent done</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
content: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command &amp; agent done</summary>\n<result>Detailed result &amp; next step</result>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
},
])
@ -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)

View File

@ -144,6 +144,7 @@ type ChatStore = {
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
const pendingTaskToolUseIdsBySession = new Map<string, Set<string>>()
const pendingToolParentUseIdsBySession = new Map<string, Map<string, string>>()
function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void {
const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set<string>()
@ -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<string, string>()
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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((set, get) => ({
useCLITaskStore.getState().refreshTasks(sessionId)
}
break
}
case 'permission_request':
notifyDesktop({
@ -1122,6 +1160,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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 } : {}),
}
}

View File

@ -155,6 +155,7 @@ export type AgentTaskNotification = {
toolUseId: string
status: 'completed' | 'failed' | 'stopped'
summary?: string
result?: string
outputFile?: string
usage?: BackgroundAgentTaskUsage
timestamp?: string

View File

@ -2032,6 +2032,9 @@ function runHeadlessStreaming(
const summaryMatch = notificationText.match(
/<summary>([^<]+)<\/summary>/,
)
const resultMatch = notificationText.match(
/<result>([\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
? {

View File

@ -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(),

View File

@ -1732,7 +1732,7 @@ describe('Sessions API', () => {
makeUserEntry('Hello'),
makeAssistantEntry('World'),
makeUserEntry(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed &amp; stopped</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed &amp; stopped</summary>\n<result>Stack trace &amp; failed assertion</result>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
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),
},

View File

@ -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()}`

View File

@ -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 } : {}),
}

View File

@ -710,11 +710,12 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: s
type SessionStreamState = {
hasReceivedStreamEvents: boolean
activeBlockTypes: Map<number, 'text' | 'tool_use' | 'thinking'>
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string }>
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string; parentToolUseId?: string }>
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<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
toolParentUseIds: Map<string, string>
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,

View File

@ -46,6 +46,7 @@ type TaskNotificationSdkEvent = {
status: 'completed' | 'failed' | 'stopped'
output_file: string
summary: string
result?: string
usage?: {
total_tokens: number
tool_uses: number