feat(core): infrastructure for event-driven subagent history (#23914)

This commit is contained in:
Abhi
2026-03-31 17:54:22 -04:00
committed by GitHub
parent 6d48a12efe
commit 9364dd8a49
16 changed files with 525 additions and 91 deletions

View File

@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { vi } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
vi.mock('../../utils/MarkdownDisplay.js', () => ({

View File

@@ -191,8 +191,9 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
}
}
const history = toolCall.subagentHistory ?? progress.recentActivity;
const lastActivity: SubagentActivityItem | undefined =
progress.recentActivity[progress.recentActivity.length - 1];
history[history.length - 1];
// Collapsed View: Show single compact line per agent
if (!isExpanded) {
@@ -260,6 +261,7 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
<SubagentProgressDisplay
progress={progress}
terminalWidth={terminalWidth}
historyOverrides={toolCall.subagentHistory}
/>
</Box>
);

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { SubagentHistoryMessage } from './SubagentHistoryMessage.js';
import type { HistoryItemSubagent } from '../../types.js';
describe('SubagentHistoryMessage', () => {
const mockItem: HistoryItemSubagent = {
type: 'subagent',
agentName: 'research',
history: [
{
id: '1',
type: 'thought',
content: 'Thinking about the problem',
status: 'completed',
},
{
id: '2',
type: 'tool_call',
content: 'Calling search_web',
status: 'running',
},
{
id: '3',
type: 'tool_call',
content: 'Calling read_file fail',
status: 'error',
},
],
};
it('renders header with agent name and item count', async () => {
const renderResult = await renderWithProviders(
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
);
await renderResult.waitUntilReady();
const output = renderResult.lastFrame();
expect(output).toContain('research Trace (3 items)');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders thought activities with brain icon', async () => {
const renderResult = await renderWithProviders(
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
);
await renderResult.waitUntilReady();
const output = renderResult.lastFrame();
expect(output).toContain('🧠 Thinking about the problem');
renderResult.unmount();
});
it('renders tool call activities with tool icon', async () => {
const renderResult = await renderWithProviders(
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
);
await renderResult.waitUntilReady();
const output = renderResult.lastFrame();
expect(output).toContain('🛠️ Calling search_web');
renderResult.unmount();
});
it('renders status indicators correctly', async () => {
const renderResult = await renderWithProviders(
<SubagentHistoryMessage item={mockItem} terminalWidth={80} />,
);
await renderResult.waitUntilReady();
const output = renderResult.lastFrame();
expect(output).toContain('Calling search_web (Running...)');
expect(output).toContain('Thinking about the problem ✅');
expect(output).toContain('Calling read_file fail ❌');
renderResult.unmount();
});
});

View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import type { HistoryItemSubagent } from '../../types.js';
interface SubagentHistoryMessageProps {
item: HistoryItemSubagent;
terminalWidth: number;
}
export const SubagentHistoryMessage: React.FC<SubagentHistoryMessageProps> = ({
item,
terminalWidth,
}) => (
<Box flexDirection="column" width={terminalWidth} marginBottom={1}>
<Box marginBottom={1}>
<Text bold color="cyan">
🤖 {item.agentName} Trace ({item.history.length} items)
</Text>
</Box>
{item.history.map((activity) => (
<Box key={activity.id} marginLeft={2} marginBottom={0}>
<Text color={activity.type === 'thought' ? 'gray' : 'white'}>
{activity.type === 'thought' ? '🧠' : '🛠️'} {activity.content}
{activity.status === 'running' && ' (Running...)'}
{activity.status === 'completed' && ' ✅'}
{activity.status === 'error' && ' ❌'}
</Text>
</Box>
))}
</Box>
);

View File

@@ -20,6 +20,7 @@ import { safeJsonToMarkdown } from '@google/gemini-cli-core';
export interface SubagentProgressDisplayProps {
progress: SubagentProgress;
terminalWidth: number;
historyOverrides?: SubagentActivityItem[];
}
export const formatToolArgs = (args?: string): string => {
@@ -57,7 +58,7 @@ export const formatToolArgs = (args?: string): string => {
export const SubagentProgressDisplay: React.FC<
SubagentProgressDisplayProps
> = ({ progress, terminalWidth }) => {
> = ({ progress, terminalWidth, historyOverrides }) => {
let headerText: string | undefined;
let headerColor = theme.text.secondary;
@@ -85,72 +86,77 @@ export const SubagentProgressDisplay: React.FC<
</Box>
)}
<Box flexDirection="column" marginLeft={0} gap={0}>
{progress.recentActivity.map((item: SubagentActivityItem) => {
if (item.type === 'thought') {
const isCancellation = item.content === 'Request cancelled.';
const icon = isCancellation ? ' ' : '💭';
const color = isCancellation
? theme.status.warning
: theme.text.secondary;
{(historyOverrides ?? progress.recentActivity).map(
(item: SubagentActivityItem) => {
if (item.type === 'thought') {
const isCancellation = item.content === 'Request cancelled.';
const icon = isCancellation ? ' ' : '💭';
const color = isCancellation
? theme.status.warning
: theme.text.secondary;
return (
<Box key={item.id} flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>
<Text color={color}>{icon}</Text>
return (
<Box key={item.id} flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>
<Text color={color}>{icon}</Text>
</Box>
<Box flexGrow={1}>
<Text color={color}>{item.content}</Text>
</Box>
</Box>
<Box flexGrow={1}>
<Text color={color}>{item.content}</Text>
</Box>
</Box>
);
} else if (item.type === 'tool_call') {
const statusSymbol =
item.status === 'running' ? (
<Spinner type="dots" />
) : item.status === 'completed' ? (
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
) : item.status === 'cancelled' ? (
<Text color={theme.status.warning} bold>
{TOOL_STATUS.CANCELED}
</Text>
) : (
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
);
const formattedArgs = item.description || formatToolArgs(item.args);
const displayArgs =
formattedArgs.length > 60
? formattedArgs.slice(0, 60) + '...'
: formattedArgs;
return (
<Box key={item.id} flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
<Text
bold
color={theme.text.primary}
strikethrough={item.status === 'cancelled'}
>
{item.displayName || item.content}
} else if (item.type === 'tool_call') {
const statusSymbol =
item.status === 'running' ? (
<Spinner type="dots" />
) : item.status === 'completed' ? (
<Text color={theme.status.success}>
{TOOL_STATUS.SUCCESS}
</Text>
{displayArgs && (
<Box marginLeft={1}>
<Text
color={theme.text.secondary}
wrap="truncate"
strikethrough={item.status === 'cancelled'}
>
{displayArgs}
</Text>
</Box>
)}
) : item.status === 'cancelled' ? (
<Text color={theme.status.warning} bold>
{TOOL_STATUS.CANCELED}
</Text>
) : (
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
);
const formattedArgs =
item.description || formatToolArgs(item.args);
const displayArgs =
formattedArgs.length > 60
? formattedArgs.slice(0, 60) + '...'
: formattedArgs;
return (
<Box key={item.id} flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
<Text
bold
color={theme.text.primary}
strikethrough={item.status === 'cancelled'}
>
{item.displayName || item.content}
</Text>
{displayArgs && (
<Box marginLeft={1}>
<Text
color={theme.text.secondary}
wrap="truncate"
strikethrough={item.status === 'cancelled'}
>
{displayArgs}
</Text>
</Box>
)}
</Box>
</Box>
</Box>
);
}
return null;
})}
);
}
return null;
},
)}
</Box>
{progress.result && (

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cdcd" textLength="234" lengthAdjust="spacingAndGlyphs" font-weight="bold">🤖 research Trace (3 items)</text>
<text x="18" y="36" fill="#7f7f7f" textLength="270" lengthAdjust="spacingAndGlyphs">🧠 Thinking about the problem ✅</text>
<text x="18" y="53" fill="#e5e5e5" textLength="297" lengthAdjust="spacingAndGlyphs">🛠️ Calling search_web (Running...)</text>
<text x="18" y="70" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs">🛠️ Calling read_file fail ❌</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 881 B

View File

@@ -0,0 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SubagentHistoryMessage > renders header with agent name and item count 1`] = `
"🤖 research Trace (3 items)
🧠 Thinking about the problem ✅
🛠️ Calling search_web (Running...)
🛠️ Calling read_file fail ❌
"
`;
exports[`SubagentHistoryMessage > renders header with agent name and item count 2`] = `
"🤖 research Trace (3 items)
🧠 Thinking about the problem ✅
🛠️ Calling search_web (Running...)
🛠️ Calling read_file fail ❌
"
`;