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;