mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -07:00
refactor(cli): migrate core tools to native ToolDisplay property and fix UI rendering (#25186)
This commit is contained in:
@@ -14,6 +14,7 @@ import { GeminiMessage } from './messages/GeminiMessage.js';
|
||||
import { InfoMessage } from './messages/InfoMessage.js';
|
||||
import { ErrorMessage } from './messages/ErrorMessage.js';
|
||||
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js';
|
||||
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
@@ -195,6 +196,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_display_group' && (
|
||||
<ToolGroupDisplay
|
||||
item={itemForDisplay}
|
||||
isToolGroupBoundary={isToolGroupBoundary}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'subagent' && (
|
||||
<SubagentHistoryMessage
|
||||
item={itemForDisplay}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { ToolGroupDisplay } from './ToolGroupDisplay.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryItemToolDisplayGroup,
|
||||
ToolDisplayItem,
|
||||
} from '../../types.js';
|
||||
|
||||
describe('<ToolGroupDisplay />', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createToolItem = (
|
||||
overrides: Partial<ToolDisplayItem> = {},
|
||||
): ToolDisplayItem => ({
|
||||
status: CoreToolCallStatus.Success,
|
||||
name: 'test-tool',
|
||||
description: 'Test description',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createHistoryItem = (
|
||||
tools: ToolDisplayItem[],
|
||||
overrides: Partial<HistoryItemToolDisplayGroup> = {},
|
||||
): HistoryItemToolDisplayGroup => ({
|
||||
type: 'tool_display_group',
|
||||
tools,
|
||||
borderColor: 'gray',
|
||||
borderDimColor: true,
|
||||
borderTop: true,
|
||||
borderBottom: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fullVerbositySettings = createMockSettings({
|
||||
ui: { errorVerbosity: 'full', compactToolOutput: false },
|
||||
});
|
||||
const compactSettings = createMockSettings({
|
||||
ui: { compactToolOutput: true },
|
||||
});
|
||||
|
||||
describe('Golden Snapshots', () => {
|
||||
it('renders notices at the top (hoisting)', async () => {
|
||||
const tools = [
|
||||
createToolItem({ name: 'Tool A', format: 'box' }),
|
||||
createToolItem({
|
||||
name: UPDATE_TOPIC_DISPLAY_NAME,
|
||||
description: 'New Topic',
|
||||
format: 'notice',
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// Notice should be before Tool A
|
||||
expect(output.indexOf(UPDATE_TOPIC_DISPLAY_NAME)).toBeLessThan(
|
||||
output.indexOf('Tool A'),
|
||||
);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders in compact mode (no box borders)', async () => {
|
||||
const tools = [
|
||||
createToolItem({ name: 'Tool A' }),
|
||||
createToolItem({ name: 'Tool B' }),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should not contain box drawing characters for the outer box
|
||||
expect(output).not.toContain('╭');
|
||||
expect(output).not.toContain('╰');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders in boxed mode (full verbosity)', async () => {
|
||||
const tools = [createToolItem({ name: 'Tool A' })];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('╭');
|
||||
expect(output).toContain('╰');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders standalone notices without a box', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
name: 'Notice Only',
|
||||
format: 'notice',
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('╭');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders error message when display info is missing', async () => {
|
||||
// Create an item that effectively has no display properties
|
||||
const tools = [
|
||||
{
|
||||
status: CoreToolCallStatus.Executing,
|
||||
originalRequestName: 'missing-tool',
|
||||
} as ToolDisplayItem,
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Error: Tool display missing');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides tools awaiting approval (confirming)', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
name: 'Confirming Tool',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
);
|
||||
|
||||
// Should render nothing (null)
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Result Formatting', () => {
|
||||
it('renders text results with summary below', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
result: { type: 'text', text: 'Detailed output' },
|
||||
resultSummary: 'Short summary',
|
||||
format: 'box',
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Detailed output');
|
||||
expect(output).toContain('Short summary');
|
||||
// Summary should be below detailed output
|
||||
expect(output.indexOf('Detailed output')).toBeLessThan(
|
||||
output.indexOf('Short summary'),
|
||||
);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders compact tools with summary on same line', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
resultSummary: 'Success summary',
|
||||
format: 'compact',
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Success summary');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders placeholder for diff results', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
result: {
|
||||
type: 'diff',
|
||||
beforeText: 'old',
|
||||
afterText: 'new',
|
||||
path: 'file.ts',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('[Diff Display: 3 -> 3 chars]');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders placeholder for terminal results', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
result: { type: 'terminal' },
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('[Terminal Output]');
|
||||
});
|
||||
|
||||
it('renders placeholder for agent results', async () => {
|
||||
const tools = [
|
||||
createToolItem({
|
||||
result: { type: 'agent', threadId: 'thread-123' },
|
||||
}),
|
||||
];
|
||||
const item = createHistoryItem(tools);
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('[Subagent: thread-123]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Border & Margin Logic', () => {
|
||||
it('forces top border on box when it follows a notice', async () => {
|
||||
const tools = [
|
||||
createToolItem({ name: 'Notice', format: 'notice' }),
|
||||
createToolItem({ name: 'Tool in Box', format: 'box' }),
|
||||
];
|
||||
// Even if item.borderTop is false (continuing a group),
|
||||
// the box should have a top border because it follows a notice.
|
||||
const item = createHistoryItem(tools, { borderTop: false });
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Notice');
|
||||
expect(output).toContain('╭'); // Top border for the box
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('applies bottom margin in compact mode when group is at boundary', async () => {
|
||||
const tools = [createToolItem({ name: 'Compact Tool' })];
|
||||
const item = createHistoryItem(tools, { borderBottom: true });
|
||||
|
||||
const { lastFrame } = await renderWithProviders(
|
||||
<ToolGroupDisplay item={item} />,
|
||||
{ settings: compactSettings },
|
||||
);
|
||||
|
||||
// This is hard to assert via string check, but ensure match snapshot
|
||||
// captures the vertical spacing.
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemToolDisplayGroup,
|
||||
ToolDisplayItem,
|
||||
} from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { ToolStatusIndicator } from './ToolShared.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
interface ToolGroupDisplayProps {
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
isToolGroupBoundary?: boolean;
|
||||
}
|
||||
|
||||
export const ToolGroupDisplay: React.FC<ToolGroupDisplayProps> = ({
|
||||
item,
|
||||
isToolGroupBoundary,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
|
||||
|
||||
if (item.type !== 'tool_display_group') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { tools, borderColor, borderDimColor, borderTop, borderBottom } =
|
||||
item as HistoryItemToolDisplayGroup;
|
||||
|
||||
const visibleTools = tools.filter(
|
||||
(t) => t.status !== CoreToolCallStatus.AwaitingApproval,
|
||||
);
|
||||
|
||||
const noticeTools = visibleTools.filter((t) => t.format === 'notice');
|
||||
const otherTools = visibleTools.filter(
|
||||
(t) => t.format !== 'notice' && t.format !== 'hidden',
|
||||
);
|
||||
|
||||
const hasOtherTools = otherTools.length > 0;
|
||||
const isClosingSlice = tools.length === 0 && borderBottom;
|
||||
|
||||
// If no tools are visible and it's not an explicit closing slice, hide the group
|
||||
if (visibleTools.length === 0 && !isClosingSlice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard view behavior: If compact mode is enabled, non-notice tools
|
||||
// are typically rendered without an outer box.
|
||||
const shouldShowBox =
|
||||
(hasOtherTools || isClosingSlice) && !isCompactModeEnabled;
|
||||
|
||||
const boxBorderTop = borderTop || noticeTools.length > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{noticeTools.map((tool, index) => {
|
||||
const isFirstInGroup = index === 0 && borderTop;
|
||||
const isLastElementInGroup =
|
||||
index === noticeTools.length - 1 && !shouldShowBox && borderBottom;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`notice-${index}`}
|
||||
marginTop={isFirstInGroup ? 1 : index > 0 ? 1 : 0}
|
||||
marginBottom={isLastElementInGroup ? 1 : 0}
|
||||
>
|
||||
<ToolDisplayMessage tool={tool} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{shouldShowBox ? (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderTop={boxBorderTop}
|
||||
borderBottom={borderBottom}
|
||||
borderLeft={!isToolGroupBoundary}
|
||||
borderRight={!isToolGroupBoundary}
|
||||
marginTop={boxBorderTop ? 1 : 0}
|
||||
marginBottom={borderBottom ? 1 : 0}
|
||||
paddingX={1}
|
||||
>
|
||||
{otherTools.map((tool, index) => (
|
||||
<ToolDisplayMessage key={`tool-${index}`} tool={tool} />
|
||||
))}
|
||||
</Box>
|
||||
) : otherTools.length > 0 ? (
|
||||
// Compact mode or no tools to box
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={noticeTools.length > 0 ? 1 : 0}
|
||||
marginBottom={borderBottom ? 1 : 0}
|
||||
>
|
||||
{otherTools.map((tool, index) => (
|
||||
<ToolDisplayMessage key={`tool-${index}`} tool={tool} />
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToolDisplayMessageProps {
|
||||
tool: ToolDisplayItem;
|
||||
}
|
||||
|
||||
const ToolDisplayMessage: React.FC<ToolDisplayMessageProps> = ({ tool }) => {
|
||||
const settings = useSettings();
|
||||
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
|
||||
|
||||
// Since ToolDisplayItem is ToolDisplay & { status, ... }, we check for identifying properties
|
||||
// of ToolDisplay. If name or description is missing and there's no result, it might be "empty".
|
||||
if (!tool.name && !tool.description && !tool.result && !tool.resultSummary) {
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<ToolStatusIndicator
|
||||
status={tool.status}
|
||||
name={tool.originalRequestName || 'unknown'}
|
||||
/>
|
||||
<Text color={theme.status.error}> Error: Tool display missing</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
status,
|
||||
format: preferredFormat,
|
||||
name,
|
||||
description,
|
||||
resultSummary,
|
||||
result,
|
||||
} = tool;
|
||||
const format = preferredFormat || 'auto';
|
||||
|
||||
if (format === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (format === 'notice') {
|
||||
// If the name is part of the description (typical for topic updates),
|
||||
// suppress the bold name to avoid redundancy and match legacy UI.
|
||||
const isRedundant = !!(name && description?.includes(`"${name}"`));
|
||||
|
||||
return (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{name && !isRedundant && (
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{name}:
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isCompact =
|
||||
format === 'compact' || (format === 'auto' && isCompactModeEnabled);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<Box paddingLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<ToolStatusIndicator
|
||||
status={status}
|
||||
name={name || tool.originalRequestName || ''}
|
||||
/>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{' '}
|
||||
{name || tool.originalRequestName}{' '}
|
||||
</Text>
|
||||
{description && <Text color={theme.text.secondary}>{description}</Text>}
|
||||
{resultSummary && (
|
||||
<Text color={theme.text.accent}>
|
||||
{' '}
|
||||
→ {resultSummary.replace(/\n/g, ' ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Box format (full)
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={0}>
|
||||
<Box flexDirection="row" paddingLeft={1}>
|
||||
<ToolStatusIndicator
|
||||
status={status}
|
||||
name={name || tool.originalRequestName || ''}
|
||||
/>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{' '}
|
||||
{name || tool.originalRequestName}{' '}
|
||||
</Text>
|
||||
{description && <Text color={theme.text.secondary}>{description}</Text>}
|
||||
</Box>
|
||||
{resultSummary && !result && (
|
||||
<Box paddingLeft={1} marginTop={1}>
|
||||
<Text color={theme.text.primary}>{resultSummary}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{result && (
|
||||
<Box paddingLeft={1} marginTop={1}>
|
||||
<ToolResultDisplayContent content={result} summary={resultSummary} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToolResultDisplayContentProps {
|
||||
content: ToolDisplayItem['result'];
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
const ToolResultDisplayContent: React.FC<ToolResultDisplayContentProps> = ({
|
||||
content,
|
||||
summary,
|
||||
}) => {
|
||||
if (!content) return null;
|
||||
|
||||
switch (content.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>{content.text}</Text>
|
||||
{summary && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={theme.text.primary}>{summary}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
case 'diff':
|
||||
// Simplified diff display for now
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{summary && <Text color={theme.text.primary}>{summary}</Text>}
|
||||
<Text color={theme.text.secondary}>
|
||||
{`[Diff Display: ${content.beforeText.length} -> ${content.afterText.length} chars]`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
case 'terminal':
|
||||
return <Text color={theme.text.secondary}>[Terminal Output]</Text>;
|
||||
case 'agent':
|
||||
return (
|
||||
<Text color={theme.text.secondary}>[Subagent: {content.threadId}]</Text>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Border & Margin Logic > applies bottom margin in compact mode when group is at boundary 1`] = `
|
||||
" ✓ Compact Tool Test description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Border & Margin Logic > forces top border on box when it follows a notice 1`] = `
|
||||
" Notice:
|
||||
Test description
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Tool in Box Test description │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Golden Snapshots > renders error message when display info is missing 1`] = `
|
||||
" ⊶ Error: Tool display missing
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Golden Snapshots > renders in boxed mode (full verbosity) 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Tool A Test description │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Golden Snapshots > renders in compact mode (no box borders) 1`] = `
|
||||
" ✓ Tool A Test description
|
||||
✓ Tool B Test description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Golden Snapshots > renders notices at the top (hoisting) 1`] = `
|
||||
"
|
||||
Update Topic Context:
|
||||
New Topic
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Tool A Test description │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Golden Snapshots > renders standalone notices without a box 1`] = `
|
||||
"
|
||||
Notice Only:
|
||||
Test description
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Result Formatting > renders compact tools with summary on same line 1`] = `
|
||||
" ✓ test-tool Test description → Success summary
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Result Formatting > renders placeholder for diff results 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ test-tool Test description │
|
||||
│ │
|
||||
│ [Diff Display: 3 -> 3 chars] │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupDisplay /> > Result Formatting > renders text results with summary below 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ test-tool Test description │
|
||||
│ │
|
||||
│ Detailed output │
|
||||
│ Short summary │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
HistoryItemWithoutId,
|
||||
LoopDetectionConfirmationRequest,
|
||||
IndividualToolCallDisplay,
|
||||
HistoryItemToolGroup,
|
||||
HistoryItemToolDisplayGroup,
|
||||
} from '../types.js';
|
||||
import { StreamingState, MessageType } from '../types.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
@@ -81,6 +81,8 @@ export const useAgentStream = ({
|
||||
useStateAndRef<Set<string>>(new Set());
|
||||
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
||||
useStateAndRef<boolean>(true);
|
||||
const [_hasEmittedBoxInTurn, hasEmittedBoxInTurnRef, setHasEmittedBoxInTurn] =
|
||||
useStateAndRef<boolean>(false);
|
||||
|
||||
const { startNewPrompt } = useSessionStats();
|
||||
|
||||
@@ -408,32 +410,27 @@ export const useAgentStream = ({
|
||||
|
||||
// Push completed tools to history
|
||||
useEffect(() => {
|
||||
const toolsToPush: IndividualToolCallDisplay[] = [];
|
||||
for (let i = 0; i < trackedTools.length; i++) {
|
||||
const tc = trackedTools[i];
|
||||
if (pushedToolCallIdsRef.current.has(tc.callId)) continue;
|
||||
if (trackedTools.length === 0) return;
|
||||
|
||||
if (
|
||||
// We only push to history once all currently known tools in the turn are terminal.
|
||||
// This allows ToolGroupDisplay to correctly hoist ALL notices (topics) for the turn.
|
||||
const allTerminal = trackedTools.every(
|
||||
(tc) =>
|
||||
tc.status === 'success' ||
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled'
|
||||
) {
|
||||
toolsToPush.push(tc);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tc.status === 'cancelled',
|
||||
);
|
||||
|
||||
if (toolsToPush.length > 0) {
|
||||
const toolsToPush = trackedTools.filter(
|
||||
(tc) => !pushedToolCallIdsRef.current.has(tc.callId),
|
||||
);
|
||||
|
||||
if (allTerminal && toolsToPush.length > 0) {
|
||||
const newPushed = new Set(pushedToolCallIdsRef.current);
|
||||
for (const tc of toolsToPush) {
|
||||
newPushed.add(tc.callId);
|
||||
}
|
||||
|
||||
const isLastInBatch =
|
||||
toolsToPush[toolsToPush.length - 1] ===
|
||||
trackedTools[trackedTools.length - 1];
|
||||
|
||||
const appearance = getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: trackedTools },
|
||||
activePtyId,
|
||||
@@ -442,24 +439,43 @@ export const useAgentStream = ({
|
||||
backgroundTasks,
|
||||
);
|
||||
|
||||
const historyItem: HistoryItemToolGroup = {
|
||||
type: 'tool_group',
|
||||
tools: toolsToPush,
|
||||
borderTop: isFirstToolInGroupRef.current,
|
||||
borderBottom: isLastInBatch,
|
||||
const hasBoxInBatch = toolsToPush.some(
|
||||
(tc) => tc.display?.format !== 'notice',
|
||||
);
|
||||
const shouldStartNewBlock =
|
||||
isFirstToolInGroupRef.current ||
|
||||
(!hasEmittedBoxInTurnRef.current && hasBoxInBatch);
|
||||
|
||||
const historyItem: HistoryItemToolDisplayGroup = {
|
||||
type: 'tool_display_group',
|
||||
tools: toolsToPush.map((tc) => ({
|
||||
name: tc.name,
|
||||
description: tc.description,
|
||||
...tc.display,
|
||||
status: tc.status,
|
||||
originalRequestName: tc.originalRequestName,
|
||||
})),
|
||||
borderTop: shouldStartNewBlock,
|
||||
borderBottom: true,
|
||||
...appearance,
|
||||
};
|
||||
|
||||
addItem(historyItem);
|
||||
setPushedToolCallIds(newPushed);
|
||||
|
||||
if (hasBoxInBatch) {
|
||||
setHasEmittedBoxInTurn(true);
|
||||
}
|
||||
setIsFirstToolInGroup(false);
|
||||
}
|
||||
}, [
|
||||
trackedTools,
|
||||
pushedToolCallIdsRef,
|
||||
isFirstToolInGroupRef,
|
||||
hasEmittedBoxInTurnRef,
|
||||
setPushedToolCallIds,
|
||||
setIsFirstToolInGroup,
|
||||
setHasEmittedBoxInTurn,
|
||||
addItem,
|
||||
activePtyId,
|
||||
isShellFocused,
|
||||
@@ -468,7 +484,7 @@ export const useAgentStream = ({
|
||||
|
||||
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
||||
const remainingTools = trackedTools.filter(
|
||||
(tc) => !pushedToolCallIds.has(tc.callId),
|
||||
(tc) => !pushedToolCallIdsRef.current.has(tc.callId),
|
||||
);
|
||||
|
||||
const items: HistoryItemWithoutId[] = [];
|
||||
@@ -482,10 +498,23 @@ export const useAgentStream = ({
|
||||
);
|
||||
|
||||
if (remainingTools.length > 0) {
|
||||
const hasBoxInPending = remainingTools.some(
|
||||
(tc) => tc.display?.format !== 'notice',
|
||||
);
|
||||
const shouldStartNewBlock =
|
||||
pushedToolCallIds.size === 0 ||
|
||||
(!hasEmittedBoxInTurnRef.current && hasBoxInPending);
|
||||
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: remainingTools,
|
||||
borderTop: pushedToolCallIds.size === 0,
|
||||
type: 'tool_display_group',
|
||||
tools: remainingTools.map((tc) => ({
|
||||
name: tc.name,
|
||||
description: tc.description,
|
||||
...tc.display,
|
||||
status: tc.status,
|
||||
originalRequestName: tc.originalRequestName,
|
||||
})),
|
||||
borderTop: shouldStartNewBlock,
|
||||
borderBottom: false,
|
||||
...appearance,
|
||||
});
|
||||
@@ -513,7 +542,7 @@ export const useAgentStream = ({
|
||||
(anyVisibleInHistory || anyVisibleInPending)
|
||||
) {
|
||||
items.push({
|
||||
type: 'tool_group' as const,
|
||||
type: 'tool_display_group',
|
||||
tools: [],
|
||||
borderTop: false,
|
||||
borderBottom: true,
|
||||
@@ -525,6 +554,8 @@ export const useAgentStream = ({
|
||||
}, [
|
||||
trackedTools,
|
||||
pushedToolCallIds,
|
||||
pushedToolCallIdsRef,
|
||||
hasEmittedBoxInTurnRef,
|
||||
activePtyId,
|
||||
isShellFocused,
|
||||
backgroundTasks,
|
||||
|
||||
@@ -260,6 +260,20 @@ export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
borderDimColor?: boolean;
|
||||
};
|
||||
|
||||
export type ToolDisplayItem = ToolDisplay & {
|
||||
status: CoreToolCallStatus;
|
||||
originalRequestName?: string;
|
||||
};
|
||||
|
||||
export type HistoryItemToolDisplayGroup = HistoryItemBase & {
|
||||
type: 'tool_display_group';
|
||||
tools: ToolDisplayItem[];
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
borderColor?: string;
|
||||
borderDimColor?: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemUserShell = HistoryItemBase & {
|
||||
type: 'user_shell';
|
||||
text: string;
|
||||
@@ -406,6 +420,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemAbout
|
||||
| HistoryItemHelp
|
||||
| HistoryItemToolGroup
|
||||
| HistoryItemToolDisplayGroup
|
||||
| HistoryItemStats
|
||||
| HistoryItemModelStats
|
||||
| HistoryItemToolStats
|
||||
|
||||
@@ -236,6 +236,7 @@ export function translateEvent(
|
||||
requestId: event.value.callId,
|
||||
name: event.value.name,
|
||||
args: event.value.args,
|
||||
display: event.value.display,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -243,13 +244,15 @@ export function translateEvent(
|
||||
case GeminiEventType.ToolCallResponse: {
|
||||
ensureStreamStart(state, out);
|
||||
const data = buildToolResponseData(event.value);
|
||||
const display: ToolDisplay | undefined = event.value.resultDisplay
|
||||
? {
|
||||
result: toolResultDisplayToDisplayContent(
|
||||
event.value.resultDisplay,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
const display: ToolDisplay | undefined =
|
||||
event.value.display ??
|
||||
(event.value.resultDisplay
|
||||
? {
|
||||
result: toolResultDisplayToDisplayContent(
|
||||
event.value.resultDisplay,
|
||||
),
|
||||
}
|
||||
: undefined);
|
||||
out.push(
|
||||
makeEvent('tool_response', state, {
|
||||
requestId: event.value.callId,
|
||||
@@ -279,7 +282,6 @@ export function translateEvent(
|
||||
((x: never) => {
|
||||
throw new Error(`Unhandled event type: ${JSON.stringify(x)}`);
|
||||
})(event);
|
||||
break;
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
@@ -102,7 +102,10 @@ function makeCompletedToolCall(
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [{ text: responseText }],
|
||||
resultDisplay: undefined,
|
||||
resultDisplay: responseText,
|
||||
display: {
|
||||
result: { type: 'text', text: responseText },
|
||||
},
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
@@ -426,6 +429,12 @@ describe('LegacyAgentSession', () => {
|
||||
(e): e is AgentEvent<'tool_response'> => e.type === 'tool_response',
|
||||
);
|
||||
expect(toolResp?.name).toBe('read_file');
|
||||
expect(toolResp?.display).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'read_file',
|
||||
result: { type: 'text', text: 'file contents' },
|
||||
}),
|
||||
);
|
||||
expect(toolResp?.content).toEqual([
|
||||
{ type: 'text', text: 'file contents' },
|
||||
]);
|
||||
|
||||
@@ -266,6 +266,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
invocation: 'invocation' in tc ? tc.invocation : undefined,
|
||||
resultDisplay: response.resultDisplay,
|
||||
displayName: 'tool' in tc ? tc.tool?.displayName : undefined,
|
||||
display: response.display,
|
||||
});
|
||||
const data = buildToolResponseData(response);
|
||||
|
||||
|
||||
@@ -21,18 +21,21 @@ export function populateToolDisplay({
|
||||
invocation,
|
||||
resultDisplay,
|
||||
displayName,
|
||||
display: prevDisplay,
|
||||
}: {
|
||||
name: string;
|
||||
invocation?: ToolInvocation<object, ToolResult>;
|
||||
resultDisplay?: ToolResultDisplay;
|
||||
displayName?: string;
|
||||
display?: ToolDisplay;
|
||||
}): ToolDisplay {
|
||||
const display: ToolDisplay = {
|
||||
name: displayName || name,
|
||||
description: invocation?.getDescription?.(),
|
||||
...prevDisplay,
|
||||
};
|
||||
|
||||
if (resultDisplay) {
|
||||
if (resultDisplay !== undefined && display.result === undefined) {
|
||||
display.result = toolResultDisplayToDisplayContent(resultDisplay);
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ export function renderDisplayDiff(diff: DisplayDiff): string {
|
||||
* Useful for fallback displays or non-interactive environments.
|
||||
*/
|
||||
export function displayContentToString(
|
||||
display: DisplayContent | undefined,
|
||||
display: DisplayContent | undefined | null,
|
||||
): string | undefined {
|
||||
if (!display) {
|
||||
return undefined;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import type { Kind } from '../tools/tools.js';
|
||||
|
||||
export type WithMeta = { _meta?: Record<string, unknown> };
|
||||
@@ -182,13 +183,48 @@ export type DisplayDiff = {
|
||||
beforeText: string;
|
||||
afterText: string;
|
||||
};
|
||||
export type DisplayContent = DisplayText | DisplayDiff;
|
||||
export type DisplayTerminal = {
|
||||
type: 'terminal';
|
||||
pid?: string;
|
||||
exitCode?: number;
|
||||
ansi?: AnsiOutput;
|
||||
};
|
||||
export type DisplayAgent = {
|
||||
type: 'agent';
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
export type DisplayContent =
|
||||
| DisplayText
|
||||
| DisplayDiff
|
||||
| DisplayTerminal
|
||||
| DisplayAgent;
|
||||
|
||||
export type ToolDisplayFormat =
|
||||
/**
|
||||
* Displays as compact when user has enabled compact tools, box otherwise.
|
||||
* This is the default format if none is selected.
|
||||
**/
|
||||
| 'auto'
|
||||
/** Always display this tool in compact format. */
|
||||
| 'compact'
|
||||
/** Always display this tool in full box format. */
|
||||
| 'box'
|
||||
/** Hide this tool from the event history. */
|
||||
| 'hidden'
|
||||
/** Display this tool as a message-like notice. */
|
||||
| 'notice';
|
||||
|
||||
export interface ToolDisplay {
|
||||
/** A display name for the tool. */
|
||||
name?: string;
|
||||
/** A short description of what the tool is doing. */
|
||||
description?: string;
|
||||
resultSummary?: string;
|
||||
result?: DisplayContent;
|
||||
/** A short, one-line summary of the tool's results. */
|
||||
resultSummary?: string | null;
|
||||
result?: DisplayContent | null;
|
||||
/** A tool may specify its preferred display format. */
|
||||
format?: ToolDisplayFormat;
|
||||
}
|
||||
|
||||
export interface ToolRequest {
|
||||
|
||||
@@ -3694,11 +3694,17 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getAgentSessionNoninteractiveEnabled(): boolean {
|
||||
return this.agentSessionNoninteractiveEnabled;
|
||||
return (
|
||||
process.env['GEMINI_CLI_EXP_AGENT'] === 'true' ||
|
||||
this.agentSessionNoninteractiveEnabled
|
||||
);
|
||||
}
|
||||
|
||||
getAgentSessionInteractiveEnabled(): boolean {
|
||||
return this.agentSessionInteractiveEnabled;
|
||||
return (
|
||||
process.env['GEMINI_CLI_EXP_AGENT'] === 'true' ||
|
||||
this.agentSessionInteractiveEnabled
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -284,6 +284,10 @@ export class GeminiChat {
|
||||
);
|
||||
}
|
||||
|
||||
get loopContext(): AgentLoopContext {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
async initialize(
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
kind: 'main' | 'subagent' = 'main',
|
||||
|
||||
@@ -49,6 +49,11 @@ describe('Turn', () => {
|
||||
getHistory: typeof mockGetHistory;
|
||||
maybeIncludeSchemaDepthContext: typeof mockMaybeIncludeSchemaDepthContext;
|
||||
context: { config: { isContextManagementEnabled: () => boolean } };
|
||||
loopContext?: {
|
||||
toolRegistry: {
|
||||
getTool: (name: string) => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
let mockChatInstance: MockedChatInstance;
|
||||
|
||||
@@ -63,6 +68,11 @@ describe('Turn', () => {
|
||||
isContextManagementEnabled: () => false,
|
||||
},
|
||||
},
|
||||
loopContext: {
|
||||
toolRegistry: {
|
||||
getTool: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
turn = new Turn(mockChatInstance as unknown as GeminiChat, 'prompt-id-1');
|
||||
mockGetHistory.mockReturnValue([]);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { getCitations } from '../utils/generateContentResponseUtilities.js';
|
||||
import { LlmRole } from '../telemetry/types.js';
|
||||
import { populateToolDisplay } from '../agent/tool-display-utils.js';
|
||||
|
||||
import {
|
||||
type ToolCallRequestInfo,
|
||||
@@ -408,17 +409,40 @@ export class Turn {
|
||||
traceId?: string,
|
||||
): ServerGeminiStreamEvent | null {
|
||||
const name = fnCall.name || 'undefined_tool_name';
|
||||
const args = fnCall.args || {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const args = (fnCall.args as Record<string, unknown>) || {};
|
||||
const callId =
|
||||
fnCall.id ??
|
||||
(this.chat.context.config.isContextManagementEnabled()
|
||||
? `synth_${this.prompt_id}_${Date.now()}_${this.callCounter++}`
|
||||
: `${name}_${Date.now()}_${this.callCounter++}`);
|
||||
|
||||
const tool = this.chat.loopContext.toolRegistry.getTool(name);
|
||||
let display;
|
||||
if (tool) {
|
||||
let invocation;
|
||||
try {
|
||||
invocation = tool.build(args);
|
||||
} catch {
|
||||
// Ignore build errors for request display purposes
|
||||
}
|
||||
display = populateToolDisplay({
|
||||
name,
|
||||
invocation,
|
||||
displayName: tool.displayName,
|
||||
});
|
||||
|
||||
// Fallback to static description if invocation failed or didn't provide one
|
||||
if (!display.description) {
|
||||
display.description = tool.description;
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallRequest: ToolCallRequestInfo = {
|
||||
callId,
|
||||
name,
|
||||
args,
|
||||
display,
|
||||
isClientInitiated: false,
|
||||
prompt_id: this.prompt_id,
|
||||
traceId,
|
||||
|
||||
@@ -407,7 +407,7 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request: req1,
|
||||
request: expect.objectContaining(req1),
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { getToolSuggestion } from '../utils/tool-utils.js';
|
||||
import { runInDevTraceSpan } from '../telemetry/trace.js';
|
||||
import { logToolCall } from '../telemetry/loggers.js';
|
||||
import { ToolCallEvent } from '../telemetry/types.js';
|
||||
import { populateToolDisplay } from '../agent/tool-display-utils.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
import {
|
||||
MessageBusType,
|
||||
@@ -381,6 +382,16 @@ export class Scheduler {
|
||||
() => {
|
||||
try {
|
||||
const invocation = tool.build(request.args);
|
||||
if (!request.display) {
|
||||
request.display = populateToolDisplay({
|
||||
name: tool.name,
|
||||
invocation,
|
||||
displayName: tool.displayName,
|
||||
});
|
||||
if (!request.display.description) {
|
||||
request.display.description = tool.description;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: CoreToolCallStatus.Validating,
|
||||
request,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
AnyToolInvocation,
|
||||
ToolDisplay,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
} from '../tools/tools.js';
|
||||
@@ -172,10 +173,15 @@ export class SchedulerStateManager {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call || call.status === CoreToolCallStatus.Error) return;
|
||||
|
||||
const display: ToolDisplay = call.request.display
|
||||
? { ...call.request.display }
|
||||
: { name: call.request.name };
|
||||
display.description = newInvocation.getDescription();
|
||||
|
||||
this.activeCalls.set(
|
||||
callId,
|
||||
this.patchCall(call, {
|
||||
request: { ...call.request, args: newArgs },
|
||||
request: { ...call.request, args: newArgs, display },
|
||||
invocation: newInvocation,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ToolCallRequestInfo,
|
||||
type ToolCallResponseInfo,
|
||||
type ToolResult,
|
||||
type ToolDisplay,
|
||||
type Config,
|
||||
type AgentLoopContext,
|
||||
type ToolLiveOutput,
|
||||
@@ -160,6 +161,7 @@ export class ToolExecutor {
|
||||
toolResult.error.type,
|
||||
displayText,
|
||||
toolResult.tailToolCallRequest,
|
||||
toolResult.display,
|
||||
);
|
||||
}
|
||||
} catch (executionError: unknown) {
|
||||
@@ -350,6 +352,7 @@ export class ToolExecutor {
|
||||
response: {
|
||||
callId: call.request.callId,
|
||||
responseParts,
|
||||
display: toolResult?.display,
|
||||
resultDisplay: toolResult?.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
@@ -386,6 +389,7 @@ export class ToolExecutor {
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
callId,
|
||||
responseParts: response,
|
||||
display: toolResult.display,
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
@@ -420,12 +424,14 @@ export class ToolExecutor {
|
||||
errorType?: ToolErrorType,
|
||||
returnDisplay?: string,
|
||||
tailToolCallRequest?: { name: string; args: Record<string, unknown> },
|
||||
display?: ToolDisplay,
|
||||
): ErroredToolCall {
|
||||
const response = this.createErrorResponse(
|
||||
call.request,
|
||||
error,
|
||||
errorType,
|
||||
returnDisplay,
|
||||
display,
|
||||
);
|
||||
const startTime = 'startTime' in call ? call.startTime : undefined;
|
||||
|
||||
@@ -447,11 +453,13 @@ export class ToolExecutor {
|
||||
error: Error,
|
||||
errorType: ToolErrorType | undefined,
|
||||
returnDisplay?: string,
|
||||
display?: ToolDisplay,
|
||||
): ToolCallResponseInfo {
|
||||
const displayText = returnDisplay ?? error.message;
|
||||
return {
|
||||
callId: request.callId,
|
||||
error,
|
||||
display,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
ToolLiveOutput,
|
||||
ToolDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolErrorType } from '../tools/tool-error.js';
|
||||
import type { SerializableConfirmationDetails } from '../confirmation-bus/types.js';
|
||||
@@ -36,6 +37,8 @@ export interface ToolCallRequestInfo {
|
||||
callId: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
/**
|
||||
* The original name and arguments of the tool requested by the model.
|
||||
* This is used for tail calls to ensure the final response and log retains
|
||||
@@ -56,6 +59,8 @@ export interface ToolCallRequestInfo {
|
||||
export interface ToolCallResponseInfo {
|
||||
callId: string;
|
||||
responseParts: Part[];
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
error: Error | undefined;
|
||||
errorType: ToolErrorType | undefined;
|
||||
|
||||
@@ -720,6 +720,17 @@ function doIt() {
|
||||
});
|
||||
|
||||
expect(result.llmContent).toMatch(/Successfully modified file/);
|
||||
expect(result.display).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Edit',
|
||||
resultSummary: expect.stringContaining('added'),
|
||||
result: expect.objectContaining({
|
||||
type: 'diff',
|
||||
beforeText: initialContent,
|
||||
afterText: newContent,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent);
|
||||
const display = result.returnDisplay as FileDiff;
|
||||
expect(display.fileDiff).toMatch(initialContent);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ToolResultDisplay,
|
||||
type PolicyUpdateOptions,
|
||||
type ExecuteOptions,
|
||||
type FileDiff,
|
||||
} from './tools.js';
|
||||
import { buildFilePathArgsPattern } from '../policy/utils.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -431,6 +432,12 @@ export function isEditToolParams(args: unknown): args is EditToolParams {
|
||||
);
|
||||
}
|
||||
|
||||
function fileDiffToSummary(diff: FileDiff, editData: CalculatedEdit) {
|
||||
return diff.diffStat
|
||||
? `${diff.diffStat.model_added_lines} added, ${diff.diffStat.model_removed_lines} removed`
|
||||
: `${editData.occurrences} replacements`;
|
||||
}
|
||||
|
||||
interface CalculatedEdit {
|
||||
currentContent: string | null;
|
||||
newContent: string;
|
||||
@@ -995,8 +1002,24 @@ ${snippet}`);
|
||||
llmContent = appendJitContext(llmContent, jitContext);
|
||||
}
|
||||
|
||||
const resultSummary =
|
||||
typeof displayResult === 'string'
|
||||
? displayResult
|
||||
: fileDiffToSummary(displayResult, editData);
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
display: {
|
||||
name: this._toolDisplayName,
|
||||
description: this.getDescription(),
|
||||
resultSummary,
|
||||
result: {
|
||||
type: 'diff',
|
||||
path: this.resolvedPath,
|
||||
beforeText: editData.currentContent ?? '',
|
||||
afterText: editData.newContent,
|
||||
},
|
||||
},
|
||||
returnDisplay: displayResult,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -284,12 +284,24 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
searchLocationDescription = `in path "${searchDirDisplay}"`;
|
||||
}
|
||||
|
||||
return await formatGrepResults(
|
||||
const result = await formatGrepResults(
|
||||
allMatches,
|
||||
this.params,
|
||||
searchLocationDescription,
|
||||
totalMaxMatches,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
display: {
|
||||
name: this._toolDisplayName,
|
||||
description: this.getDescription(),
|
||||
resultSummary: result.returnDisplay.summary,
|
||||
result: {
|
||||
type: 'text',
|
||||
text: result.llmContent.split('\n---\n').slice(1).join('\n---\n'),
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
debugLogger.warn(`Error during GrepLogic execution: ${error}`);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
@@ -284,6 +284,11 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
||||
|
||||
return {
|
||||
llmContent: resultMessage,
|
||||
display: {
|
||||
name: LS_DISPLAY_NAME,
|
||||
description: this.getDescription(),
|
||||
resultSummary: displayMessage,
|
||||
},
|
||||
returnDisplay: {
|
||||
summary: displayMessage,
|
||||
files: entries.map(
|
||||
|
||||
@@ -237,10 +237,18 @@ describe('ReadFileTool', () => {
|
||||
const params: ReadFileToolParams = { file_path: 'textfile.txt' };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
expect(await invocation.execute({ abortSignal })).toEqual({
|
||||
llmContent: fileContent,
|
||||
returnDisplay: '',
|
||||
});
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
llmContent: fileContent,
|
||||
returnDisplay: '',
|
||||
display: expect.objectContaining({
|
||||
name: 'ReadFile',
|
||||
description: expect.stringContaining('textfile.txt'),
|
||||
resultSummary: '1 lines',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if file does not exist', async () => {
|
||||
@@ -267,10 +275,18 @@ describe('ReadFileTool', () => {
|
||||
const params: ReadFileToolParams = { file_path: filePath };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
expect(await invocation.execute({ abortSignal })).toEqual({
|
||||
llmContent: fileContent,
|
||||
returnDisplay: '',
|
||||
});
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
llmContent: fileContent,
|
||||
returnDisplay: '',
|
||||
display: expect.objectContaining({
|
||||
name: 'ReadFile',
|
||||
description: expect.stringContaining('textfile.txt'),
|
||||
resultSummary: '1 lines',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if path is a directory', async () => {
|
||||
|
||||
@@ -186,8 +186,20 @@ ${result.llmContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
const displayResultSummary = result.isTruncated
|
||||
? `${result.linesShown![0]}-${result.linesShown![1]} of ${result.originalLineCount}`
|
||||
: lines !== undefined
|
||||
? `${lines} lines`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
display: {
|
||||
name: READ_FILE_DISPLAY_NAME,
|
||||
description: this.getDescription(),
|
||||
resultSummary: displayResultSummary,
|
||||
result: { type: 'text', text: result.returnDisplay || '' },
|
||||
},
|
||||
returnDisplay: result.returnDisplay || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,12 +301,24 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const searchLocationDescription = `in path "${searchDirDisplay}"`;
|
||||
|
||||
return await formatGrepResults(
|
||||
const result = await formatGrepResults(
|
||||
allMatches,
|
||||
this.params,
|
||||
searchLocationDescription,
|
||||
totalMaxMatches,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
display: {
|
||||
name: this._toolDisplayName,
|
||||
description: this.getDescription(),
|
||||
resultSummary: result.returnDisplay.summary,
|
||||
result: {
|
||||
type: 'text',
|
||||
text: result.llmContent.split('\n---\n').slice(1).join('\n---\n'),
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
debugLogger.warn(`Error during GrepLogic execution: ${error}`);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
@@ -504,6 +504,13 @@ EOF`;
|
||||
const result = await promise;
|
||||
expect(result.llmContent).toContain('Error: wrapped command failed');
|
||||
expect(result.llmContent).not.toContain('pgrep');
|
||||
expect(result.display).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Shell',
|
||||
description: 'user-command',
|
||||
resultSummary: 'Exit Code: 1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a SHELL_EXECUTE_ERROR for a command failure', async () => {
|
||||
|
||||
@@ -942,8 +942,24 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
const displayResultSummary = result.backgrounded
|
||||
? `PID: ${result.pid}`
|
||||
: result.exitCode !== null && result.exitCode !== 0
|
||||
? `Exit Code: ${result.exitCode}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
display: {
|
||||
name: 'Shell',
|
||||
description: this.getDescription(),
|
||||
resultSummary: displayResultSummary,
|
||||
result:
|
||||
typeof returnDisplay === 'string'
|
||||
? { type: 'text', text: returnDisplay }
|
||||
: // TODO: Add support for terminal display type (AnsiOutput)
|
||||
undefined,
|
||||
},
|
||||
returnDisplay,
|
||||
data,
|
||||
...executionError,
|
||||
|
||||
@@ -740,6 +740,10 @@ export function isTool(obj: unknown): obj is AnyDeclarativeTool {
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
/**
|
||||
* Tool-controlled display information.
|
||||
*/
|
||||
display?: ToolDisplay;
|
||||
/**
|
||||
* Content meant to be included in LLM history.
|
||||
* This should represent the factual outcome of the tool execution.
|
||||
@@ -1084,6 +1088,9 @@ export type ToolCallConfirmationDetails =
|
||||
| ToolAskUserConfirmationDetails
|
||||
| ToolExitPlanModeConfirmationDetails;
|
||||
|
||||
import type { ToolDisplay } from '../agent/types.js';
|
||||
export type { ToolDisplay };
|
||||
|
||||
export enum ToolConfirmationOutcome {
|
||||
ProceedOnce = 'proceed_once',
|
||||
ProceedAlways = 'proceed_always',
|
||||
|
||||
@@ -93,6 +93,11 @@ class UpdateTopicInvocation extends BaseToolInvocation<
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
display: {
|
||||
format: 'notice',
|
||||
name: title || UPDATE_TOPIC_DISPLAY_NAME,
|
||||
description: this.getDescription(),
|
||||
},
|
||||
returnDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,6 +678,16 @@ describe('WriteFileTool', () => {
|
||||
expect(result.llmContent).toMatch(
|
||||
/Successfully created and wrote to new file/,
|
||||
);
|
||||
expect(result.display).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'WriteFile',
|
||||
resultSummary: expect.stringContaining('added'),
|
||||
result: expect.objectContaining({
|
||||
type: 'diff',
|
||||
afterText: content,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
const writtenContent = await fsService.readTextFile(filePath);
|
||||
expect(writtenContent).toBe(content);
|
||||
|
||||
@@ -430,6 +430,19 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
display: {
|
||||
name: WRITE_FILE_DISPLAY_NAME,
|
||||
description: this.getDescription(),
|
||||
resultSummary: diffStat
|
||||
? `${diffStat.model_added_lines} added, ${diffStat.model_removed_lines} removed`
|
||||
: 'Written',
|
||||
result: {
|
||||
type: 'diff',
|
||||
path: this.resolvedPath,
|
||||
beforeText: correctedContentResult.originalContent ?? '',
|
||||
afterText: correctedContentResult.correctedContent,
|
||||
},
|
||||
},
|
||||
returnDisplay: displayResult,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user