Skip to content

Commit 9364dd8

Browse files
authored
feat(core): infrastructure for event-driven subagent history (#23914)
1 parent 6d48a12 commit 9364dd8

16 files changed

Lines changed: 524 additions & 90 deletions

packages/cli/src/ui/components/HistoryItemDisplay.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
1717
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
1818
import { CompressionMessage } from './messages/CompressionMessage.js';
1919
import { WarningMessage } from './messages/WarningMessage.js';
20+
import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js';
2021
import { Box } from 'ink';
2122
import { AboutBox } from './AboutBox.js';
2223
import { StatsDisplay } from './StatsDisplay.js';
@@ -215,6 +216,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
215216
isExpandable={isExpandable}
216217
/>
217218
)}
219+
{itemForDisplay.type === 'subagent' && (
220+
<SubagentHistoryMessage
221+
item={itemForDisplay}
222+
terminalWidth={terminalWidth}
223+
/>
224+
)}
218225
{itemForDisplay.type === 'compression' && (
219226
<CompressionMessage compression={itemForDisplay.compression} />
220227
)}

packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../../test-utils/render.js';
88
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
99
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
1010
import type { IndividualToolCallDisplay } from '../../types.js';
11-
import { vi } from 'vitest';
11+
import { describe, it, expect, vi } from 'vitest';
1212
import { Text } from 'ink';
1313

