mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
fix(cli): ensure agent stops when all declinable tools are cancelled (#24479)
This commit is contained in:
@@ -52,6 +52,7 @@ import {
|
|||||||
MCPDiscoveryState,
|
MCPDiscoveryState,
|
||||||
GeminiCliOperation,
|
GeminiCliOperation,
|
||||||
getPlanModeExitMessage,
|
getPlanModeExitMessage,
|
||||||
|
UPDATE_TOPIC_TOOL_NAME,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part, PartListUnion } from '@google/genai';
|
import type { Part, PartListUnion } from '@google/genai';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
@@ -904,6 +905,30 @@ describe('useGeminiStream', () => {
|
|||||||
|
|
||||||
it('should handle all tool calls being cancelled', async () => {
|
it('should handle all tool calls being cancelled', async () => {
|
||||||
const cancelledToolCalls: TrackedToolCall[] = [
|
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: {
|
request: {
|
||||||
callId: '1',
|
callId: '1',
|
||||||
@@ -924,8 +949,8 @@ describe('useGeminiStream', () => {
|
|||||||
},
|
},
|
||||||
invocation: {
|
invocation: {
|
||||||
getDescription: () => `Mock description`,
|
getDescription: () => `Mock description`,
|
||||||
} as unknown as AnyToolInvocation,
|
},
|
||||||
} as TrackedCancelledToolCall,
|
} as any,
|
||||||
];
|
];
|
||||||
const client = new MockedGeminiClientClass(mockConfig);
|
const client = new MockedGeminiClientClass(mockConfig);
|
||||||
|
|
||||||
@@ -978,16 +1003,109 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['1']);
|
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['topic1', '1']);
|
||||||
expect(client.addHistory).toHaveBeenCalledWith({
|
expect(client.addHistory).toHaveBeenCalledWith({
|
||||||
role: 'user',
|
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
|
// Ensure we do NOT call back to the API
|
||||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
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 () => {
|
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
|
||||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1968,11 +1968,20 @@ export const useGeminiStream = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If all the tools were cancelled, don't submit a response to Gemini.
|
// If all the tools were cancelled, don't submit a response to Gemini.
|
||||||
const allToolsCancelled = geminiTools.every(
|
// 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,
|
(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,
|
// If the turn was cancelled via the imperative escape key flow,
|
||||||
// the cancellation message is added there. We check the ref to avoid duplication.
|
// the cancellation message is added there. We check the ref to avoid duplication.
|
||||||
if (!turnCancelledRef.current) {
|
if (!turnCancelledRef.current) {
|
||||||
|
|||||||
Reference in New Issue
Block a user