Download packages/cli/src/ui/components/messages/ToolMessage.test.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
-
curl -L -o ToolMessage.test.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
16.3 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import type React from 'react'; | |
| import { ToolMessage, type ToolMessageProps } from './ToolMessage.js'; | |
| import { describe, it, expect, vi } from 'vitest'; | |
| import { StreamingState } from '../../types.js'; | |
| import { Text } from 'ink'; | |
| import { | |
| type AnsiOutput, | |
| CoreToolCallStatus, | |
| Kind, | |
| makeFakeConfig, | |
| } from '@google/gemini-cli-core'; | |
| import { renderWithProviders } from '../../../test-utils/render.js'; | |
| import { createMockSettings } from '../../../test-utils/settings.js'; | |
| import { tryParseJSON } from '../../../utils/jsonoutput.js'; | |
| vi.mock('../GeminiRespondingSpinner.js', () => ({ | |
| GeminiRespondingSpinner: () => <Text>MockRespondingSpinner</Text>, | |
| })); | |
| vi.mock('../TerminalOutput.js', () => ({ | |
| TerminalOutput: function MockTerminalOutput({ | |
| cursor, | |
| }: { | |
| cursor: { x: number; y: number } | null; | |
| }) { | |
| return ( | |
| <Text> | |
| MockCursor:({cursor?.x},{cursor?.y}) | |
| </Text> | |
| ); | |
| }, | |
| })); | |
| describe('<ToolMessage />', () => { | |
| const baseProps: ToolMessageProps = { | |
| callId: 'tool-123', | |
| name: 'test-tool', | |
| description: 'A tool for testing', | |
| resultDisplay: 'Test result', | |
| status: CoreToolCallStatus.Success, | |
| terminalWidth: 80, | |
| confirmationDetails: undefined, | |
| emphasis: 'medium', | |
| isFirst: true, | |
| borderColor: 'green', | |
| borderDimColor: false, | |
| }; | |
| const mockSetEmbeddedShellFocused = vi.fn(); | |
| const uiActions = { | |
| setEmbeddedShellFocused: mockSetEmbeddedShellFocused, | |
| }; | |
| // Helper to render with context | |
| const renderWithContext = async ( | |
| ui: React.ReactElement, | |
| streamingState: StreamingState, | |
| ) => | |
| renderWithProviders(ui, { | |
| uiActions, | |
| uiState: { streamingState }, | |
| width: 80, | |
| }); | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| vi.useFakeTimers(); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| }); | |
| it('renders basic tool information', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} />, | |
| StreamingState.Idle, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| describe('JSON rendering', () => { | |
| it('pretty prints valid JSON', async () => { | |
| const testJSONstring = '{"a": 1, "b": [2, 3]}'; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| resultDisplay={testJSONstring} | |
| renderOutputAsMarkdown={false} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| const output = lastFrame(); | |
| // Verify the JSON utility correctly parses the input | |
| expect(tryParseJSON(testJSONstring)).toBeTruthy(); | |
| // Verify pretty-printed JSON appears in output (with proper indentation) | |
| expect(output).toContain('"a": 1'); | |
| expect(output).toContain('"b": ['); | |
| // Should not use markdown renderer for JSON | |
| unmount(); | |
| }); | |
| it('renders pretty JSON in ink frame', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} resultDisplay='{"a":1,"b":2}' />, | |
| StreamingState.Idle, | |
| ); | |
| const frame = lastFrame(); | |
| expect(frame).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('uses JSON renderer even when renderOutputAsMarkdown=true is true', async () => { | |
| const testJSONstring = '{"a": 1, "b": [2, 3]}'; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| resultDisplay={testJSONstring} | |
| renderOutputAsMarkdown={true} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| const output = lastFrame(); | |
| // Verify the JSON utility correctly parses the input | |
| expect(tryParseJSON(testJSONstring)).toBeTruthy(); | |
| // Verify pretty-printed JSON appears in output | |
| expect(output).toContain('"a": 1'); | |
| expect(output).toContain('"b": ['); | |
| // Should not use markdown renderer for JSON even when renderOutputAsMarkdown=true | |
| unmount(); | |
| }); | |
| it('falls back to plain text for malformed JSON', async () => { | |
| const testJSONstring = 'a": 1, "b": [2, 3]}'; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| resultDisplay={testJSONstring} | |
| renderOutputAsMarkdown={false} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| const output = lastFrame(); | |
| expect(tryParseJSON(testJSONstring)).toBeFalsy(); | |
| expect(typeof output === 'string').toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('rejects mixed text + JSON renders as plain text', async () => { | |
| const testJSONstring = `{"result": "count": 42,"items": ["apple", "banana"]},"meta": {"timestamp": "2025-09-28T12:34:56Z"}}End.`; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| resultDisplay={testJSONstring} | |
| renderOutputAsMarkdown={false} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| const output = lastFrame(); | |
| expect(tryParseJSON(testJSONstring)).toBeFalsy(); | |
| expect(typeof output === 'string').toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('rejects ANSI-tained JSON renders as plain text', async () => { | |
| const testJSONstring = | |
| '\u001b[32mOK\u001b[0m {"status": "success", "data": {"id": 123, "values": [10, 20, 30]}}'; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| resultDisplay={testJSONstring} | |
| renderOutputAsMarkdown={false} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| const output = lastFrame(); | |
| expect(tryParseJSON(testJSONstring)).toBeFalsy(); | |
| expect(typeof output === 'string').toBeTruthy(); | |
| unmount(); | |
| }); | |
| it('pretty printing 10kb JSON completes in <50ms', async () => { | |
| const large = '{"key": "' + 'x'.repeat(10000) + '"}'; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| resultDisplay={large} | |
| renderOutputAsMarkdown={false} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| const start = performance.now(); | |
| lastFrame(); | |
| expect(performance.now() - start).toBeLessThan(50); | |
| unmount(); | |
| }); | |
| }); | |
| describe('ToolStatusIndicator rendering', () => { | |
| it('shows ✓ for Success status', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Success} />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows o for Pending status', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Scheduled} />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows ? for Confirming status', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| status={CoreToolCallStatus.AwaitingApproval} | |
| />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows - for Canceled status', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Cancelled} />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows x for Error status', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Error} />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows paused spinner for Executing status when streamingState is Idle', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Executing} />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Executing} />, | |
| StreamingState.WaitingForConfirmation, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('shows MockRespondingSpinner for Executing status when streamingState is Responding', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} status={CoreToolCallStatus.Executing} />, | |
| StreamingState.Responding, // Simulate app still responding | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| }); | |
| it('renders DiffRenderer for diff results', async () => { | |
| const diffResult = { | |
| fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new', | |
| fileName: 'file.txt', | |
| originalContent: 'old', | |
| newContent: 'new', | |
| filePath: 'file.txt', | |
| }; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} resultDisplay={diffResult} />, | |
| StreamingState.Idle, | |
| ); | |
| // Check that the output contains the MockDiff content as part of the whole message | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('renders emphasis correctly', async () => { | |
| const { | |
| lastFrame: highEmphasisFrame, | |
| waitUntilReady: waitUntilReadyHigh, | |
| unmount: unmountHigh, | |
| } = await renderWithContext( | |
| <ToolMessage {...baseProps} emphasis="high" />, | |
| StreamingState.Idle, | |
| ); | |
| await waitUntilReadyHigh(); | |
| // Check for trailing indicator or specific color if applicable (Colors are not easily testable here) | |
| expect(highEmphasisFrame()).toMatchSnapshot(); | |
| unmountHigh(); | |
| const { | |
| lastFrame: lowEmphasisFrame, | |
| waitUntilReady: waitUntilReadyLow, | |
| unmount: unmountLow, | |
| } = await renderWithContext( | |
| <ToolMessage {...baseProps} emphasis="low" />, | |
| StreamingState.Idle, | |
| ); | |
| await waitUntilReadyLow(); | |
| // For low emphasis, the name and description might be dimmed (check for dimColor if possible) | |
| // This is harder to assert directly in text output without color checks. | |
| // We can at least ensure it doesn't have the high emphasis indicator. | |
| expect(lowEmphasisFrame()).toMatchSnapshot(); | |
| unmountLow(); | |
| }); | |
| it('renders AnsiOutputText for AnsiOutput results', async () => { | |
| const ansiResult: AnsiOutput = [ | |
| [ | |
| { | |
| text: 'hello', | |
| fg: '#ffffff', | |
| bg: '#000000', | |
| bold: false, | |
| italic: false, | |
| underline: false, | |
| dim: false, | |
| inverse: false, | |
| isUninitialized: false, | |
| }, | |
| ], | |
| ]; | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage {...baseProps} resultDisplay={ansiResult} />, | |
| StreamingState.Idle, | |
| ); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('renders McpProgressIndicator with percentage and message for executing tools', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| status={CoreToolCallStatus.Executing} | |
| progress={42} | |
| progressTotal={100} | |
| progressMessage="Working on it..." | |
| />, | |
| StreamingState.Responding, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toContain('42%'); | |
| expect(output).toContain('Working on it...'); | |
| expect(output).toContain('\u2588'); | |
| expect(output).toContain('\u2591'); | |
| expect(output).not.toContain('A tool for testing (Working on it... - 42%)'); | |
| expect(output).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('renders only percentage when progressMessage is missing', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| status={CoreToolCallStatus.Executing} | |
| progress={75} | |
| progressTotal={100} | |
| />, | |
| StreamingState.Responding, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toContain('75%'); | |
| expect(output).toContain('\u2588'); | |
| expect(output).toContain('\u2591'); | |
| expect(output).not.toContain('A tool for testing (75%)'); | |
| expect(output).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| it('renders indeterminate progress when total is missing', async () => { | |
| const { lastFrame, unmount } = await renderWithContext( | |
| <ToolMessage | |
| {...baseProps} | |
| status={CoreToolCallStatus.Executing} | |
| progress={7} | |
| />, | |
| StreamingState.Responding, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toContain('7'); | |
| expect(output).toContain('\u2588'); | |
| expect(output).toContain('\u2591'); | |
| expect(output).not.toContain('%'); | |
| expect(output).toMatchSnapshot(); | |
| unmount(); | |
| }); | |
| describe('Truncation', () => { | |
| it('applies truncation for Kind.Agent when availableTerminalHeight is provided', async () => { | |
| const multilineString = Array.from( | |
| { length: 30 }, | |
| (_, i) => `Line ${i + 1}`, | |
| ).join('\n'); | |
| const { lastFrame, unmount } = await renderWithProviders( | |
| <ToolMessage | |
| {...baseProps} | |
| kind={Kind.Agent} | |
| resultDisplay={multilineString} | |
| renderOutputAsMarkdown={false} | |
| availableTerminalHeight={40} | |
| />, | |
| { | |
| uiActions, | |
| uiState: { | |
| streamingState: StreamingState.Idle, | |
| constrainHeight: true, | |
| }, | |
| width: 80, | |
| config: makeFakeConfig({ useAlternateBuffer: true }), | |
| settings: createMockSettings({ ui: { useAlternateBuffer: true } }), | |
| }, | |
| ); | |
| const output = lastFrame(); | |
| // Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15) | |
| // It should constrain the height, showing the tail of the output (overflowDirection='top' or due to scroll) | |
| expect(output).not.toMatch(/Line 1\b/); | |
| expect(output).not.toMatch(/Line 14\b/); | |
| expect(output).toMatch(/Line 16\b/); | |
| expect(output).toMatch(/Line 30\b/); | |
| unmount(); | |
| }); | |
| it('does NOT apply truncation for Kind.Agent when availableTerminalHeight is undefined', async () => { | |
| const multilineString = Array.from( | |
| { length: 30 }, | |
| (_, i) => `Line ${i + 1}`, | |
| ).join('\n'); | |
| const { lastFrame, unmount } = await renderWithProviders( | |
| <ToolMessage | |
| {...baseProps} | |
| kind={Kind.Agent} | |
| resultDisplay={multilineString} | |
| renderOutputAsMarkdown={false} | |
| availableTerminalHeight={undefined} | |
| />, | |
| { | |
| uiActions, | |
| uiState: { streamingState: StreamingState.Idle }, | |
| width: 80, | |
| config: makeFakeConfig({ useAlternateBuffer: false }), | |
| settings: createMockSettings({ ui: { useAlternateBuffer: false } }), | |
| }, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toContain('Line 1'); | |
| expect(output).toContain('Line 30'); | |
| unmount(); | |
| }); | |
| it('does NOT apply truncation for Kind.Read', async () => { | |
| const multilineString = Array.from( | |
| { length: 30 }, | |
| (_, i) => `Line ${i + 1}`, | |
| ).join('\n'); | |
| const { lastFrame, unmount } = await renderWithProviders( | |
| <ToolMessage | |
| {...baseProps} | |
| kind={Kind.Read} | |
| resultDisplay={multilineString} | |
| renderOutputAsMarkdown={false} | |
| />, | |
| { | |
| uiActions, | |
| uiState: { streamingState: StreamingState.Idle }, | |
| width: 80, | |
| config: makeFakeConfig({ useAlternateBuffer: false }), | |
| settings: createMockSettings({ ui: { useAlternateBuffer: false } }), | |
| }, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toContain('Line 1'); | |
| expect(output).toContain('Line 30'); | |
| unmount(); | |
| }); | |
| }); | |
| }); | |