fix(cli): ensure agent stops when all declinable tools are cancelled (#24479)

This commit is contained in:
N. Taylor Mullen
2026-04-01 16:16:34 -07:00
committed by GitHub
parent cb7f7d6c72
commit ca78a0f177
2 changed files with 134 additions and 7 deletions

View File

@@ -52,6 +52,7 @@ import {
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
UPDATE_TOPIC_TOOL_NAME,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -904,6 +905,30 @@ describe('useGeminiStream', () => {
it('should handle all tool calls being cancelled', async () => {
const cancelledToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'topic1',
name: UPDATE_TOPIC_TOOL_NAME,
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
status: CoreToolCallStatus.Success,
response: {
callId: 'topic1',
responseParts: [
{
functionResponse: {
name: UPDATE_TOPIC_TOOL_NAME,
id: 'topic1',
response: {},
},
},
],
},
tool: { displayName: 'Update Topic Context' },
invocation: { getDescription: () => 'Updating topic' },
} as any,
{
request: {
callId: '1',
@@ -924,8 +949,8 @@ describe('useGeminiStream', () => {
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCancelledToolCall,
},
} as any,
];
const client = new MockedGeminiClientClass(mockConfig);
@@ -978,16 +1003,109 @@ describe('useGeminiStream', () => {
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['1']);
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['topic1', '1']);
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [{ text: CoreToolCallStatus.Cancelled }],
parts: [
{
functionResponse: {
name: UPDATE_TOPIC_TOOL_NAME,
id: 'topic1',
response: {},
},
},
{ text: CoreToolCallStatus.Cancelled },
],
});
// Ensure we do NOT call back to the API
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
});
it('should NOT stop responding when only update_topic is called', async () => {
const topicToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'topic1',
name: UPDATE_TOPIC_TOOL_NAME,
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
status: CoreToolCallStatus.Success,
response: {
callId: 'topic1',
responseParts: [
{
functionResponse: {
name: UPDATE_TOPIC_TOOL_NAME,
id: 'topic1',
response: {},
},
},
],
},
tool: { displayName: 'Update Topic Context' },
invocation: { getDescription: () => 'Updating topic' },
} as any,
];
const client = new MockedGeminiClientClass(mockConfig);
// Capture the onComplete callback
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
topicToolCalls,
vi.fn(),
mockMarkToolsAsSubmitted,
vi.fn(),
vi.fn(),
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with the topic tool
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete(topicToolCalls);
}
});
await waitFor(() => {
// The streaming state should still be Responding because we didn't cancel anything important
// and we expect a continuation.
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['topic1']);
// Should HAVE called back to the API for continuation
expect(mockSendMessageStream).toHaveBeenCalled();
});
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
{

View File

@@ -1968,11 +1968,20 @@ export const useGeminiStream = (
}
// If all the tools were cancelled, don't submit a response to Gemini.
const allToolsCancelled = geminiTools.every(
(tc) => tc.status === CoreToolCallStatus.Cancelled,
// Note: we ignore the topic tool because the user doesn't have a chance to decline it.
const declinableTools = geminiTools.filter(
(tc) => !isTopicTool(tc.request.name),
);
const allDeclinableToolsCancelled =
declinableTools.length > 0 &&
declinableTools.every(
(tc) => tc.status === CoreToolCallStatus.Cancelled,
);
const allToolsCancelled =
geminiTools.length > 0 &&
geminiTools.every((tc) => tc.status === CoreToolCallStatus.Cancelled);
if (allToolsCancelled) {
if (allDeclinableToolsCancelled || allToolsCancelled) {
// If the turn was cancelled via the imperative escape key flow,
// the cancellation message is added there. We check the ref to avoid duplication.
if (!turnCancelledRef.current) {