From ecc42e85287aa1b673060cd2d6e6552ae6c11e78 Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:20:36 +0800 Subject: [PATCH 1/3] fix(web-shell): release footer after agent completion --- .../components/MessageList.dom.test.tsx | 116 ++++++++++++++++++ .../client/components/MessageList.tsx | 19 ++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 3555d60bc3a..aa1ae94f35e 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -2793,6 +2793,96 @@ describe('MessageList — turn collapse (DOM)', () => { expect(assistantActions(c, 'summary')).toBe('true'); }); + it('does not render final actions while AskUserQuestion is waiting', () => { + const renderAssistantTurnFooter = vi.fn(() => ( + footer + )); + const c = mount( + [ + userMsg('review-request'), + asstMsg('critical-findings'), + standaloneToolMsg('ask-user', 'AskUserQuestion'), + ], + undefined, + { customization: { renderAssistantTurnFooter } }, + ); + + expect(assistantActions(c, 'critical-findings')).toBe('false'); + expect(renderAssistantTurnFooter).not.toHaveBeenCalled(); + expect(c.querySelector('[data-testid="assistant-turn-footer"]')).toBeNull(); + }); + + it('restores final actions after matched agent notifications even when launch rows stay pending', () => { + const firstAgent = agentMsg('agent-1'); + const secondAgent = agentMsg('agent-2'); + firstAgent.tools[0]!.status = 'pending'; + secondAgent.tools[0]!.status = 'pending'; + const renderAssistantTurnFooter = vi.fn(() => ( + footer + )); + + const c = mount( + [ + userMsg('review-request'), + asstMsg('critical-findings'), + standaloneToolMsg('ask-user', 'AskUserQuestion'), + userMsg('ask-user-answer'), + firstAgent, + secondAgent, + asstMsg('report'), + backgroundNotificationMsg('bg-1', 'call-agent-1'), + backgroundNotificationMsg('bg-2', 'call-agent-2'), + thinkingMsg('late-thinking'), + asstMsg('final-supplement'), + ], + undefined, + { customization: { renderAssistantTurnFooter } }, + ); + + expect(assistantActions(c, 'report')).toBe('false'); + expect(assistantActions(c, 'final-supplement')).toBe('true'); + expect(renderAssistantTurnFooter.mock.calls.map(([info]) => info)).toEqual( + expect.arrayContaining([ + { + turnId: 'ask-user-answer', + message: { + id: 'final-supplement', + content: 'answer', + isStreaming: undefined, + timestamp: undefined, + }, + }, + ]), + ); + expect( + renderAssistantTurnFooter.mock.calls.every( + ([info]) => info.message.id === 'final-supplement', + ), + ).toBe(true); + expect( + c.querySelectorAll('[data-testid="assistant-turn-footer"]'), + ).toHaveLength(1); + }); + + it('does not release an older turn for another agent completion', () => { + const firstAgent = agentMsg('agent-1'); + const secondAgent = agentMsg('agent-2'); + firstAgent.tools[0]!.status = 'pending'; + secondAgent.tools[0]!.status = 'pending'; + const c = mount([ + userMsg('u1'), + firstAgent, + asstMsg('a1'), + userMsg('u2'), + secondAgent, + backgroundNotificationMsg('bg-2', 'call-agent-2'), + asstMsg('a2'), + ]); + + expect(assistantActions(c, 'a1')).toBe('false'); + expect(assistantActions(c, 'a2')).toBe('true'); + }); + it('keeps actions suppressed for stale agents until they reconcile terminal', () => { const firstAgent = agentMsg('agent-1'); const secondAgent = agentMsg('agent-2'); @@ -2828,6 +2918,32 @@ describe('MessageList — turn collapse (DOM)', () => { expect(assistantActions(c, 'a1')).toBe('true'); }); + it('restores the custom footer during readonly transcript replay', () => { + const staleAgent = agentMsg('agent-1'); + staleAgent.tools[0]!.status = 'pending'; + const renderAssistantTurnFooter = vi.fn(() => ( + footer + )); + const c = mount([userMsg('u1'), staleAgent, asstMsg('a1')], undefined, { + transcriptRenderMode: 'readonly', + customization: { renderAssistantTurnFooter }, + }); + + expect(assistantActions(c, 'a1')).toBe('true'); + expect(renderAssistantTurnFooter).toHaveBeenCalledWith({ + turnId: 'u1', + message: { + id: 'a1', + content: 'answer', + isStreaming: undefined, + timestamp: undefined, + }, + }); + expect( + c.querySelectorAll('[data-testid="assistant-turn-footer"]'), + ).toHaveLength(1); + }); + it('keeps final actions for a pending foreground agent in a completed turn', () => { const foregroundAgent = agentMsg('agent-1'); foregroundAgent.tools[0]!.status = 'pending'; diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index cc743f1021e..9cb85b4bb8d 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -794,6 +794,7 @@ function collectFinalAssistantTurnIds( } const turnIdByAssistantId = new Map(); + const completedAgentCallIds = completedBackgroundAgentCallIds(items); for (let k = 0; k < userIdxs.length; k++) { const start = userIdxs[k]; const end = (k + 1 < userIdxs.length ? userIdxs[k + 1] : items.length) - 1; @@ -808,7 +809,7 @@ function collectFinalAssistantTurnIds( // whether it is the latest turn or the user has moved on to a newer one. if ( gateBackgroundAgentStatus && - turnHasActiveBackgroundAgent(items, start, end) + turnHasActiveBackgroundAgent(items, start, end, completedAgentCallIds) ) { continue; } @@ -1667,13 +1668,16 @@ function turnHasActiveBackgroundAgent( items: readonly DisplayItem[], start: number, end: number, + completedAgentCallIds = completedBackgroundAgentCallIds(items), ): boolean { return someTurnToolCall( items, start, end, (tool) => - isBackgroundSubAgentToolCall(tool) && isActiveToolStatus(tool.status), + isBackgroundSubAgentToolCall(tool) && + isActiveToolStatus(tool.status) && + !completedAgentCallIds.has(tool.callId), ); } @@ -1753,6 +1757,17 @@ function backgroundAgentCompletion( : null; } +function completedBackgroundAgentCallIds( + items: readonly DisplayItem[], +): ReadonlySet { + const callIds = new Set(); + for (const item of items) { + const callId = backgroundAgentCompletion(item)?.callId; + if (callId) callIds.add(callId); + } + return callIds; +} + interface BackgroundAgentSummaryState { lastNotificationIndex: number; sawAgentCompletion: boolean; From 392e8bfac19507714280a18649765c8266b57b94 Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:39:13 +0800 Subject: [PATCH 2/3] fix(web-shell): normalize terminal background agents --- .../components/MessageList.dom.test.tsx | 33 +++++- .../client/components/MessageList.test.ts | 37 ++++++ .../client/components/MessageList.tsx | 107 ++++++++++++++---- 3 files changed, 154 insertions(+), 23 deletions(-) diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index aa1ae94f35e..0b8e3923a44 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -2839,7 +2839,7 @@ describe('MessageList — turn collapse (DOM)', () => { { customization: { renderAssistantTurnFooter } }, ); - expect(assistantActions(c, 'report')).toBe('false'); + expect(assistantActions(c, 'report')).not.toBe('true'); expect(assistantActions(c, 'final-supplement')).toBe('true'); expect(renderAssistantTurnFooter.mock.calls.map(([info]) => info)).toEqual( expect.arrayContaining([ @@ -2864,6 +2864,37 @@ describe('MessageList — turn collapse (DOM)', () => { ).toHaveLength(1); }); + it('releases the latest turn after matched delayed agent notifications', () => { + vi.useFakeTimers(); + const firstAgent = agentMsg('agent-1'); + const secondAgent = agentMsg('agent-2'); + firstAgent.tools[0]!.status = 'pending'; + secondAgent.tools[0]!.status = 'pending'; + const c = mount([userMsg('u1'), firstAgent, secondAgent, asstMsg('a1')]); + + expect(assistantActions(c, 'a1')).toBe('false'); + + const staleFirstAgent = agentMsg('agent-1'); + const staleSecondAgent = agentMsg('agent-2'); + staleFirstAgent.tools[0]!.status = 'pending'; + staleSecondAgent.tools[0]!.status = 'pending'; + rerenderMessages(c, [ + userMsg('u1'), + staleFirstAgent, + staleSecondAgent, + asstMsg('a1'), + backgroundNotificationMsg('bg-1', 'call-agent-1'), + backgroundNotificationMsg('bg-2', 'call-agent-2'), + ]); + + expect(assistantActions(c, 'a1')).toBe('false'); + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(assistantActions(c, 'a1')).toBe('true'); + expect(parallelAgentsSummary(c)?.textContent).toContain('2/2 done'); + }); + it('does not release an older turn for another agent completion', () => { const firstAgent = agentMsg('agent-1'); const secondAgent = agentMsg('agent-2'); diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index 0216e8a8a03..10a75372b83 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -350,6 +350,43 @@ describe('groupParallelAgents', () => { } }); + it('normalizes matched terminal agent notifications before grouping', () => { + const items = groupParallelAgents([ + makeBackgroundAgentToolGroup('a1'), + makeBackgroundAgentToolGroup('a2'), + makeBackgroundNotification('done-a1', 'call-a1'), + makeBackgroundNotification('done-a2', 'call-a2'), + ]); + + expect(items[0]).toMatchObject({ + type: 'parallel_agents', + agents: [{ status: 'completed' }, { status: 'completed' }], + }); + }); + + it('does not normalize an explicitly non-terminal agent notification', () => { + const items = groupParallelAgents([ + makeBackgroundAgentToolGroup('a1'), + { + id: 'running-a1', + role: 'system', + content: 'Background agent is still running.', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + status: 'in_progress', + toolUseId: 'call-a1', + }, + }, + ]); + + expect(items[0]).toMatchObject({ + type: 'message', + message: { role: 'tool_group', tools: [{ status: 'pending' }] }, + }); + }); + it('preserves background thought narration when it is not between launches', () => { const msgs = [ makeBackgroundAgentToolGroup('a1'), diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 9cb85b4bb8d..a90d10c5c9c 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -509,7 +509,8 @@ function updateCompactStreamingThinkingTail( return result; } -export function groupParallelAgents(messages: Message[]): DisplayItem[] { +export function groupParallelAgents(sourceMessages: Message[]): DisplayItem[] { + const messages = normalizeTerminalBackgroundAgentTools(sourceMessages); const items: DisplayItem[] = []; let i = 0; while (i < messages.length) { @@ -794,7 +795,6 @@ function collectFinalAssistantTurnIds( } const turnIdByAssistantId = new Map(); - const completedAgentCallIds = completedBackgroundAgentCallIds(items); for (let k = 0; k < userIdxs.length; k++) { const start = userIdxs[k]; const end = (k + 1 < userIdxs.length ? userIdxs[k + 1] : items.length) - 1; @@ -809,7 +809,7 @@ function collectFinalAssistantTurnIds( // whether it is the latest turn or the user has moved on to a newer one. if ( gateBackgroundAgentStatus && - turnHasActiveBackgroundAgent(items, start, end, completedAgentCallIds) + turnHasActiveBackgroundAgent(items, start, end) ) { continue; } @@ -1668,16 +1668,13 @@ function turnHasActiveBackgroundAgent( items: readonly DisplayItem[], start: number, end: number, - completedAgentCallIds = completedBackgroundAgentCallIds(items), ): boolean { return someTurnToolCall( items, start, end, (tool) => - isBackgroundSubAgentToolCall(tool) && - isActiveToolStatus(tool.status) && - !completedAgentCallIds.has(tool.callId), + isBackgroundSubAgentToolCall(tool) && isActiveToolStatus(tool.status), ); } @@ -1723,9 +1720,12 @@ function backgroundAgentCallIds(item: DisplayItem): string[] { return []; } -function backgroundAgentCompletionForMessage( - message: Message, -): { callId?: string } | null { +function backgroundAgentCompletionForMessage(message: Message): { + callId?: string; + toolStatus: 'completed' | 'failed'; + cancelled?: boolean; + endTime?: number; +} | null { if ( message.role !== 'system' || message.source !== 'background_notification' @@ -1739,33 +1739,96 @@ function backgroundAgentCompletionForMessage( .startsWith('background agent ') === true; const data = message.data; if (typeof data !== 'object' || data === null || Array.isArray(data)) { - return identifiesAgent ? {} : null; + return identifiesAgent + ? { + toolStatus: 'completed', + ...(message.timestamp !== undefined + ? { endTime: message.timestamp } + : {}), + } + : null; } - const { kind, toolUseId } = data as { + const { kind, toolUseId, status } = data as { kind?: unknown; toolUseId?: unknown; + status?: unknown; }; if (kind !== 'agent' && !(kind === undefined && identifiesAgent)) return null; - return typeof toolUseId === 'string' ? { callId: toolUseId } : {}; + if ( + status !== undefined && + status !== 'completed' && + status !== 'failed' && + status !== 'cancelled' && + status !== 'canceled' + ) { + return null; + } + return { + ...(typeof toolUseId === 'string' ? { callId: toolUseId } : {}), + toolStatus: status === 'failed' ? 'failed' : 'completed', + ...(status === 'cancelled' || status === 'canceled' + ? { cancelled: true } + : {}), + ...(message.timestamp !== undefined ? { endTime: message.timestamp } : {}), + }; } function backgroundAgentCompletion( item: DisplayItem, -): { callId?: string } | null { +): ReturnType { return item.type === 'message' ? backgroundAgentCompletionForMessage(item.message) : null; } -function completedBackgroundAgentCallIds( - items: readonly DisplayItem[], -): ReadonlySet { - const callIds = new Set(); - for (const item of items) { - const callId = backgroundAgentCompletion(item)?.callId; - if (callId) callIds.add(callId); +function normalizeTerminalBackgroundAgentTools(messages: Message[]): Message[] { + const updates = new Map< + string, + NonNullable> + >(); + for (const message of messages) { + const completion = backgroundAgentCompletionForMessage(message); + if (completion?.callId) updates.set(completion.callId, completion); } - return callIds; + if (updates.size === 0) return messages; + + let changed = false; + const normalized = messages.map((message) => { + if (message.role !== 'tool_group') return message; + let toolsChanged = false; + const tools = message.tools.map((tool) => { + const update = updates.get(tool.callId); + if ( + !update || + !isBackgroundSubAgentToolCall(tool) || + !isActiveToolStatus(tool.status) + ) { + return tool; + } + toolsChanged = true; + return { + ...tool, + status: update.toolStatus, + ...(update.endTime !== undefined ? { endTime: update.endTime } : {}), + ...(update.cancelled + ? { + rawOutput: { + ...(typeof tool.rawOutput === 'object' && + tool.rawOutput !== null && + !Array.isArray(tool.rawOutput) + ? tool.rawOutput + : {}), + status: 'cancelled', + }, + } + : {}), + }; + }); + if (!toolsChanged) return message; + changed = true; + return { ...message, tools }; + }); + return changed ? normalized : messages; } interface BackgroundAgentSummaryState { From a5935ea45dd9f8c023c3739a3fed2151bd4009ce Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:37:55 +0800 Subject: [PATCH 3/3] fix(web-shell): unify background agent terminal projection --- .../client/adapters/toolClassification.ts | 39 ++++++++++++++ .../client/adapters/transcriptToMessages.ts | 43 +++++---------- .../components/MessageList.dom.test.tsx | 4 +- .../client/components/MessageList.test.ts | 47 +++++++++++++++- .../client/components/MessageList.tsx | 53 +++++++------------ .../web-shell/client/hooks/useMessages.ts | 45 +++++----------- 6 files changed, 131 insertions(+), 100 deletions(-) diff --git a/packages/web-shell/client/adapters/toolClassification.ts b/packages/web-shell/client/adapters/toolClassification.ts index a32e2621951..d74bb1da6ea 100644 --- a/packages/web-shell/client/adapters/toolClassification.ts +++ b/packages/web-shell/client/adapters/toolClassification.ts @@ -15,6 +15,23 @@ export function isActiveToolStatus( ); } +export type TerminalBackgroundAgentStatus = + | 'completed' + | 'failed' + | 'cancelled' + | 'canceled'; + +export function isTerminalBackgroundAgentStatus( + status: unknown, +): status is TerminalBackgroundAgentStatus { + return ( + status === 'completed' || + status === 'failed' || + status === 'cancelled' || + status === 'canceled' + ); +} + export function hasActiveAgents(agents: readonly ACPToolCall[]): boolean { return agents.some((agent) => isActiveToolStatus(agent.status)); } @@ -72,6 +89,28 @@ export function isBackgroundSubAgentToolCall(tool: ACPToolCall): boolean { ); } +export function projectTerminalBackgroundAgentTool( + tool: ACPToolCall, + status: unknown, + endTime?: number, +): ACPToolCall { + if (!isTerminalBackgroundAgentStatus(status)) return tool; + const cancelled = status === 'cancelled' || status === 'canceled'; + return { + ...tool, + status: status === 'failed' ? 'failed' : 'completed', + ...(endTime !== undefined ? { endTime } : {}), + ...(cancelled + ? { + rawOutput: { + ...(getRecord(tool.rawOutput) ?? {}), + status: 'cancelled', + }, + } + : {}), + }; +} + const BACKGROUND_SHELL_NAMES = new Set([ 'shell', 'bash', diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index e532b04716e..bee25071815 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -23,7 +23,10 @@ import type { DaemonMessageTodoItem, DaemonUserMessage, } from './messageTypes.js'; -import { isSubAgentToolCall } from './toolClassification.js'; +import { + isSubAgentToolCall, + projectTerminalBackgroundAgentTool, +} from './toolClassification.js'; import { parseTodoItemsFromEntries } from '../utils/todos.js'; interface PermissionToolInfo { @@ -84,32 +87,6 @@ function collectBackgroundAgentTaskUpdates( return updates; } -function applyBackgroundAgentTaskUpdate( - tool: DaemonMessageToolCall, - update: BackgroundAgentTaskUpdate | undefined, -): void { - if (!update) return; - switch (update.status) { - case 'completed': - tool.status = 'completed'; - tool.endTime = update.endTime; - break; - case 'failed': - tool.status = 'failed'; - tool.endTime = update.endTime; - break; - case 'cancelled': - case 'canceled': - tool.status = 'completed'; - tool.endTime = update.endTime; - tool.rawOutput = { - ...(getRecord(tool.rawOutput) ?? {}), - status: 'cancelled', - }; - break; - } -} - function isIgnoredWebShellStatus(text: string): boolean { // `model.changed` projects to a `status` block, not a `debug` one, so this // stays text-keyed. The Web Shell renders its own richer model-switch @@ -680,10 +657,14 @@ export function transcriptBlocksToDaemonMessages( case 'tool': { const toolBlock = block as DaemonToolTranscriptBlock; - const toolCall = daemonToolBlockToToolCall(toolBlock); - applyBackgroundAgentTaskUpdate( - toolCall, - backgroundAgentTaskUpdates.get(toolCall.callId), + const projectedToolCall = daemonToolBlockToToolCall(toolBlock); + const backgroundAgentUpdate = backgroundAgentTaskUpdates.get( + projectedToolCall.callId, + ); + const toolCall = projectTerminalBackgroundAgentTool( + projectedToolCall, + backgroundAgentUpdate?.status, + backgroundAgentUpdate?.endTime, ); const permissionInfo = permissionToolInfoByCallId.get(toolCall.callId); if (permissionInfo?.title) { diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 0b8e3923a44..d67ee571e43 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -2812,7 +2812,7 @@ describe('MessageList — turn collapse (DOM)', () => { expect(c.querySelector('[data-testid="assistant-turn-footer"]')).toBeNull(); }); - it('restores final actions after matched agent notifications even when launch rows stay pending', () => { + it('restores final actions and collapses the intermediate report after matched agent notifications', () => { const firstAgent = agentMsg('agent-1'); const secondAgent = agentMsg('agent-2'); firstAgent.tools[0]!.status = 'pending'; @@ -2839,7 +2839,7 @@ describe('MessageList — turn collapse (DOM)', () => { { customization: { renderAssistantTurnFooter } }, ); - expect(assistantActions(c, 'report')).not.toBe('true'); + expect(isCollapsed(c, 'report')).toBe(true); expect(assistantActions(c, 'final-supplement')).toBe('true'); expect(renderAssistantTurnFooter.mock.calls.map(([info]) => info)).toEqual( expect.arrayContaining([ diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index 10a75372b83..2c8461c9b85 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -72,7 +72,12 @@ function makeSystemMessage(id: string): Message { return { id, role: 'system', content: 'heads up', variant: 'error' }; } -function makeBackgroundNotification(id: string, toolUseId?: string): Message { +function makeBackgroundNotification( + id: string, + toolUseId?: string, + status = 'completed', + timestamp?: number, +): Message { return { id, role: 'system', @@ -81,9 +86,10 @@ function makeBackgroundNotification(id: string, toolUseId?: string): Message { source: 'background_notification', data: { kind: 'agent', - status: 'completed', + status, ...(toolUseId ? { toolUseId } : {}), }, + ...(timestamp !== undefined ? { timestamp } : {}), }; } @@ -364,6 +370,43 @@ describe('groupParallelAgents', () => { }); }); + it.each([ + ['failed', 'failed', 'background'], + ['cancelled', 'completed', 'cancelled'], + ['canceled', 'completed', 'cancelled'], + ] as const)( + 'normalizes a %s agent notification before grouping', + (notificationStatus, toolStatus, rawStatus) => { + const items = groupParallelAgents([ + makeBackgroundAgentToolGroup('a1'), + makeBackgroundNotification( + 'done-a1', + 'call-a1', + notificationStatus, + 1_234, + ), + ]); + + expect(items[0]).toMatchObject({ + type: 'message', + message: { + role: 'tool_group', + tools: [ + { + status: toolStatus, + endTime: 1_234, + rawOutput: { + type: 'task_execution', + taskDescription: 'task a1', + status: rawStatus, + }, + }, + ], + }, + }); + }, + ); + it('does not normalize an explicitly non-terminal agent notification', () => { const items = groupParallelAgents([ makeBackgroundAgentToolGroup('a1'), diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index a90d10c5c9c..6ca2f34cef5 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -30,7 +30,10 @@ import type { PermissionRequest } from '../adapters/types'; import { backgroundShellTaskId, isBackgroundSubAgentToolCall, + isTerminalBackgroundAgentStatus, isSubAgentToolCall, + projectTerminalBackgroundAgentTool, + type TerminalBackgroundAgentStatus, } from '../adapters/toolClassification'; import { CompactModeContext } from '../WebShellContexts'; import { @@ -1722,8 +1725,7 @@ function backgroundAgentCallIds(item: DisplayItem): string[] { function backgroundAgentCompletionForMessage(message: Message): { callId?: string; - toolStatus: 'completed' | 'failed'; - cancelled?: boolean; + status: TerminalBackgroundAgentStatus; endTime?: number; } | null { if ( @@ -1741,7 +1743,7 @@ function backgroundAgentCompletionForMessage(message: Message): { if (typeof data !== 'object' || data === null || Array.isArray(data)) { return identifiesAgent ? { - toolStatus: 'completed', + status: 'completed', ...(message.timestamp !== undefined ? { endTime: message.timestamp } : {}), @@ -1754,21 +1756,16 @@ function backgroundAgentCompletionForMessage(message: Message): { status?: unknown; }; if (kind !== 'agent' && !(kind === undefined && identifiesAgent)) return null; - if ( - status !== undefined && - status !== 'completed' && - status !== 'failed' && - status !== 'cancelled' && - status !== 'canceled' - ) { - return null; - } + const terminalStatus = + status === undefined + ? 'completed' + : isTerminalBackgroundAgentStatus(status) + ? status + : undefined; + if (!terminalStatus) return null; return { ...(typeof toolUseId === 'string' ? { callId: toolUseId } : {}), - toolStatus: status === 'failed' ? 'failed' : 'completed', - ...(status === 'cancelled' || status === 'canceled' - ? { cancelled: true } - : {}), + status: terminalStatus, ...(message.timestamp !== undefined ? { endTime: message.timestamp } : {}), }; } @@ -1805,24 +1802,14 @@ function normalizeTerminalBackgroundAgentTools(messages: Message[]): Message[] { ) { return tool; } + const normalizedTool = projectTerminalBackgroundAgentTool( + tool, + update.status, + update.endTime, + ); + if (normalizedTool === tool) return tool; toolsChanged = true; - return { - ...tool, - status: update.toolStatus, - ...(update.endTime !== undefined ? { endTime: update.endTime } : {}), - ...(update.cancelled - ? { - rawOutput: { - ...(typeof tool.rawOutput === 'object' && - tool.rawOutput !== null && - !Array.isArray(tool.rawOutput) - ? tool.rawOutput - : {}), - status: 'cancelled', - }, - } - : {}), - }; + return normalizedTool; }); if (!toolsChanged) return message; changed = true; diff --git a/packages/web-shell/client/hooks/useMessages.ts b/packages/web-shell/client/hooks/useMessages.ts index 592f391378b..942ba07088d 100644 --- a/packages/web-shell/client/hooks/useMessages.ts +++ b/packages/web-shell/client/hooks/useMessages.ts @@ -16,6 +16,8 @@ import type { Message } from '../adapters/types'; import { isActiveToolStatus, isBackgroundSubAgentToolCall, + isTerminalBackgroundAgentStatus, + projectTerminalBackgroundAgentTool, } from '../adapters/toolClassification'; type Translator = ( @@ -247,15 +249,6 @@ export function projectStreamingTailMessages( return messages; } -function isTerminalBackgroundAgentStatus(status: string): boolean { - return ( - status === 'completed' || - status === 'failed' || - status === 'cancelled' || - status === 'canceled' - ); -} - function getRecord(value: unknown): Record | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) @@ -361,30 +354,18 @@ export function reconcileBackgroundAgentResolutions( ) { return tool; } + const endTime = + tool.startTime !== undefined + ? tool.startTime + (resolution.durationMs ?? 0) + : undefined; + const reconciledTool = projectTerminalBackgroundAgentTool( + tool, + resolution.status, + endTime, + ); + if (reconciledTool === tool) return tool; toolsChanged = true; - const cancelled = - resolution.status === 'cancelled' || resolution.status === 'canceled'; - const status: typeof tool.status = - resolution.status === 'failed' ? 'failed' : 'completed'; - return { - ...tool, - status, - ...(tool.startTime !== undefined - ? { endTime: tool.startTime + (resolution.durationMs ?? 0) } - : {}), - ...(cancelled - ? { - rawOutput: { - ...(typeof tool.rawOutput === 'object' && - tool.rawOutput !== null && - !Array.isArray(tool.rawOutput) - ? tool.rawOutput - : {}), - status: 'cancelled', - }, - } - : {}), - }; + return reconciledTool; }); if (!toolsChanged) return message; changed = true;