Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/web-shell/client/adapters/toolClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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',
Expand Down
43 changes: 12 additions & 31 deletions packages/web-shell/client/adapters/transcriptToMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
147 changes: 147 additions & 0 deletions packages/web-shell/client/components/MessageList.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2797,6 +2797,127 @@ 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(() => (
<span data-testid="assistant-turn-footer">footer</span>
));
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 and collapses the intermediate report after matched agent notifications', () => {
const firstAgent = agentMsg('agent-1');
const secondAgent = agentMsg('agent-2');
firstAgent.tools[0]!.status = 'pending';
secondAgent.tools[0]!.status = 'pending';
const renderAssistantTurnFooter = vi.fn(() => (
<span data-testid="assistant-turn-footer">footer</span>
));

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(isCollapsed(c, 'report')).toBe(true);
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('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');
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');
Expand Down Expand Up @@ -2832,6 +2953,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(() => (
<span data-testid="assistant-turn-footer">footer</span>
));
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';
Expand Down
84 changes: 82 additions & 2 deletions packages/web-shell/client/components/MessageList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -350,6 +356,80 @@ 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.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'),
{
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'),
Expand Down
Loading
Loading