1414
vi.mock('../../utils/MarkdownDisplay.js', () => ({

packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,9 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
191191
}
192192
}
193193

194+
const history = toolCall.subagentHistory ?? progress.recentActivity;
194195
const lastActivity: SubagentActivityItem | undefined =
195-
progress.recentActivity[progress.recentActivity.length - 1];
196+
history[history.length - 1];
196197

197198
// Collapsed View: Show single compact line per agent
198199
if (!isExpanded) {
@@ -260,6 +261,7 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
260261
<SubagentProgressDisplay
261262
progress={progress}
262263
terminalWidth={terminalWidth}
264+
historyOverrides={toolCall.subagentHistory}
263265
/>
264266
</Box>
265267
);
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { renderWithProviders } from '../../../test-utils/render.js';
9+
import { SubagentHistoryMessage } from './SubagentHistoryMessage.js';
10+
import type { HistoryItemSubagent } from '../../types.js';
11+
12+
describe('SubagentHistoryMessage', () => {
13+
const mockItem: HistoryItemSubagent = {
14+
type: 'subagent',
15+
agentName: 'research',
16+
history: [
17+
{
18+
id: '1',
19+
type: 'thought',
20+
content: 'Thinking about the problem',
21+
status: 'completed',
22+
},
23+
{
24+
id: '2',
25+
type: 'tool_call',
26+
content: 'Calling search_web',
27+
status: 'running',
28+
},
29+
{
30+
id: '3',
31+
type: 'tool_call',
32+
content: 'Calling read_file fail',
33+
status: 'error',
34+
},
35+
],
36+
};
37+
38+
it('renders header with agent name and item count', async () => {
39+
const renderResult = await renderWithProviders(
40+
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
41+
);
42+
await renderResult.waitUntilReady();
43+
44+
const output = renderResult.lastFrame();
45+
expect(output).toContain('research Trace (3 items)');
46+
expect(output).toMatchSnapshot();
47+
await expect(renderResult).toMatchSvgSnapshot();
48+
renderResult.unmount();
49+
});
50+
51+
it('renders thought activities with brain icon', async () => {
52+
const renderResult = await renderWithProviders(
53+
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
54+
);
55+
await renderResult.waitUntilReady();
56+
57+
const output = renderResult.lastFrame();
58+
expect(output).toContain('🧠 Thinking about the problem');
59+
renderResult.unmount();
60+
});
61+
62+
it('renders tool call activities with tool icon', async () => {
63+
const renderResult = await renderWithProviders(
64+
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
65+
);
66+
await renderResult.waitUntilReady();
67+
68+
const output = renderResult.lastFrame();
69+
expect(output).toContain('🛠️ Calling search_web');
70+
renderResult.unmount();
71+
});
72+
73+
it('renders status indicators correctly', async () => {
74+
const renderResult = await renderWithProviders(
75+
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
76+
);
77+
await renderResult.waitUntilReady();
78+
79+
const output = renderResult.lastFrame();
80+
expect(output).toContain('Calling search_web (Running...)');
81+
expect(output).toContain('Thinking about the problem ✅');
82+
expect(output).toContain('Calling read_file fail ❌');
83+
renderResult.unmount();
84+
});
85+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type React from 'react';
8+
import { Box, Text } from 'ink';
9+
import type { HistoryItemSubagent } from '../../types.js';
10+
11+
interface SubagentHistoryMessageProps {
12+
item: HistoryItemSubagent;
13+
terminalWidth: number;
14+
}
15+
16+
export const SubagentHistoryMessage: React.FC<SubagentHistoryMessageProps> = ({
17+
item,
18+
terminalWidth,
19+
}) => (
20+
<Box flexDirection="column" width={terminalWidth} marginBottom={1}>
21+
<Box marginBottom={1}>
22+
<Text bold color="cyan">
23+
🤖 {item.agentName} Trace ({item.history.length} items)
24+
</Text>
25+
</Box>
26+
27+
{item.history.map((activity) => (
28+
<Box key={activity.id} marginLeft={2} marginBottom={0}>
29+
<Text color={activity.type === 'thought' ? 'gray' : 'white'}>
30+
{activity.type === 'thought' ? '🧠' : '🛠️'} {activity.content}
31+
{activity.status === 'running' && ' (Running...)'}
32+
{activity.status === 'completed' && ' ✅'}
33+
{activity.status === 'error' && ' ❌'}
34+
</Text>
35+
</Box>
36+
))}
37+
</Box>
38+
);

packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx

Lines changed: 67 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { safeJsonToMarkdown } from '@google/gemini-cli-core';
2020
export interface SubagentProgressDisplayProps {
2121
progress: SubagentProgress;
2222
terminalWidth: number;
23+
historyOverrides?: SubagentActivityItem[];
2324
}
2425

2526
export const formatToolArgs = (args?: string): string => {
@@ -57,7 +58,7 @@ export const formatToolArgs = (args?: string): string => {
5758

5859
export const SubagentProgressDisplay: React.FC<
5960
SubagentProgressDisplayProps
60-
> = ({ progress, terminalWidth }) => {
61+
> = ({ progress, terminalWidth, historyOverrides }) => {
6162
let headerText: string | undefined;
6263
let headerColor = theme.text.secondary;
6364

@@ -85,72 +86,77 @@ export const SubagentProgressDisplay: React.FC<
8586
</Box>
8687
)}
8788
<Box flexDirection="column" marginLeft={0} gap={0}>
88-
{progress.recentActivity.map((item: SubagentActivityItem) => {
89-
if (item.type === 'thought') {
90-
const isCancellation = item.content === 'Request cancelled.';
91-
const icon = isCancellation ? 'ℹ ' : '💭';
92-
const color = isCancellation
93-
? theme.status.warning
94-
: theme.text.secondary;
89+
{(historyOverrides ?? progress.recentActivity).map(
90+
(item: SubagentActivityItem) => {
91+
if (item.type === 'thought') {
92+
const isCancellation = item.content === 'Request cancelled.';
93+
const icon = isCancellation ? 'ℹ ' : '💭';
94+
const color = isCancellation
95+
? theme.status.warning
96+
: theme.text.secondary;
9597

96-
return (
97-
<Box key={item.id} flexDirection="row">
98-
<Box minWidth={STATUS_INDICATOR_WIDTH}>
99-
<Text color={color}>{icon}</Text>
98+
return (
99+
<Box key={item.id} flexDirection="row">
100+
<Box minWidth={STATUS_INDICATOR_WIDTH}>
101+
<Text color={color}>{icon}</Text>
102+
</Box>
103+
<Box flexGrow={1}>
104+
<Text color={color}>{item.content}</Text>
105+
</Box>
100106
</Box>
101-
<Box flexGrow={1}>
102-
<Text color={color}>{item.content}</Text>
103-
</Box>
104-
</Box>
105-
);
106-
} else if (item.type === 'tool_call') {
107-
const statusSymbol =
108-
item.status === 'running' ? (
109-
<Spinner type="dots" />
110-
) : item.status === 'completed' ? (
111-
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
112-
) : item.status === 'cancelled' ? (
113-
<Text color={theme.status.warning} bold>
114-
{TOOL_STATUS.CANCELED}
115-
</Text>
116-
) : (
117-
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
118107
);
108+
} else if (item.type === 'tool_call') {
109+
const statusSymbol =
110+
item.status === 'running' ? (
111+
<Spinner type="dots" />
112+
) : item.status === 'completed' ? (
113+
<Text color={theme.status.success}>
114+
{TOOL_STATUS.SUCCESS}
115+
</Text>
116+
) : item.status === 'cancelled' ? (
117+
<Text color={theme.status.warning} bold>
118+
{TOOL_STATUS.CANCELED}
119+
</Text>
120+
) : (
121+
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
122+
);
119123

120-
const formattedArgs = item.description || formatToolArgs(item.args);
121-
const displayArgs =
122-
formattedArgs.length > 60
123-
? formattedArgs.slice(0, 60) + '...'
124-
: formattedArgs;
124+
const formattedArgs =
125+
item.description || formatToolArgs(item.args);
126+
const displayArgs =
127+
formattedArgs.length > 60
128+
? formattedArgs.slice(0, 60) + '...'
129+
: formattedArgs;
125130

126-
return (
127-
<Box key={item.id} flexDirection="row">
128-
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
129-
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
130-
<Text
131-
bold
132-
color={theme.text.primary}
133-
strikethrough={item.status === 'cancelled'}
134-
>
135-
{item.displayName || item.content}
136-
</Text>
137-
{displayArgs && (
138-
<Box marginLeft={1}>
139-
<Text
140-
color={theme.text.secondary}
141-
wrap="truncate"
142-
strikethrough={item.status === 'cancelled'}
143-
>
144-
{displayArgs}
145-
</Text>
146-
</Box>
147-
)}
131+
return (
132+
<Box key={item.id} flexDirection="row">
133+
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
134+
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
135+
<Text
136+
bold
137+
color={theme.text.primary}
138+
strikethrough={item.status === 'cancelled'}
139+
>
140+
{item.displayName || item.content}
141+
</Text>
142+
{displayArgs && (
143+
<Box marginLeft={1}>
144+
<Text
145+
color={theme.text.secondary}
146+
wrap="truncate"
147+
strikethrough={item.status === 'cancelled'}
148+
>
149+
{displayArgs}
150+
</Text>
151+
</Box>
152+
)}
153+
</Box>
148154
</Box>
149-
</Box>
150-
);
151-
}
152-
return null;
153-
})}
155+
);
156+
}
157+
return null;
158+
},
159+
)}
154160
</Box>
155161

156162
{progress.result && (
Lines changed: 12 additions & 0 deletions
Loading
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`SubagentHistoryMessage > renders header with agent name and item count 1`] = `
4+
"🤖 research Trace (3 items)
5+
6+
🧠 Thinking about the problem ✅
7+
🛠️ Calling search_web (Running...)
8+
🛠️ Calling read_file fail ❌
9+
"
10+
`;
11+
12+
exports[`SubagentHistoryMessage > renders header with agent name and item count 2`] = `
13+
"🤖 research Trace (3 items)
14+
15+
🧠 Thinking about the problem ✅
16+
🛠️ Calling search_web (Running...)
17+
🛠️ Calling read_file fail ❌
18+
"
19+
`;

packages/cli/src/ui/hooks/toolMapping.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ import {
1010
type ToolResultDisplay,
1111
debugLogger,
1212
CoreToolCallStatus,
13+
type SubagentActivityItem,
1314
} from '@google/gemini-cli-core';
1415
import {
1516
type HistoryItemToolGroup,
1617
type IndividualToolCallDisplay,
1718
} from '../types.js';
1819

20+
function hasSubagentHistory(
21+
call: ToolCall,
22+
): call is ToolCall & { subagentHistory: SubagentActivityItem[] } {
23+
return 'subagentHistory' in call && call.subagentHistory !== undefined;
24+
}
25+
1926
/**
2027
* Transforms `ToolCall` objects into `HistoryItemToolGroup` objects for UI
2128
* display. This is a pure projection layer and does not track interaction
@@ -115,6 +122,9 @@ export function mapToDisplay(
115122
progressTotal,
116123
approvalMode: call.approvalMode,
117124
originalRequestName: call.request.originalRequestName,
125+
subagentHistory: hasSubagentHistory(call)
126+
? call.subagentHistory
127+
: undefined,
118128
};
119129
});
120130

0 commit comments

Comments
 (0)