fix(cli,core): resolve build issues with structured tool result display

- Added `isStructuredToolResult` type guard to safely check for `summary` properties on tool results in `ToolResultDisplay`.
- Resolved `data.slice is not a function` errors in UI tests caused by structured tool results falling back to `AnsiOutput` parsing.
- Updated `DiffRenderer` assertions to include `disableColor: false`.
- Updated `ReadManyFilesTool` tests to assert against the new `ReadManyFilesResult` object structure instead of flat strings.
- Removed unsafe type assertions (`any`) in `ToolGroupMessage.compact.test.tsx` and `ToolGroupMessage.tsx` where possible.
This commit is contained in:
Jarrod Whelan
2026-03-09 23:48:59 -07:00
parent a6c26dd91a
commit 418fd55cde
10 changed files with 149 additions and 76 deletions

View File

@@ -118,7 +118,9 @@ describe('DenseToolMessage', () => {
name="Edit"
status={CoreToolCallStatus.AwaitingApproval}
resultDisplay={undefined}
confirmationDetails={confirmationDetails as SerializableConfirmationDetails}
confirmationDetails={
confirmationDetails as SerializableConfirmationDetails
}
/>,
{ useAlternateBuffer: false },
);
@@ -194,7 +196,9 @@ describe('DenseToolMessage', () => {
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={undefined}
confirmationDetails={confirmationDetails as unknown as SerializableConfirmationDetails}
confirmationDetails={
confirmationDetails as unknown as SerializableConfirmationDetails
}
/>,
{ useAlternateBuffer: false },
);
@@ -391,7 +395,7 @@ describe('DenseToolMessage', () => {
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Error}
resultDisplay={"Error occurred" as ToolResultDisplay}
resultDisplay={'Error occurred' as ToolResultDisplay}
/>,
);
await waitUntilReady();
@@ -497,4 +501,3 @@ describe('DenseToolMessage', () => {
});
});
});

View File

@@ -52,6 +52,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -84,6 +85,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -112,6 +114,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});

View File

