From c266b529ae88097f14a33d43829e46ca9664a622 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:16:30 -0500 Subject: [PATCH] refactor(cli): decouple UI from live tool execution via ToolActionsContext (#17183) --- packages/cli/src/test-utils/render.tsx | 37 ++-- packages/cli/src/ui/AppContainer.tsx | 20 +- .../AlternateBufferQuittingDisplay.test.tsx | 1 + .../messages/RedirectionConfirmation.test.tsx | 1 + .../messages/ToolConfirmationMessage.test.tsx | 25 +++ .../messages/ToolConfirmationMessage.tsx | 67 +++---- .../messages/ToolGroupMessage.test.tsx | 102 +++++++++- .../components/messages/ToolGroupMessage.tsx | 1 + .../ui/contexts/ToolActionsContext.test.tsx | 180 ++++++++++++++++++ .../src/ui/contexts/ToolActionsContext.tsx | 142 ++++++++++++++ packages/cli/src/ui/hooks/toolMapping.test.ts | 27 +++ packages/cli/src/ui/hooks/toolMapping.ts | 22 +-- packages/cli/src/ui/types.ts | 13 +- packages/core/src/confirmation-bus/types.ts | 2 + 14 files changed, 561 insertions(+), 79 deletions(-) create mode 100644 packages/cli/src/ui/contexts/ToolActionsContext.test.tsx create mode 100644 packages/cli/src/ui/contexts/ToolActionsContext.tsx diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index bb4cd5ca8d..55444cf694 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -14,7 +14,6 @@ import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; -import { StreamingState } from '../ui/types.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; @@ -25,6 +24,8 @@ import { type UIActions, UIActionsContext, } from '../ui/contexts/UIActionsContext.js'; +import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; +import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; import { type Config } from '@google/gemini-cli-core'; @@ -239,6 +240,10 @@ export const renderWithProviders = ( const finalUIActions = { ...mockUIActions, ...uiActions }; + const allToolCalls = (finalUiState.pendingHistoryItems || []) + .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') + .flatMap((item) => item.tools); + const renderResult = render( @@ -247,20 +252,22 @@ export const renderWithProviders = ( - - - - - {component} - - - - + + + + + + {component} + + + + + diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 79c6fcd8af..c1f322ee1d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,9 +25,11 @@ import { type HistoryItem, ToolCallStatus, type HistoryItemWithoutId, + type HistoryItemToolGroup, AuthState, } from './types.js'; import { MessageType, StreamingState } from './types.js'; +import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { type EditorType, type Config, @@ -1486,6 +1488,16 @@ Logging in with Google... Restarting Gemini CLI to continue. [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); + const allToolCalls = useMemo( + () => + pendingHistoryItems + .filter( + (item): item is HistoryItemToolGroup => item.type === 'tool_group', + ) + .flatMap((item) => item.tools), + [pendingHistoryItems], + ); + const [geminiMdFileCount, setGeminiMdFileCount] = useState( config.getGeminiMdFileCount(), ); @@ -1832,9 +1844,11 @@ Logging in with Google... Restarting Gemini CLI to continue. startupWarnings: props.startupWarnings || [], }} > - - - + + + + + diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index c487357081..0863e50286 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -88,6 +88,7 @@ const mockConfig = { getModel: () => 'gemini-pro', getTargetDir: () => '/tmp', getDebugMode: () => false, + getIdeMode: () => false, getGeminiMdFileCount: () => 0, getExperiments: () => ({ flags: {}, diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx index d5b7f54f0e..7dc5341331 100644 --- a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx +++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx @@ -35,6 +35,7 @@ describe('ToolConfirmationMessage Redirection', () => { const { lastFrame } = renderWithProviders( { + const actual = + await importOriginal< + typeof import('../../contexts/ToolActionsContext.js') + >(); + return { + ...actual, + useToolActions: vi.fn(), + }; +}); describe('ToolConfirmationMessage', () => { + const mockConfirm = vi.fn(); + vi.mocked(useToolActions).mockReturnValue({ + confirm: mockConfirm, + cancel: vi.fn(), + }); + const mockConfig = { isTrustedFolder: () => true, getIdeMode: () => false, @@ -32,6 +50,7 @@ describe('ToolConfirmationMessage', () => { const { lastFrame } = renderWithProviders( { const { lastFrame } = renderWithProviders( { const { lastFrame } = renderWithProviders( { const { lastFrame } = renderWithProviders( { const { lastFrame } = renderWithProviders( { const { lastFrame } = renderWithProviders( { const { lastFrame } = renderWithProviders( = ({ + callId, confirmationDetails, config, isFocused = true, availableTerminalHeight, terminalWidth, }) => { - const { onConfirm } = confirmationDetails; + const { confirm } = useToolActions(); const settings = useSettings(); const allowPermanentApproval = settings.merged.security.enablePermanentToolApproval; - const [ideClient, setIdeClient] = useState(null); - const [isDiffingEnabled, setIsDiffingEnabled] = useState(false); - - useEffect(() => { - let isMounted = true; - if (config.getIdeMode()) { - const getIdeClient = async () => { - const client = await IdeClient.getInstance(); - if (isMounted) { - setIdeClient(client); - setIsDiffingEnabled(client?.isDiffingEnabled() ?? false); - } - }; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - getIdeClient(); - } - return () => { - isMounted = false; - }; - }, [config]); - - const handleConfirm = async (outcome: ToolConfirmationOutcome) => { - if (confirmationDetails.type === 'edit') { - if (config.getIdeMode() && isDiffingEnabled) { - const cliOutcome = - outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted'; - await ideClient?.resolveDiffFromCli( - confirmationDetails.filePath, - cliOutcome, - ); - } - } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - onConfirm(outcome); + const handleConfirm = (outcome: ToolConfirmationOutcome) => { + void confirm(callId, outcome).catch((error) => { + debugLogger.error( + `Failed to handle tool confirmation for ${callId}:`, + error, + ); + }); }; const isTrustedFolder = config.isTrustedFolder(); @@ -96,7 +73,6 @@ export const ToolConfirmationMessage: React.FC< (key) => { if (!isFocused) return; if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises handleConfirm(ToolConfirmationOutcome.Cancel); } }, @@ -132,7 +108,9 @@ export const ToolConfirmationMessage: React.FC< }); } } - if (!config.getIdeMode() || !isDiffingEnabled) { + // We hide "Modify with external editor" if IDE mode is active, assuming + // the IDE provides a better interface (diff view) for this. + if (!config.getIdeMode()) { options.push({ label: 'Modify with external editor', value: ToolConfirmationOutcome.ModifyWithEditor, @@ -400,7 +378,6 @@ export const ToolConfirmationMessage: React.FC< confirmationDetails, isTrustedFolder, config, - isDiffingEnabled, availableTerminalHeight, terminalWidth, allowPermanentApproval, diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index b36523715a..3f61959440 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -39,6 +39,11 @@ describe('', () => { const toolCalls = [createToolCall()]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -67,6 +72,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -89,6 +99,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -105,6 +120,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -133,6 +153,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -160,6 +185,11 @@ describe('', () => { toolCalls={toolCalls} availableTerminalHeight={10} />, + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -173,6 +203,11 @@ describe('', () => { toolCalls={toolCalls} isFocused={false} />, + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -192,6 +227,11 @@ describe('', () => { toolCalls={toolCalls} terminalWidth={40} />, + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -200,6 +240,11 @@ describe('', () => { it('renders empty tool calls array', () => { const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: [] }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -225,6 +270,11 @@ describe('', () => { , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -242,6 +292,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -270,6 +325,14 @@ describe('', () => { , + { + uiState: { + pendingHistoryItems: [ + { type: 'tool_group', tools: toolCalls1 }, + { type: 'tool_group', tools: toolCalls2 }, + ], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -281,6 +344,11 @@ describe('', () => { const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); // The snapshot will capture the visual appearance including border color expect(lastFrame()).toMatchSnapshot(); @@ -296,6 +364,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -312,6 +385,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -340,6 +418,11 @@ describe('', () => { toolCalls={toolCalls} availableTerminalHeight={20} />, + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -374,6 +457,11 @@ describe('', () => { ]; const { lastFrame, unmount } = renderWithProviders( , + { + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); // Should only show confirmation for the first tool expect(lastFrame()).toMatchSnapshot(); @@ -399,7 +487,12 @@ describe('', () => { }); const { lastFrame, unmount } = renderWithProviders( , - { settings }, + { + settings, + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).toContain('Allow for all future sessions'); expect(lastFrame()).toMatchSnapshot(); @@ -425,7 +518,12 @@ describe('', () => { }); const { lastFrame, unmount } = renderWithProviders( , - { settings }, + { + settings, + uiState: { + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + }, + }, ); expect(lastFrame()).not.toContain('Allow for all future sessions'); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index d41ff534d0..dda785b906 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -157,6 +157,7 @@ export const ToolGroupMessage: React.FC = ({ isConfirming && tool.confirmationDetails && ( { + const actual = + await importOriginal(); + return { + ...actual, + IdeClient: { + getInstance: vi.fn(), + }, + }; +}); + +describe('ToolActionsContext', () => { + const mockMessageBus = { + publish: vi.fn(), + }; + + const mockConfig = { + getIdeMode: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn().mockReturnValue(mockMessageBus), + } as unknown as Config; + + const mockToolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'modern-call', + correlationId: 'corr-123', + name: 'test-tool', + description: 'desc', + status: ToolCallStatus.Confirming, + resultDisplay: undefined, + confirmationDetails: { type: 'info', title: 'title', prompt: 'prompt' }, + }, + { + callId: 'legacy-call', + name: 'legacy-tool', + description: 'desc', + status: ToolCallStatus.Confirming, + resultDisplay: undefined, + confirmationDetails: { + type: 'info', + title: 'legacy', + prompt: 'prompt', + onConfirm: vi.fn(), + } as ToolCallConfirmationDetails, + }, + { + callId: 'edit-call', + name: 'edit-tool', + description: 'desc', + status: ToolCallStatus.Confirming, + resultDisplay: undefined, + confirmationDetails: { + type: 'edit', + title: 'edit', + fileName: 'f.txt', + filePath: '/f.txt', + fileDiff: 'diff', + originalContent: 'old', + newContent: 'new', + onConfirm: vi.fn(), + } as ToolCallConfirmationDetails, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('publishes to MessageBus for tools with correlationId (Modern Path)', async () => { + const { result } = renderHook(() => useToolActions(), { wrapper }); + + await result.current.confirm( + 'modern-call', + ToolConfirmationOutcome.ProceedOnce, + ); + + expect(mockMessageBus.publish).toHaveBeenCalledWith({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-123', + confirmed: true, + requiresUserConfirmation: false, + outcome: ToolConfirmationOutcome.ProceedOnce, + payload: undefined, + }); + }); + + it('calls onConfirm for legacy tools (Legacy Path)', async () => { + const { result } = renderHook(() => useToolActions(), { wrapper }); + const legacyDetails = mockToolCalls[1] + .confirmationDetails as ToolCallConfirmationDetails; + + await result.current.confirm( + 'legacy-call', + ToolConfirmationOutcome.ProceedOnce, + ); + + if (legacyDetails && 'onConfirm' in legacyDetails) { + expect(legacyDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + undefined, + ); + } else { + throw new Error('Expected onConfirm to be present'); + } + expect(mockMessageBus.publish).not.toHaveBeenCalled(); + }); + + it('handles cancel by calling confirm with Cancel outcome', async () => { + const { result } = renderHook(() => useToolActions(), { wrapper }); + + await result.current.cancel('modern-call'); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: ToolConfirmationOutcome.Cancel, + confirmed: false, + }), + ); + }); + + it('resolves IDE diffs for edit tools when in IDE mode', async () => { + const mockIdeClient = { + isDiffingEnabled: vi.fn().mockReturnValue(true), + resolveDiffFromCli: vi.fn(), + } as unknown as IdeClient; + vi.mocked(IdeClient.getInstance).mockResolvedValue(mockIdeClient); + vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + + const { result } = renderHook(() => useToolActions(), { wrapper }); + + // Wait for IdeClient initialization in useEffect + await act(async () => { + await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); + // Give React a chance to update state + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await result.current.confirm( + 'edit-call', + ToolConfirmationOutcome.ProceedOnce, + ); + + expect(mockIdeClient.resolveDiffFromCli).toHaveBeenCalledWith( + '/f.txt', + 'accepted', + ); + const editDetails = mockToolCalls[2] + .confirmationDetails as ToolCallConfirmationDetails; + if (editDetails && 'onConfirm' in editDetails) { + expect(editDetails.onConfirm).toHaveBeenCalled(); + } else { + throw new Error('Expected onConfirm to be present'); + } + }); +}); diff --git a/packages/cli/src/ui/contexts/ToolActionsContext.tsx b/packages/cli/src/ui/contexts/ToolActionsContext.tsx new file mode 100644 index 0000000000..46c2026c26 --- /dev/null +++ b/packages/cli/src/ui/contexts/ToolActionsContext.tsx @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + createContext, + useContext, + useCallback, + useState, + useEffect, +} from 'react'; +import { + IdeClient, + ToolConfirmationOutcome, + MessageBusType, + type Config, + type ToolConfirmationPayload, + type ToolCallConfirmationDetails, + debugLogger, +} from '@google/gemini-cli-core'; +import type { IndividualToolCallDisplay } from '../types.js'; + +interface ToolActionsContextValue { + confirm: ( + callId: string, + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; + cancel: (callId: string) => Promise; +} + +const ToolActionsContext = createContext(null); + +export const useToolActions = () => { + const context = useContext(ToolActionsContext); + if (!context) { + throw new Error('useToolActions must be used within a ToolActionsProvider'); + } + return context; +}; + +interface ToolActionsProviderProps { + children: React.ReactNode; + config: Config; + toolCalls: IndividualToolCallDisplay[]; +} + +export const ToolActionsProvider: React.FC = ( + props: ToolActionsProviderProps, +) => { + const { children, config, toolCalls } = props; + // Hoist IdeClient logic here to keep UI pure + const [ideClient, setIdeClient] = useState(null); + useEffect(() => { + let isMounted = true; + if (config.getIdeMode()) { + IdeClient.getInstance() + .then((client) => { + if (isMounted) setIdeClient(client); + }) + .catch((error) => { + debugLogger.error('Failed to get IdeClient instance:', error); + }); + } + return () => { + isMounted = false; + }; + }, [config]); + + const confirm = useCallback( + async ( + callId: string, + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + const tool = toolCalls.find((t) => t.callId === callId); + if (!tool) { + debugLogger.warn(`ToolActions: Tool ${callId} not found`); + return; + } + + const details = tool.confirmationDetails; + + // 1. Handle Side Effects (IDE Diff) + if ( + details?.type === 'edit' && + ideClient?.isDiffingEnabled() && + 'filePath' in details // Check for safety + ) { + const cliOutcome = + outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted'; + await ideClient.resolveDiffFromCli(details.filePath, cliOutcome); + } + + // 2. Dispatch + // PATH A: Event Bus (Modern) + if (tool.correlationId) { + await config.getMessageBus().publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: tool.correlationId, + confirmed: outcome !== ToolConfirmationOutcome.Cancel, + requiresUserConfirmation: false, + outcome, + payload, + }); + return; + } + + // PATH B: Legacy Callback (Adapter or Old Scheduler) + if ( + details && + 'onConfirm' in details && + typeof details.onConfirm === 'function' + ) { + await (details as ToolCallConfirmationDetails).onConfirm( + outcome, + payload, + ); + return; + } + + debugLogger.warn(`ToolActions: No confirmation mechanism for ${callId}`); + }, + [config, toolCalls, ideClient], + ); + + const cancel = useCallback( + async (callId: string) => { + await confirm(callId, ToolConfirmationOutcome.Cancel); + }, + [confirm], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts index 9ebabc2f65..16d518135f 100644 --- a/packages/cli/src/ui/hooks/toolMapping.test.ts +++ b/packages/cli/src/ui/hooks/toolMapping.test.ts @@ -195,6 +195,33 @@ describe('toolMapping', () => { expect(displayTool.confirmationDetails).toEqual(confirmationDetails); }); + it('maps correlationId and serializable confirmation details', () => { + const serializableDetails = { + type: 'edit' as const, + title: 'Confirm Edit', + fileName: 'file.txt', + filePath: '/path/file.txt', + fileDiff: 'diff', + originalContent: 'old', + newContent: 'new', + }; + + const toolCall: WaitingToolCall = { + status: 'awaiting_approval', + request: mockRequest, + tool: mockTool, + invocation: mockInvocation, + confirmationDetails: serializableDetails, + correlationId: 'corr-123', + }; + + const result = mapToDisplay(toolCall); + const displayTool = result.tools[0]; + + expect(displayTool.correlationId).toBe('corr-123'); + expect(displayTool.confirmationDetails).toEqual(serializableDetails); + }); + it('maps error tool call missing tool definition', () => { // e.g. "TOOL_NOT_REGISTERED" errors const toolCall: ToolCall = { diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index 7865ba1c5e..237044135f 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -8,6 +8,7 @@ import { type ToolCall, type Status as CoreStatus, type ToolCallConfirmationDetails, + type SerializableConfirmationDetails, type ToolResultDisplay, debugLogger, } from '@google/gemini-cli-core'; @@ -72,10 +73,13 @@ export function mapToDisplay( }; let resultDisplay: ToolResultDisplay | undefined = undefined; - let confirmationDetails: ToolCallConfirmationDetails | undefined = - undefined; + let confirmationDetails: + | ToolCallConfirmationDetails + | SerializableConfirmationDetails + | undefined = undefined; let outputFile: string | undefined = undefined; let ptyId: number | undefined = undefined; + let correlationId: string | undefined = undefined; switch (call.status) { case 'success': @@ -87,16 +91,9 @@ export function mapToDisplay( resultDisplay = call.response.resultDisplay; break; case 'awaiting_approval': - // Only map if it's the legacy callback-based details. - // Serializable details will be handled in a later milestone. - if ( - call.confirmationDetails && - 'onConfirm' in call.confirmationDetails && - typeof call.confirmationDetails.onConfirm === 'function' - ) { - confirmationDetails = - call.confirmationDetails as ToolCallConfirmationDetails; - } + correlationId = call.correlationId; + // Pass through details. Context handles dispatch (callback vs bus). + confirmationDetails = call.confirmationDetails; break; case 'executing': resultDisplay = call.liveOutput; @@ -123,6 +120,7 @@ export function mapToDisplay( confirmationDetails, outputFile, ptyId, + correlationId, }; }); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index f013d27fbf..9442b44c51 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -10,6 +10,7 @@ import type { MCPServerConfig, ThoughtSummary, ToolCallConfirmationDetails, + SerializableConfirmationDetails, ToolResultDisplay, RetrieveUserQuotaResponse, SkillDefinition, @@ -63,7 +64,11 @@ export interface ToolCallEvent { name: string; args: Record; resultDisplay: ToolResultDisplay | undefined; - confirmationDetails: ToolCallConfirmationDetails | undefined; + confirmationDetails: + | ToolCallConfirmationDetails + | SerializableConfirmationDetails + | undefined; + correlationId?: string; } export interface IndividualToolCallDisplay { @@ -72,10 +77,14 @@ export interface IndividualToolCallDisplay { description: string; resultDisplay: ToolResultDisplay | undefined; status: ToolCallStatus; - confirmationDetails: ToolCallConfirmationDetails | undefined; + confirmationDetails: + | ToolCallConfirmationDetails + | SerializableConfirmationDetails + | undefined; renderOutputAsMarkdown?: boolean; ptyId?: number; outputFile?: string; + correlationId?: string; } export interface CompressionProps { diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index d9caa9c5c2..0e7e7eae06 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -74,6 +74,7 @@ export type SerializableConfirmationDetails = fileDiff: string; originalContent: string | null; newContent: string; + isModifying?: boolean; } | { type: 'exec'; @@ -81,6 +82,7 @@ export type SerializableConfirmationDetails = command: string; rootCommand: string; rootCommands: string[]; + commands?: string[]; } | { type: 'mcp';