Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function setup(initial: readonly DialogEntry[]): Harness {
const config = {
getBackgroundTaskRegistry: () => ({
cancel,
setActivityChangeCallback: vi.fn(),
addActivityChangeListener: vi.fn(() => () => {}),
get: (id: string) => {
const match = currentEntries.find(
(e) => e.kind === 'agent' && e.agentId === id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import {
buildBackgroundEntryLabel,
ToolDisplayNames,
ToolNames,
type BackgroundTaskEntry,
type MonitorEntry,
} from '@qwen-code/qwen-code-core';
import { formatDuration, formatTokenCount } from '../../utils/formatters.js';
import {
formatActivityLabel,
formatDuration,
formatTokenCount,
} from '../../utils/formatters.js';
import {
type AgentDialogEntry,
type DialogEntry,
Expand All @@ -41,22 +43,6 @@ import {
// `paused` state, so dialog handlers can switch on a single combined enum.
type EntryStatus = DialogEntry['status'];

// Tool-name → display-name lookup (`run_shell_command` → `Shell`).
const TOOL_DISPLAY_BY_NAME: Record<string, string> = Object.fromEntries(
(Object.keys(ToolNames) as Array<keyof typeof ToolNames>).map((key) => [
ToolNames[key],
ToolDisplayNames[key],
]),
);

function formatActivityLabel(name: string, description: string | undefined) {
const display = TOOL_DISPLAY_BY_NAME[name] ?? name;
const singleLineDesc = description
? description.replace(/\s*\n\s*/g, ' ').trim()
: '';
return singleLineDesc ? `${display}(${singleLineDesc})` : display;
}

const STATUS_VERBS: Record<EntryStatus, string> = {
running: 'Running',
paused: 'Paused',
Expand Down Expand Up @@ -901,8 +887,7 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
if (entry.agentId !== selectedAgentIdForActivity) return;
setActivityTick((n) => n + 1);
};
registry.setActivityChangeCallback(onActivity);
return () => registry.setActivityChangeCallback(undefined);
return registry.addActivityChangeListener(onActivity);
}, [dialogOpen, dialogMode, config, selectedAgentIdForActivity]);

// Wall-clock tick for the running agent's duration. Activity callbacks
Expand Down
212 changes: 212 additions & 0 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,33 @@ vi.mock('./ToolConfirmationMessage.js', () => ({
},
}));

vi.mock('../subagents/index.js', () => ({
AgentTree: function MockAgentTree({
agents,
}: {
agents: Array<{
callId: string;
data: { subagentName?: string };
isFocused?: boolean;
}>;
}) {
return (
<Text>
MockAgentTree[
{agents
.map(
(a) =>
`${a.callId}:${a.data.subagentName ?? '?'}:focused=${String(
Boolean(a.isFocused),
)}`,
)
.join('|')}
]
</Text>
);
},
}));

describe('<ToolGroupMessage />', () => {
const mockConfig: Config = {} as Config;

Expand Down Expand Up @@ -467,6 +494,191 @@ describe('<ToolGroupMessage />', () => {
});
});

describe('Live agent grouping', () => {
const createRunningSubagentDisplay = (
name: string,
): AgentResultDisplay => ({
type: 'task_execution',
subagentName: name,
taskDescription: `${name} task`,
taskPrompt: `Run ${name}`,
status: 'running',
});

it('routes contiguous live agents into a single AgentTree', () => {
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
isPending={true}
toolCalls={[
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
createToolCall({
callId: 'agent-2',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
]}
/>,
);

const frame = lastFrame() ?? '';
// Both agents collapse into one tree node.
expect(frame).toContain('MockAgentTree[agent-1:reviewer');
expect(frame).toContain('agent-2:reviewer');
// No per-tool MockSubagent rows for live agents.
expect(frame).not.toContain('MockSubagent[agent-1]');
expect(frame).not.toContain('MockSubagent[agent-2]');
});

it('splits the tree when a non-agent call sits between agent calls', () => {
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
isPending={true}
toolCalls={[
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
createToolCall({
callId: 'shell-1',
name: 'run_shell_command',
status: ToolCallStatus.Executing,
}),
createToolCall({
callId: 'agent-2',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
]}
/>,
);

const frame = lastFrame() ?? '';
// Two separate trees, one per contiguous run.
expect(frame).toContain('MockAgentTree[agent-1:reviewer');
expect(frame).toContain('MockAgentTree[agent-2:reviewer');
// Non-agent call still renders via the per-tool path.
expect(frame).toContain('MockTool[shell-1]');
});

it('passes focus only to the first pending-confirmation agent', () => {
const pendingDisplay: AgentResultDisplay = {
...createRunningSubagentDisplay('reviewer'),
pendingConfirmation: {
type: 'info',
title: 'Approve?',
prompt: 'allow?',
onConfirm: vi.fn(),
},
};
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
isPending={true}
toolCalls={[
createToolCall({
callId: 'agent-running',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
createToolCall({
callId: 'agent-pending',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: pendingDisplay,
}),
]}
/>,
);

// Frame may soft-wrap inside the group border; collapse before
// matching so the assertion isn't sensitive to terminal width.
const frame = (lastFrame() ?? '').replace(/[│\n]/g, '');
expect(frame).toContain('agent-pending:reviewer:focused=true');
expect(frame).toContain('agent-running:reviewer:focused=false');
});

it('queues the agent banner when a direct tool confirmation is active', () => {
const pendingDisplay: AgentResultDisplay = {
...createRunningSubagentDisplay('reviewer'),
pendingConfirmation: {
type: 'info',
title: 'Approve agent action?',
prompt: 'allow agent action?',
onConfirm: vi.fn(),
},
};
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
isPending={true}
toolCalls={[
createToolCall({
callId: 'tool-confirm',
name: 'write_file',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Write file?',
prompt: 'allow write?',
onConfirm: vi.fn(),
},
}),
createToolCall({
callId: 'agent-pending',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: pendingDisplay,
}),
]}
/>,
);
// Direct tool's Confirming row keeps focus; agent banner does not
// light up its own focused branch even though the agent has a
// pending confirmation.
const frame = (lastFrame() ?? '').replace(/[│\n]/g, '');
expect(frame).toContain('agent-pending:reviewer:focused=false');
});

it('falls back to per-tool live rendering once the group commits', () => {
const completed: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'reviewer',
taskDescription: 'reviewer task',
taskPrompt: 'review it',
status: 'completed',
};
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
isPending={false}
toolCalls={[
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Success,
resultDisplay: completed,
}),
]}
/>,
);
const frame = lastFrame() ?? '';
expect(frame).not.toContain('MockAgentTree');
expect(frame).toContain('MockSubagent[agent-1]');
});
});

describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
Expand Down
Loading
Loading