feat(core, cli): Implement sequential approval. (#11593)

This commit is contained in:
joshualitt
2025-10-27 09:59:08 -07:00
committed by GitHub
parent 23c906b085
commit 541eeb7a50
9 changed files with 1272 additions and 339 deletions
@@ -37,7 +37,7 @@ import {
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { HistoryItem, SlashCommandProcessorResult } from '../types.js';
import type { SlashCommandProcessorResult } from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@@ -231,8 +231,9 @@ describe('useGeminiStream', () => {
mockUseReactToolScheduler.mockReturnValue([
[], // Default to empty array for toolCalls
mockScheduleToolCalls,
mockCancelAllToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(), // setToolCallsForDisplay
mockCancelAllToolCalls,
]);
// Reset mocks for GeminiClient instance methods (startChat and sendMessageStream)
@@ -259,38 +260,71 @@ describe('useGeminiStream', () => {
initialToolCalls: TrackedToolCall[] = [],
geminiClient?: any,
) => {
let currentToolCalls = initialToolCalls;
const setToolCalls = (newToolCalls: TrackedToolCall[]) => {
currentToolCalls = newToolCalls;
};
mockUseReactToolScheduler.mockImplementation(() => [
currentToolCalls,
mockScheduleToolCalls,
mockCancelAllToolCalls,
mockMarkToolsAsSubmitted,
]);
const client = geminiClient || mockConfig.getGeminiClient();
const initialProps = {
client,
history: [],
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
config: mockConfig,
onDebugMessage: mockOnDebugMessage,
handleSlashCommand: mockHandleSlashCommand as unknown as (
cmd: PartListUnion,
) => Promise<SlashCommandProcessorResult | false>,
shellModeActive: false,
loadedSettings: mockLoadedSettings,
toolCalls: initialToolCalls,
};
const { result, rerender } = renderHook(
(props: {
client: any;
history: HistoryItem[];
addItem: UseHistoryManagerReturn['addItem'];
config: Config;
onDebugMessage: (message: string) => void;
handleSlashCommand: (
cmd: PartListUnion,
) => Promise<SlashCommandProcessorResult | false>;
shellModeActive: boolean;
loadedSettings: LoadedSettings;
toolCalls?: TrackedToolCall[]; // Allow passing updated toolCalls
}) => {
// Update the mock's return value if new toolCalls are passed in props
if (props.toolCalls) {
setToolCalls(props.toolCalls);
}
(props: typeof initialProps) => {
// This mock needs to be stateful. When setToolCallsForDisplay is called,
// it should trigger a rerender with the new state.
const mockSetToolCallsForDisplay = vi.fn((updater) => {
const newToolCalls =
typeof updater === 'function' ? updater(props.toolCalls) : updater;
rerender({ ...props, toolCalls: newToolCalls });
});
// Create a stateful mock for cancellation that updates the toolCalls state.
const statefulCancelAllToolCalls = vi.fn((...args) => {
// Call the original spy so `toHaveBeenCalled` checks still work.
mockCancelAllToolCalls(...args);
const newToolCalls = props.toolCalls.map((tc) => {
// Only cancel tools that are in a cancellable state.
if (
tc.status === 'awaiting_approval' ||
tc.status === 'executing' ||
tc.status === 'scheduled' ||
tc.status === 'validating'
) {
// A real cancelled tool call has a response object.
// We need to simulate this to avoid type errors downstream.
return {
...tc,
status: 'cancelled',
response: {
callId: tc.request.callId,
responseParts: [],
resultDisplay: 'Request cancelled.',
},
responseSubmittedToGemini: true, // Mark as "processed"
} as any as TrackedCancelledToolCall;
}
return tc;
});
rerender({ ...props, toolCalls: newToolCalls });
});
mockUseReactToolScheduler.mockImplementation(() => [
props.toolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
mockSetToolCallsForDisplay,
statefulCancelAllToolCalls, // Use the stateful mock
]);
return useGeminiStream(
props.client,
props.history,
@@ -313,19 +347,7 @@ describe('useGeminiStream', () => {
);
},
{
initialProps: {
client,
history: [],
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
config: mockConfig,
onDebugMessage: mockOnDebugMessage,
handleSlashCommand: mockHandleSlashCommand as unknown as (
cmd: PartListUnion,
) => Promise<SlashCommandProcessorResult | false>,
shellModeActive: false,
loadedSettings: mockLoadedSettings,
toolCalls: initialToolCalls,
},
initialProps,
},
);
return {
@@ -452,7 +474,7 @@ describe('useGeminiStream', () => {
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
renderHook(() =>
@@ -535,7 +557,7 @@ describe('useGeminiStream', () => {
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
renderHook(() =>
@@ -647,7 +669,7 @@ describe('useGeminiStream', () => {
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
renderHook(() =>
@@ -760,6 +782,7 @@ describe('useGeminiStream', () => {
currentToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(), // setToolCallsForDisplay
];
});
@@ -797,6 +820,7 @@ describe('useGeminiStream', () => {
completedToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(), // setToolCallsForDisplay
];
});
@@ -1031,7 +1055,7 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should not cancel if a tool call is in progress (not just responding)', async () => {
it('should cancel if a tool call is in progress', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
@@ -1052,7 +1076,6 @@ describe('useGeminiStream', () => {
} as TrackedExecutingToolCall,
];
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const { result } = renderTestHook(toolCalls);
// State is `Responding` because a tool is running
@@ -1061,8 +1084,71 @@ describe('useGeminiStream', () => {
// Try to cancel
simulateEscapeKeyPress();
// Nothing should happen because the state is not `Responding`
expect(abortSpy).not.toHaveBeenCalled();
// The cancel function should be called
expect(mockCancelAllToolCalls).toHaveBeenCalled();
});
it('should cancel a request when a tool is awaiting confirmation', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const toolCalls: TrackedToolCall[] = [
{
request: {
callId: 'confirm-call',
name: 'some_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
tool: {
name: 'some_tool',
description: 'a tool',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirm,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(toolCalls);
// State is `WaitingForConfirmation` because a tool is awaiting approval
expect(result.current.streamingState).toBe(
StreamingState.WaitingForConfirmation,
);
// Try to cancel
simulateEscapeKeyPress();
// The imperative cancel function should be called on the scheduler
expect(mockCancelAllToolCalls).toHaveBeenCalled();
// A cancellation message should be added to history
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
text: 'Request cancelled.',
}),
expect.any(Number),
);
});
// The final state should be idle
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
});
@@ -1282,7 +1368,7 @@ describe('useGeminiStream', () => {
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
renderHook(() =>