refactor(cli): decouple UI from live tool execution via ToolActionsContext (#17183)

This commit is contained in:
Abhi
2026-01-21 16:16:30 -05:00
committed by GitHub
parent dce450b1e8
commit c266b529ae
14 changed files with 561 additions and 79 deletions
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
import {
type Config,
ToolConfirmationOutcome,
MessageBusType,
IdeClient,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { ToolCallStatus, type IndividualToolCallDisplay } from '../types.js';
// Mock IdeClient
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
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 }) => (
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
{children}
</ToolActionsProvider>
);
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');
}
});
});
@@ -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<void>;
cancel: (callId: string) => Promise<void>;
}
const ToolActionsContext = createContext<ToolActionsContextValue | null>(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<ToolActionsProviderProps> = (
props: ToolActionsProviderProps,
) => {
const { children, config, toolCalls } = props;
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(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 (
<ToolActionsContext.Provider value={{ confirm, cancel }}>
{children}
</ToolActionsContext.Provider>
);
};