feat(hooks): implement STOP_EXECUTION and enhance hook decision handling (#15685)

This commit is contained in:
Sandy Tao
2025-12-31 07:22:53 +08:00
committed by GitHub
parent 3ebe4e6a8f
commit 05049b5abf
10 changed files with 379 additions and 22 deletions

View File

@@ -1745,4 +1745,56 @@ describe('runNonInteractive', () => {
);
expect(getWrittenOutput()).toContain('Done');
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'stop-call',
name: 'stopTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-stop',
},
};
// Mock tool execution returning STOP_EXECUTION
mockCoreExecuteToolCall.mockResolvedValue({
status: 'error',
request: toolCallEvent.value,
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
callId: 'stop-call',
responseParts: [{ text: 'error occurred' }],
errorType: ToolErrorType.STOP_EXECUTION,
error: new Error('Stop reason from hook'),
resultDisplay: undefined,
},
});
const firstCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Executing tool...' },
toolCallEvent,
];
// Setup the mock to return events for the first call.
// We expect the loop to terminate after the tool execution.
// If it doesn't, it might call sendMessageStream again, which we'll assert against.
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents([]));
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Run stop tool',
prompt_id: 'prompt-id-stop',
});
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
// The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
});

View File

@@ -28,6 +28,7 @@ import {
CoreEvent,
createWorkingStdio,
recordToolCallInteractions,
ToolErrorType,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
@@ -416,6 +417,43 @@ export async function runNonInteractive({
);
}
// Check if any tool requested to stop execution immediately
const stopExecutionTool = completedToolCalls.find(
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
);
if (stopExecutionTool && stopExecutionTool.response.error) {
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`${stopMessage}\n`);
}
// Emit final result event for streaming JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(
metrics,
durationMs,
),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// Emit final result event for streaming JSON

View File

@@ -694,6 +694,99 @@ describe('useGeminiStream', () => {
});
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
name: 'stopTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-stop',
},
status: 'error',
response: {
callId: 'stop-call',
responseParts: [{ text: 'error occurred' }],
errorType: ToolErrorType.STOP_EXECUTION,
error: new Error('Stop reason from hook'),
resultDisplay: undefined,
},
responseSubmittedToGemini: false,
tool: {
displayName: 'stop tool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as unknown as TrackedCompletedToolCall,
];
const client = new MockedGeminiClientClass(mockConfig);
// Capture the onComplete callback
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
];
});
const { result } = renderHook(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with STOP_EXECUTION tool
await act(async () => {
if (capturedOnComplete) {
await (capturedOnComplete as any)(stopExecutionToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['stop-call']);
// Should add an info message to history
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining(
'Agent execution stopped: Stop reason from hook',
),
}),
expect.any(Number),
);
// Ensure we do NOT call back to the API
expect(mockSendMessageStream).not.toHaveBeenCalled();
// Streaming state should be Idle
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
});
it('should group multiple cancelled tool call responses into a single history entry', async () => {
const cancelledToolCall1: TrackedCancelledToolCall = {
request: {

View File

@@ -39,6 +39,7 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
} from '@google/gemini-cli-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
@@ -1153,6 +1154,28 @@ export const useGeminiStream = (
return;
}
// Check if any tool requested to stop execution immediately
const stopExecutionTool = geminiTools.find(
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
);
if (stopExecutionTool && stopExecutionTool.response.error) {
addItem(
{
type: MessageType.INFO,
text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`,
},
Date.now(),
);
setIsResponding(false);
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
return;
}
// If all the tools were cancelled, don't submit a response to Gemini.
const allToolsCancelled = geminiTools.every(
(tc) => tc.status === 'cancelled',