Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
104 changes: 104 additions & 0 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
} from '@qwen-code/qwen-code-core';
import { TOOL_STATUS } from '../../constants.js';
import { ConfigContext } from '../../contexts/ConfigContext.js';
import { CompactModeProvider } from '../../contexts/CompactModeContext.js';

// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
Expand Down Expand Up @@ -566,4 +567,107 @@ describe('<ToolGroupMessage />', () => {
expect(lastFrame()).toMatchSnapshot();
});
});

describe('Compact mode + terminal subagent expansion', () => {
// Helper that wraps the group with `compactMode: true` so the
// `showCompact` branch is exercised. Verifies the safety net that
// forces the group to expand when it carries a committed terminal
// subagent — without it, `CompactToolGroupDisplay` would skip the
// ToolMessage path and `SubagentScrollbackSummary` would never
// surface in scrollback. The committed-summary handoff promised
// by the LiveAgentPanel design depends on this.
const renderCompact = (component: React.ReactElement, compactMode = true) =>
render(
<ConfigContext.Provider value={mockConfig}>
<CompactModeProvider value={{ compactMode }}>
{component}
</CompactModeProvider>
</ConfigContext.Provider>,
);

const subagentCall = (
status: 'running' | 'completed' | 'failed' | 'cancelled',
): IndividualToolCallDisplay =>
createToolCall({
callId: `task-${status}`,
name: 'task',
description: 'Delegate task to subagent',
status:
status === 'running'
? ToolCallStatus.Executing
: status === 'completed'
? ToolCallStatus.Success
: ToolCallStatus.Error,
resultDisplay: {
type: 'task_execution',
subagentName: 'researcher',
taskDescription: 'investigate the change',
taskPrompt: 'investigate',
status,
} as AgentResultDisplay,
});

it('compact mode: committed group with completed subagent forces expand', () => {
// isPending=false (committed) + completed subagent → expand,
// routing through ToolMessage so the scrollback summary lands
// in the persistent record.
const { lastFrame } = renderCompact(
<ToolGroupMessage
{...baseProps}
toolCalls={[subagentCall('completed')]}
isPending={false}
/>,
);
const frame = lastFrame() ?? '';
// The MockToolMessage's `MockSubagent[task-completed]` sentinel
// proves we routed through the expanded path; absence would
// mean CompactToolGroupDisplay swallowed the call.
expect(frame).toContain('MockSubagent[task-completed]');
});

it('compact mode: live group with running subagent stays compact', () => {
// isPending=true (live) → panel below the composer owns the
// row; staying compact keeps scrollback quiet until the parent
// turn commits.
const { lastFrame } = renderCompact(
<ToolGroupMessage
{...baseProps}
toolCalls={[subagentCall('running')]}
isPending={true}
/>,
);
// Compact path renders the group header / count, NOT the
// expanded MockToolMessage sentinel.
expect(lastFrame() ?? '').not.toContain('MockSubagent[task-running]');
});

it('compact mode: live group with completed subagent stays compact', () => {
// The subagent terminated mid-turn but the parent is still
// running. Panel shows the synthesized / terminal row; the
// group should NOT yet expand to render the scrollback summary
// (that happens when the parent commits, isPending=false).
const { lastFrame } = renderCompact(
<ToolGroupMessage
{...baseProps}
toolCalls={[subagentCall('completed')]}
isPending={true}
/>,
);
expect(lastFrame() ?? '').not.toContain('MockSubagent[task-completed]');
});

it('compact mode: committed group with failed subagent forces expand', () => {
// Same as the completed case — the scrollback summary needs to
// land for failed / cancelled foreground subagents too so the
// user has a permanent record of the run's outcome.
const { lastFrame } = renderCompact(
<ToolGroupMessage
{...baseProps}
toolCalls={[subagentCall('failed')]}
isPending={false}
/>,
);
expect(lastFrame() ?? '').toContain('MockSubagent[task-failed]');
});
});
});
30 changes: 24 additions & 6 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight,
contentWidth,
isFocused = true,
// `isPending` stays on the props interface for upstream compat
// (HistoryItemDisplay et al. forward it) but the group body no
// longer reads it. Skip the destructure so TS catches accidental
// re-introductions of dead state.
isPending = false,
activeShellPtyId,
Comment thread
wenshao marked this conversation as resolved.
Resolved
embeddedShellFocused,
memoryWriteCount,
Expand Down Expand Up @@ -167,15 +164,35 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({

// Compact mode: entire group → single line summary
// Force-expand when: user must interact (Confirming or subagent pending
// confirmation), tool errored, shell is focused, or user-initiated
// confirmation), tool errored, shell is focused, or user-initiated.
// Also force-expand when this group has just committed and contains
// a terminal subagent — `CompactToolGroupDisplay` doesn't know about
// `task_execution` results, so the compact path would skip
// `SubagentScrollbackSummary` and the user would lose the persistent
// record promised by the LiveAgentPanel → committed-summary handoff.
// Stay compact while the parent turn is still live (`isPending`),
// because the panel below the composer is still showing the row.
const hasSubagentPendingConfirmation = subagentsAwaitingApproval.length > 0;
const hasCommittedTerminalSubagent =
Comment thread
wenshao marked this conversation as resolved.
Resolved
!isPending &&
toolCalls.some((t) => {
const display = t.resultDisplay;
if (!display || typeof display !== 'object') return false;
if (!('type' in display) || display.type !== 'task_execution')
return false;
const status = (display as { status?: string }).status;
return (
status === 'completed' || status === 'failed' || status === 'cancelled'
);
});
const showCompact =
compactMode &&
!hasConfirmingTool &&
!hasSubagentPendingConfirmation &&
!hasErrorTool &&
!isEmbeddedShellFocused &&
!isUserInitiated;
!isUserInitiated &&
!hasCommittedTerminalSubagent;

if (showCompact) {
Comment thread
wenshao marked this conversation as resolved.
Resolved
return (
Expand Down Expand Up @@ -327,6 +344,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isAgentWithPendingConfirmation(tool.resultDisplay)
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
}
isFocused={isSubagentFocused}
isPending={isPending}
Comment thread
wenshao marked this conversation as resolved.
Resolved
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&
Expand Down
32 changes: 31 additions & 1 deletion packages/cli/src/ui/components/messages/ToolMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ describe('<ToolMessage />', () => {
terminateReason?: string;
};
isFocused?: boolean;
isPending?: boolean;
}): ToolMessageProps => {
const resultDisplay = {
type: 'task_execution' as const,
Expand All @@ -331,6 +332,7 @@ describe('<ToolMessage />', () => {
callId: 'gated-task-call',
forceShowResult: true, // mirror ToolGroupMessage's forceShowResult
isFocused: overrides.isFocused,
isPending: overrides.isPending,
};
};

Expand All @@ -355,7 +357,7 @@ describe('<ToolMessage />', () => {
expect(output).not.toContain('Queued approval:');
});

it('completed subagent → renders a one-line scrollback summary', () => {
it('committed (`!isPending`) terminal subagent → renders a one-line scrollback summary', () => {
// The verbose 15-row inline frame is retired (it caused
// scrollback flicker), but the conversation history needs to
// keep a permanent record after the panel's 8s window expires
Expand All @@ -370,6 +372,7 @@ describe('<ToolMessage />', () => {
taskPrompt: 'Already done',
status: 'completed',
},
isPending: false,
})}
/>,
StreamingState.Idle,
Expand All @@ -384,6 +387,33 @@ describe('<ToolMessage />', () => {
expect(output).not.toContain('MockApprovalPrompt');
});

it('live (`isPending`) terminal subagent → no scrollback summary (panel owns the row)', () => {
// While the parent turn is still in `pendingHistoryItems`,
// LiveAgentPanel below the composer renders the synthesized /
// terminal row — emitting the summary here too would duplicate
// it visually. The summary fires only when the tool message
// commits to <Static> (`isPending=false`), guaranteeing exactly
// one inline copy in the eventual scrollback record.
const { lastFrame } = renderWithContext(
<ToolMessage
{...buildProps({
data: {
subagentName: 'live-terminal',
taskDescription: 'Just finished mid-turn',
taskPrompt: 'Mid-turn',
status: 'completed',
},
isPending: true,
})}
/>,
StreamingState.Responding,
);
const output = lastFrame() ?? '';
// No inline summary — the panel renders this row instead.
expect(output).not.toContain('✔');
expect(output).not.toContain('Just finished mid-turn');
});

it('failed subagent → renders summary with terminate reason', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
Expand Down
41 changes: 37 additions & 4 deletions packages/cli/src/ui/components/messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,14 +262,30 @@ const PlanResultRenderer: React.FC<{
* single-line scrollback summary so the conversation history retains
* a permanent record after the panel's 8s window expires and the
* dialog closes. Format: `<icon> <type>: <description> · N tools · Xs · Yk tokens`.
*
* The committed-phase summary is gated on `!isPending` — without the
* gate the summary would also paint into `pendingHistoryItems`
* (the live area) the moment a subagent terminates, duplicating the
* row LiveAgentPanel already renders synthesized below the composer.
* `isPending=true` means we're still in the live area; `isPending=false`
* means the item has committed to `<Static>` and the summary is the
* one and only place the user can find this run after the panel evicts.
*/
const SubagentExecutionRenderer: React.FC<{
data: AgentResultDisplay;
availableHeight?: number;
childWidth: number;
config: Config;
isFocused?: boolean;
}> = ({ data, availableHeight, childWidth, config, isFocused }) => {
isPending?: boolean;
}> = ({
data,
availableHeight,
childWidth,
config,
isFocused,
isPending = false,
}) => {
if (data.pendingConfirmation && isFocused) {
const agentLabel = data.subagentName || 'agent';
return (
Comment thread
wenshao marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -308,10 +324,14 @@ const SubagentExecutionRenderer: React.FC<{
// 8s visibility window expires (LiveAgentPanel evicts terminal rows;
// BackgroundTasksDialog only retains them while open). Skip
// `running` / `background` since the panel + dialog cover those.
// ALSO skip while `isPending` — the live area is already painted by
// the panel, so emitting the summary into `pendingHistoryItems` would
// duplicate the row visually until the parent turn commits.
if (
data.status === 'completed' ||
data.status === 'failed' ||
data.status === 'cancelled'
!isPending &&
(data.status === 'completed' ||
data.status === 'failed' ||
data.status === 'cancelled')
) {
return <SubagentScrollbackSummary data={data} />;
}
Expand Down Expand Up @@ -478,6 +498,17 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
* sibling subagents render a dim "Queued approval" marker instead.
*/
isFocused?: boolean;
/**
* True while the tool message is rendered inside `pendingHistoryItems`
* (live area), false (or omitted — undefined is treated as false)
* once committed to `<Static>`. Live-area renderers MUST pass
* `true` explicitly; committed renderers can omit it. Used by the
* subagent renderer to gate the scrollback summary so the live
* area stays panel-only — without this gate the summary would
* duplicate the synthesized row LiveAgentPanel already renders
* below the composer the moment a subagent terminates.
Comment thread
wenshao marked this conversation as resolved.
Resolved
*/
isPending?: boolean;
}

export const ToolMessage: React.FC<ToolMessageProps> = ({
Expand All @@ -495,6 +526,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
config,
forceShowResult,
isFocused,
isPending,
executionStartTime,
}) => {
const settings = useSettings();
Expand Down Expand Up @@ -649,6 +681,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
childWidth={innerWidth}
config={config}
isFocused={isFocused}
isPending={isPending}
/>
)}
{effectiveDisplayRenderer.type === 'diff' && (
Expand Down
Loading
Loading