@@ -1,3 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { ToolGroupMessage } from './ToolGroupMessage.js';
@@ -7,73 +12,92 @@ import {
READ_FILE_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import { expect, it, describe } from 'vitest';
import type { IndividualToolCallDisplay } from '../../types.js';
import type { LoadedSettings } from '../../../config/settings.js';
describe('ToolGroupMessage Compact Rendering', () => {
const defaultProps = {
item: {
id: '1',
role: 'assistant',
content: '',
item: {
id: '1',
role: 'assistant',
content: '',
timestamp: new Date(),
type: 'help' as const, // Adding type property to satisfy HistoryItem type
},
terminalWidth: 80,
};
const compactSettings = {
const compactSettings: LoadedSettings = {
merged: {
ui: {
compactToolOutput: true,
},
},
};
sources: [],
} as unknown as LoadedSettings; // Test mock of settings
it('renders consecutive compact tools without empty lines between them', async () => {
const toolCalls = [
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt\nfile2.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file3.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line between a compact tool and a standard tool', async () => {
const toolCalls = [
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
description: 'Doing something',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
@@ -82,24 +106,32 @@ describe('ToolGroupMessage Compact Rendering', () => {
});
it('does not add an extra empty line if a compact tool has a dense payload', async () => {
const toolCalls = [
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: READ_FILE_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: { summary: 'read file', payload: 'file content' }, // Dense payload
description: 'Reading file',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
@@ -108,24 +140,32 @@ describe('ToolGroupMessage Compact Rendering', () => {
});
it('does not add an extra empty line between a standard tool and a compact tool', async () => {
const toolCalls = [
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
description: 'Doing something',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();

View File

@@ -17,6 +17,7 @@ import {
mapCoreStatusToDisplayStatus,
isFileDiff,
isGrepResult,
isListResult,
} from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
@@ -78,14 +79,7 @@ export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
if (isGrepResult(res) && (res.matches?.length ?? 0) > 0) return true;
// ReadManyFilesResult check (has 'include' and 'files')
if (
typeof res === 'object' &&
res !== null &&
'include' in res &&
'files' in res &&
Array.isArray((res as any).files) &&
(res as any).files.length > 0
) {
if (isListResult(res) && 'include' in res && (res.files?.length ?? 0) > 0) {
return true;
}
@@ -212,14 +206,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
height += 1; // Base height for compact tool
// Spacing logic (matching marginTop)
if (isFirst) {
height += borderTopOverride ?? true ? 1 : 0;
height += (borderTopOverride ?? true) ? 1 : 0;
} else if (!prevIsCompact) {
height += 1;
}
} else {
height += 3; // Static overhead for standard tool
if (isFirst) {
height += borderTopOverride ?? true ? 1 : 0;
height += (borderTopOverride ?? true) ? 1 : 0;
} else {
height += 1; // marginTop is always 1 for non-compact tools (not first)
}
@@ -270,7 +264,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={borderBottomOverride ?? true ? 1 : 0}
marginBottom={(borderBottomOverride ?? true) ? 1 : 0}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
@@ -290,7 +284,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
let marginTop = 0;
if (isFirst) {
marginTop = borderTopOverride ?? true ? 1 : 0;
marginTop = (borderTopOverride ?? true) ? 1 : 0;
} else if (!(isCompact && prevIsCompact)) {
marginTop = 1;
}

View File

@@ -15,6 +15,7 @@ import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
isStructuredToolResult,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -118,7 +119,28 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
} else if (isStructuredToolResult(contentData)) {
if (renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
text={contentData.summary}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{contentData.summary}
</Text>
);
}
} else if (
typeof contentData === 'object' &&
contentData !== null &&
'fileDiff' in contentData
) {
content = (
<DiffRenderer
diffContent={

View File

@@ -2,10 +2,10 @@
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a compact tool and a standard tool 1`] = `
"
✓ ReadFolder → file1.txt
✓ ReadFolder Listing files → file1.txt
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool
│ ✓ non-compact-tool Doing something
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
@@ -15,25 +15,25 @@ exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line b
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a standard tool and a compact tool 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool
│ ✓ non-compact-tool Doing something
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
✓ ReadFolder → file1.txt
✓ ReadFolder Listing files → file1.txt
"
`;
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line if a compact tool has a dense payload 1`] = `
"
✓ ReadFolder → file1.txt
✓ ReadFile → read file
✓ ReadFolder Listing files → file1.txt
✓ ReadFile Reading file → read file
"
`;
exports[`ToolGroupMessage Compact Rendering > renders consecutive compact tools without empty lines between them 1`] = `
"
✓ ReadFolder → file1.txt file2.txt
✓ ReadFolder → file3.txt
✓ ReadFolder Listing files → file1.txt file2.txt
✓ ReadFolder Listing files → file3.txt
"
`;

View File

@@ -36,6 +36,7 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all t
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
│ │
│ ✓ another-tool A tool for testing │
│ │
@@ -57,10 +58,10 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shel
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
"│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
│ │ ▄
│ │
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
@@ -76,6 +77,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls incl
│ ✓ read_file Read a file │
│ │
│ Test result │
│ │
│ ⊶ run_shell_command Run command │
│ │
@@ -94,6 +96,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls w
│ ✓ successful-tool This tool succeeded │
│ │
│ Test result │
│ │
│ o pending-tool This tool is pending │
│ │
@@ -143,6 +146,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
│ ✓ tool-with-result Tool with output │
│ │
│ This is a long result that might need height constraints │
│ │
│ ✓ another-tool Another tool │
│ │
@@ -167,10 +171,12 @@ exports[`<ToolGroupMessage /> > Height Calculation > calculates available height
│ ✓ test-tool A tool for testing │
│ │
│ Result 1 │
│ │
│ ✓ test-tool A tool for testing │
│ │
│ Result 2 │
│ │
│ ✓ test-tool A tool for testing │
│ │

View File

@@ -40,7 +40,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
"│ │
│ ✓ tool-2 Description for tool-2 │
│────────────────────────────────────────────────────────────────────────│
│ c2-09 │ ▄
│ c2-10 │ ▀
│ c2-08 │ ▄
│ c2-09 │ ▀
"
`;

View File

@@ -75,9 +75,7 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import {
isCompactTool,
} from '../components/messages/ToolGroupMessage.js';
import { isCompactTool } from '../components/messages/ToolGroupMessage.js';
import {
useToolScheduler,
type TrackedToolCall,
@@ -305,13 +303,11 @@ export const useGeminiStream = (
// If the first tool in this push is non-compact but follows a compact tool,
// we must start a new border group.
const currentIsCompact = isCompactTool(
mapTrackedToolCallsToDisplay(firstToolToPush as TrackedToolCall)
.tools[0],
mapTrackedToolCallsToDisplay(firstToolToPush).tools[0],
isCompactModeEnabled,
);
const prevWasCompact = isCompactTool(
mapTrackedToolCallsToDisplay(prevTool as TrackedToolCall)
.tools[0],
mapTrackedToolCallsToDisplay(prevTool).tools[0],
isCompactModeEnabled,
);
if (!currentIsCompact && prevWasCompact) {
@@ -356,9 +352,7 @@ export const useGeminiStream = (
}
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
completedToolCallsFromScheduler as TrackedToolCall[],
);
await handleCompletedTools(completedToolCallsFromScheduler);
}
},
config,