| |
| |
| |
| |
| |
|
|
| import type { |
| Config, |
| ToolRegistry, |
| ServerGeminiStreamEvent, |
| SessionMetrics, |
| AnyDeclarativeTool, |
| AnyToolInvocation, |
| UserFeedbackPayload, |
| } from '@google/gemini-cli-core'; |
| import { |
| ToolErrorType, |
| GeminiEventType, |
| OutputFormat, |
| uiTelemetryService, |
| FatalInputError, |
| CoreEvent, |
| CoreToolCallStatus, |
| JsonStreamEventType, |
| TRUE_EMPTY_RESPONSE_MESSAGE, |
| } from '@google/gemini-cli-core'; |
| import type { Part } from '@google/genai'; |
| import { runNonInteractive } from './nonInteractiveCliAgentSession.js'; |
| import { |
| describe, |
| it, |
| expect, |
| beforeEach, |
| afterEach, |
| vi, |
| type Mock, |
| type MockInstance, |
| } from 'vitest'; |
| import type { LoadedSettings } from './config/settings.js'; |
|
|
| |
| vi.mock('./ui/hooks/atCommandProcessor.js'); |
|
|
| const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn()); |
| vi.mock('./utils/devtoolsService.js', () => ({ |
| setupInitialActivityLogger: mockSetupInitialActivityLogger, |
| })); |
|
|
| const mockCoreEvents = vi.hoisted(() => ({ |
| on: vi.fn(), |
| off: vi.fn(), |
| emit: vi.fn(), |
| emitConsoleLog: vi.fn(), |
| emitFeedback: vi.fn(), |
| drainBacklogs: vi.fn(), |
| })); |
|
|
| const mockSchedulerSchedule = vi.hoisted(() => vi.fn()); |
|
|
| vi.mock('@google/gemini-cli-core', async (importOriginal) => { |
| const original = |
| await importOriginal<typeof import('@google/gemini-cli-core')>(); |
|
|
| class MockChatRecordingService { |
| initialize = vi.fn(); |
| recordMessage = vi.fn(); |
| recordMessageTokens = vi.fn(); |
| recordToolCalls = vi.fn(); |
| } |
|
|
| return { |
| ...original, |
| Scheduler: class { |
| schedule = mockSchedulerSchedule; |
| cancelAll = vi.fn(); |
| dispose = vi.fn(); |
| }, |
| isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), |
| ChatRecordingService: MockChatRecordingService, |
| uiTelemetryService: { |
| getMetrics: vi.fn(), |
| recordSemanticValidationError: vi.fn(), |
| }, |
| LegacyAgentSession: original.LegacyAgentSession, |
| geminiPartsToContentParts: original.geminiPartsToContentParts, |
| coreEvents: mockCoreEvents, |
| createWorkingStdio: vi.fn(() => ({ |
| stdout: process.stdout, |
| stderr: process.stderr, |
| })), |
| }; |
| }); |
|
|
| const mockGetCommands = vi.hoisted(() => vi.fn()); |
| const mockCommandServiceCreate = vi.hoisted(() => vi.fn()); |
| vi.mock('./services/CommandService.js', () => ({ |
| CommandService: { |
| create: mockCommandServiceCreate, |
| }, |
| })); |
|
|
| vi.mock('./services/FileCommandLoader.js'); |
| vi.mock('./services/McpPromptLoader.js'); |
| vi.mock('./services/BuiltinCommandLoader.js'); |
|
|
| describe('runNonInteractive', () => { |
| let mockConfig: Config; |
| let mockSettings: LoadedSettings; |
| let mockToolRegistry: ToolRegistry; |
| let consoleErrorSpy: MockInstance; |
| let processStdoutSpy: MockInstance; |
| let processStderrSpy: MockInstance; |
| let mockGeminiClient: { |
| sendMessageStream: Mock; |
| resumeChat: Mock; |
| getChatRecordingService: Mock; |
| getChat: Mock; |
| getCurrentSequenceModel: Mock; |
| }; |
| const MOCK_SESSION_METRICS: SessionMetrics = { |
| models: {}, |
| tools: { |
| totalCalls: 0, |
| totalSuccess: 0, |
| totalFail: 0, |
| totalDurationMs: 0, |
| totalDecisions: { |
| accept: 0, |
| reject: 0, |
| modify: 0, |
| auto_accept: 0, |
| }, |
| byName: {}, |
| }, |
| files: { |
| totalLinesAdded: 0, |
| totalLinesRemoved: 0, |
| }, |
| }; |
|
|
| beforeEach(async () => { |
| mockSchedulerSchedule.mockReset(); |
|
|
| mockCommandServiceCreate.mockResolvedValue({ |
| getCommands: mockGetCommands, |
| }); |
|
|
| consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); |
| processStdoutSpy = vi |
| .spyOn(process.stdout, 'write') |
| .mockImplementation(() => true); |
| vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout); |
| processStderrSpy = vi |
| .spyOn(process.stderr, 'write') |
| .mockImplementation(() => true); |
| vi.spyOn(process, 'exit').mockImplementation((code) => { |
| throw new Error(`process.exit(${code}) called`); |
| }); |
|
|
| mockToolRegistry = { |
| getTool: vi.fn(), |
| getFunctionDeclarations: vi.fn().mockReturnValue([]), |
| } as unknown as ToolRegistry; |
|
|
| mockGeminiClient = { |
| sendMessageStream: vi.fn(), |
| resumeChat: vi.fn().mockResolvedValue(undefined), |
| getChatRecordingService: vi.fn(() => ({ |
| initialize: vi.fn(), |
| recordMessage: vi.fn(), |
| recordMessageTokens: vi.fn(), |
| recordToolCalls: vi.fn(), |
| })), |
| getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })), |
| getCurrentSequenceModel: vi.fn().mockReturnValue(null), |
| }; |
|
|
| mockConfig = { |
| initialize: vi.fn().mockReturnValue(Promise.resolve(undefined)), |
| getMessageBus: vi.fn().mockReturnValue({ |
| subscribe: vi.fn(), |
| unsubscribe: vi.fn(), |
| publish: vi.fn(), |
| }), |
| getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), |
| getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), |
| getMaxSessionTurns: vi.fn().mockReturnValue(10), |
| getSessionId: vi.fn().mockReturnValue('test-session-id'), |
| getProjectRoot: vi.fn().mockReturnValue('/test/project'), |
| storage: { |
| getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'), |
| }, |
| getIdeMode: vi.fn().mockReturnValue(false), |
|
|
| getContentGeneratorConfig: vi.fn().mockReturnValue({}), |
| getDebugMode: vi.fn().mockReturnValue(false), |
| getOutputFormat: vi.fn().mockReturnValue('text'), |
| getModel: vi.fn().mockReturnValue('test-model'), |
| getFolderTrust: vi.fn().mockReturnValue(false), |
| isTrustedFolder: vi.fn().mockReturnValue(false), |
| getRawOutput: vi.fn().mockReturnValue(false), |
| getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), |
| getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false), |
| getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), |
| } as unknown as Config; |
|
|
| mockSettings = { |
| system: { path: '', settings: {} }, |
| systemDefaults: { path: '', settings: {} }, |
| user: { path: '', settings: {} }, |
| workspace: { path: '', settings: {} }, |
| errors: [], |
| setValue: vi.fn(), |
| merged: { |
| security: { |
| auth: { |
| enforcedType: undefined, |
| }, |
| }, |
| }, |
| isTrusted: true, |
| migratedInMemoryScopes: new Set(), |
| forScope: vi.fn(), |
| computeMergedSettings: vi.fn(), |
| } as unknown as LoadedSettings; |
|
|
| const { handleAtCommand } = await import( |
| './ui/hooks/atCommandProcessor.js' |
| ); |
| vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({ |
| processedQuery: [{ text: query }], |
| })); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| async function* createStreamFromEvents( |
| events: ServerGeminiStreamEvent[], |
| ): AsyncGenerator<ServerGeminiStreamEvent> { |
| for (const event of events) { |
| yield event; |
| } |
| } |
|
|
| const getWrittenOutput = () => |
| processStdoutSpy.mock.calls.map((c) => c[0]).join(''); |
|
|
| it('should process input and write text output', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Hello' }, |
| { type: GeminiEventType.Content, value: ' World' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-1', |
| }); |
|
|
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| [{ text: 'Test input' }], |
| expect.any(AbortSignal), |
| 'prompt-id-1', |
| undefined, |
| 'Test input', |
| ); |
| expect(getWrittenOutput()).toBe('Hello World\n'); |
| |
| |
| }); |
|
|
| it('should stream the specific stream started by send', async () => { |
| const { LegacyAgentSession } = await import('@google/gemini-cli-core'); |
| const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream'); |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Hello again' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-stream', |
| }); |
|
|
| expect(streamSpy).toHaveBeenCalledWith({ streamId: expect.any(String) }); |
| }); |
|
|
| it('fails fast if the session acknowledges a message send without a stream', async () => { |
| const { LegacyAgentSession } = await import('@google/gemini-cli-core'); |
| const sendSpy = vi |
| .spyOn(LegacyAgentSession.prototype, 'send') |
| .mockResolvedValue({ streamId: null }); |
| const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream'); |
|
|
| await expect( |
| runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-null-stream', |
| }), |
| ).rejects.toThrow( |
| 'LegacyAgentSession.send() unexpectedly returned no stream for a message send.', |
| ); |
|
|
| expect(streamSpy).not.toHaveBeenCalled(); |
|
|
| sendSpy.mockRestore(); |
| streamSpy.mockRestore(); |
| }); |
|
|
| it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => { |
| vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl'); |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'prompt-id-activity-logger', |
| }); |
|
|
| expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig); |
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => { |
| vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', ''); |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'prompt-id-activity-logger-off', |
| }); |
|
|
| expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled(); |
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should handle a single tool call and respond', async () => { |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-2', |
| }, |
| }; |
| const toolResponse: Part[] = [{ text: 'Tool response' }]; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-2', |
| }, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: toolResponse, |
| callId: 'tool-1', |
| error: undefined, |
| errorType: undefined, |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
|
|
| const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; |
| const secondCallEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) |
| .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Use a tool', |
| prompt_id: 'prompt-id-2', |
| }); |
|
|
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); |
| expect(mockSchedulerSchedule).toHaveBeenCalledWith( |
| [expect.objectContaining({ name: 'testTool' })], |
| expect.any(AbortSignal), |
| ); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( |
| 2, |
| [{ text: 'Tool response' }], |
| expect.any(AbortSignal), |
| 'prompt-id-2', |
| undefined, |
| undefined, |
| ); |
| expect(getWrittenOutput()).toBe('Final answer\n'); |
| }); |
|
|
| it('should write a single newline between sequential text outputs from the model', async () => { |
| |
| |
|
|
| |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'mock-tool', |
| name: 'mockTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-multi', |
| }, |
| }; |
|
|
| |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: toolCallEvent.value, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: [], |
| callId: 'mock-tool', |
| }, |
| }, |
| ]); |
|
|
| |
| |
| const modelTurn1: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Use mock tool' }, |
| toolCallEvent, |
| ]; |
| |
| const modelTurn2: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Use mock tool again' }, |
| toolCallEvent, |
| ]; |
| |
| const modelTurn3: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Finished.' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(modelTurn1)) |
| .mockReturnValueOnce(createStreamFromEvents(modelTurn2)) |
| .mockReturnValueOnce(createStreamFromEvents(modelTurn3)); |
|
|
| |
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Use mock tool multiple times', |
| prompt_id: 'prompt-id-multi', |
| }); |
|
|
| |
| |
| |
| expect(getWrittenOutput()).toMatchSnapshot(); |
|
|
| |
| expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it('should handle error during tool execution and should send error back to the model', async () => { |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'errorTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-3', |
| }, |
| }; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Error, |
| request: { |
| callId: 'tool-1', |
| name: 'errorTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-3', |
| }, |
| tool: {} as AnyDeclarativeTool, |
| response: { |
| callId: 'tool-1', |
| error: new Error('Execution failed'), |
| errorType: ToolErrorType.EXECUTION_FAILED, |
| responseParts: [ |
| { |
| functionResponse: { |
| name: 'errorTool', |
| response: { |
| output: 'Error: Execution failed', |
| }, |
| }, |
| }, |
| ], |
| resultDisplay: 'Execution failed', |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
| const finalResponse: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Content, |
| value: 'Sorry, let me try again.', |
| }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) |
| .mockReturnValueOnce(createStreamFromEvents(finalResponse)); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Trigger tool error', |
| prompt_id: 'prompt-id-3', |
| }); |
|
|
| expect(mockSchedulerSchedule).toHaveBeenCalled(); |
| expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 'Error executing tool errorTool: Execution failed', |
| ); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( |
| 2, |
| [ |
| { |
| functionResponse: { |
| name: 'errorTool', |
| response: { |
| output: 'Error: Execution failed', |
| }, |
| }, |
| }, |
| ], |
| expect.any(AbortSignal), |
| 'prompt-id-3', |
| undefined, |
| undefined, |
| ); |
| expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); |
| }); |
|
|
| it('should exit with error if sendMessageStream throws initially', async () => { |
| const apiError = new Error('API connection failed'); |
| mockGeminiClient.sendMessageStream.mockImplementation(() => { |
| throw apiError; |
| }); |
|
|
| await expect( |
| runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Initial fail', |
| prompt_id: 'prompt-id-4', |
| }), |
| ).rejects.toThrow('API connection failed'); |
| }); |
|
|
| it('should not exit if a tool is not found, and should send error back to model', async () => { |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'nonexistentTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-5', |
| }, |
| }; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Error, |
| request: { |
| callId: 'tool-1', |
| name: 'nonexistentTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-5', |
| }, |
| response: { |
| callId: 'tool-1', |
| error: new Error('Tool "nonexistentTool" not found in registry.'), |
| resultDisplay: 'Tool "nonexistentTool" not found in registry.', |
| responseParts: [], |
| errorType: undefined, |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
| const finalResponse: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Content, |
| value: "Sorry, I can't find that tool.", |
| }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) |
| .mockReturnValueOnce(createStreamFromEvents(finalResponse)); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Trigger tool not found', |
| prompt_id: 'prompt-id-5', |
| }); |
|
|
| expect(mockSchedulerSchedule).toHaveBeenCalled(); |
| expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', |
| ); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); |
| expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n"); |
| }); |
|
|
| it('should exit when max session turns are exceeded', async () => { |
| vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); |
| await expect( |
| runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Trigger loop', |
| prompt_id: 'prompt-id-6', |
| }), |
| ).rejects.toThrow('Reached max session turns for this session'); |
| }); |
|
|
| it('should preprocess @include commands before sending to the model', async () => { |
| |
| const { handleAtCommand } = await import( |
| './ui/hooks/atCommandProcessor.js' |
| ); |
| const mockHandleAtCommand = vi.mocked(handleAtCommand); |
|
|
| |
| const rawInput = 'Summarize @file.txt'; |
| const processedParts: Part[] = [ |
| { text: 'Summarize @file.txt' }, |
| { text: '\n--- Content from referenced files ---\n' }, |
| { text: 'This is the content of the file.' }, |
| { text: '\n--- End of content ---' }, |
| ]; |
|
|
| |
| mockHandleAtCommand.mockResolvedValue({ |
| processedQuery: processedParts, |
| }); |
|
|
| |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Summary complete.' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| |
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: rawInput, |
| prompt_id: 'prompt-id-7', |
| }); |
|
|
| |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| processedParts, |
| expect.any(AbortSignal), |
| 'prompt-id-7', |
| undefined, |
| rawInput, |
| ); |
|
|
| |
| expect(getWrittenOutput()).toBe('Summary complete.\n'); |
| }); |
|
|
| it('should process input and write JSON output with stats', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Hello World' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-1', |
| }); |
|
|
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| [{ text: 'Test input' }], |
| expect.any(AbortSignal), |
| 'prompt-id-1', |
| undefined, |
| 'Test input', |
| ); |
| expect(processStdoutSpy).toHaveBeenCalledWith( |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| response: 'Hello World', |
| stats: MOCK_SESSION_METRICS, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should write JSON output with stats for tool-only commands (no text response)', async () => { |
| |
| |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-tool-only', |
| }, |
| }; |
| const toolResponse: Part[] = [{ text: 'Tool executed successfully' }]; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-tool-only', |
| }, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: toolResponse, |
| callId: 'tool-1', |
| error: undefined, |
| errorType: undefined, |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
|
|
| |
| const firstCallEvents: ServerGeminiStreamEvent[] = [ |
| toolCallEvent, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
|
|
| |
| const secondCallEvents: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, |
| }, |
| ]; |
|
|
| |
| const thirdCallEvents: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) |
| .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)) |
| .mockReturnValueOnce(createStreamFromEvents(thirdCallEvents)); |
|
|
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Execute tool only', |
| prompt_id: 'prompt-id-tool-only', |
| }); |
|
|
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); |
| expect(mockSchedulerSchedule).toHaveBeenCalledWith( |
| [expect.objectContaining({ name: 'testTool' })], |
| expect.any(AbortSignal), |
| ); |
|
|
| |
| expect(processStdoutSpy).toHaveBeenCalledWith( |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| response: '', |
| stats: MOCK_SESSION_METRICS, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should keep only the final post-tool assistant text in JSON output', async () => { |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-json-tool-text', |
| }, |
| }; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: toolCallEvent.value, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: [{ text: 'Tool executed successfully' }], |
| callId: 'tool-1', |
| error: undefined, |
| errorType: undefined, |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce( |
| createStreamFromEvents([ |
| { type: GeminiEventType.Content, value: 'Let me check that...' }, |
| toolCallEvent, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]), |
| ) |
| .mockReturnValueOnce( |
| createStreamFromEvents([ |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, |
| }, |
| ]), |
| ); |
|
|
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Use a tool', |
| prompt_id: 'prompt-id-json-tool-text', |
| }); |
|
|
| expect(processStdoutSpy).toHaveBeenCalledWith( |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| response: 'Final answer', |
| stats: MOCK_SESSION_METRICS, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should write JSON output with stats for empty response commands', async () => { |
| |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Empty response test', |
| prompt_id: 'prompt-id-empty', |
| }); |
|
|
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| [{ text: 'Empty response test' }], |
| expect.any(AbortSignal), |
| 'prompt-id-empty', |
| undefined, |
| 'Empty response test', |
| ); |
|
|
| |
| expect(processStdoutSpy).toHaveBeenCalledWith( |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| response: '', |
| stats: MOCK_SESSION_METRICS, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should handle errors in JSON format', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| const testError = new Error('Invalid input provided'); |
|
|
| mockGeminiClient.sendMessageStream.mockImplementation(() => { |
| throw testError; |
| }); |
|
|
| let thrownError: Error | null = null; |
| try { |
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-error', |
| }); |
| |
| expect.fail('Expected process.exit to be called'); |
| } catch (error) { |
| thrownError = error as Error; |
| } |
|
|
| |
| expect(thrownError?.message).toBe('process.exit(1) called'); |
|
|
| expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( |
| 'error', |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| error: { |
| type: 'Error', |
| message: 'Invalid input provided', |
| code: 1, |
| }, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should handle FatalInputError with custom exit code in JSON format', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| const fatalError = new FatalInputError('Invalid command syntax provided'); |
|
|
| mockGeminiClient.sendMessageStream.mockImplementation(() => { |
| throw fatalError; |
| }); |
|
|
| let thrownError: Error | null = null; |
| try { |
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Invalid syntax', |
| prompt_id: 'prompt-id-fatal', |
| }); |
| |
| expect.fail('Expected process.exit to be called'); |
| } catch (error) { |
| thrownError = error as Error; |
| } |
|
|
| |
| expect(thrownError?.message).toBe('process.exit(42) called'); |
|
|
| expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( |
| 'error', |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| error: { |
| type: 'FatalInputError', |
| message: 'Invalid command syntax provided', |
| code: 42, |
| }, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should execute a slash command that returns a prompt', async () => { |
| const mockCommand = { |
| name: 'testcommand', |
| description: 'a test command', |
| action: vi.fn().mockResolvedValue({ |
| type: 'submit_prompt', |
| content: [{ text: 'Prompt from command' }], |
| }), |
| }; |
| mockGetCommands.mockReturnValue([mockCommand]); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Response from command' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/testcommand', |
| prompt_id: 'prompt-id-slash', |
| }); |
|
|
| |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| [{ text: 'Prompt from command' }], |
| expect.any(AbortSignal), |
| 'prompt-id-slash', |
| undefined, |
| '/testcommand', |
| ); |
|
|
| expect(getWrittenOutput()).toBe('Response from command\n'); |
| }); |
|
|
| it('should handle slash commands', async () => { |
| const nonInteractiveCliCommands = await import( |
| './nonInteractiveCliCommands.js' |
| ); |
| const handleSlashCommandSpy = vi.spyOn( |
| nonInteractiveCliCommands, |
| 'handleSlashCommand', |
| ); |
| handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Response to slash command' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/help', |
| prompt_id: 'prompt-id-slash', |
| }); |
|
|
| expect(handleSlashCommandSpy).toHaveBeenCalledWith( |
| '/help', |
| expect.any(AbortController), |
| mockConfig, |
| mockSettings, |
| ); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| [{ text: 'Slash command output' }], |
| expect.any(AbortSignal), |
| 'prompt-id-slash', |
| undefined, |
| '/help', |
| ); |
| expect(getWrittenOutput()).toBe('Response to slash command\n'); |
| handleSlashCommandSpy.mockRestore(); |
| }); |
|
|
| it('should handle cancellation (Ctrl+C)', async () => { |
| |
| const originalIsTTY = process.stdin.isTTY; |
| |
| const originalSetRawMode = (process.stdin as any).setRawMode; |
|
|
| Object.defineProperty(process.stdin, 'isTTY', { |
| value: true, |
| configurable: true, |
| }); |
| if (!originalSetRawMode) { |
| |
| (process.stdin as any).setRawMode = vi.fn(); |
| } |
|
|
| const stdinOnSpy = vi |
| .spyOn(process.stdin, 'on') |
| .mockImplementation(() => process.stdin); |
| |
| vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true); |
| vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); |
| vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin); |
| vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation( |
| () => process.stdin, |
| ); |
|
|
| |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Thinking...' }, |
| ]; |
| |
| mockGeminiClient.sendMessageStream.mockImplementation( |
| (_messages, signal: AbortSignal) => |
| (async function* () { |
| yield events[0]; |
| await new Promise((resolve, reject) => { |
| const timeout = setTimeout(resolve, 1000); |
| signal.addEventListener('abort', () => { |
| clearTimeout(timeout); |
| setTimeout(() => { |
| reject(new Error('Aborted')); |
| }, 300); |
| }); |
| }); |
| })(), |
| ); |
|
|
| const runPromise = runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Long running query', |
| prompt_id: 'prompt-id-cancel', |
| }); |
|
|
| |
| await new Promise((resolve) => setTimeout(resolve, 100)); |
|
|
| |
| const keypressCall = stdinOnSpy.mock.calls.find( |
| |
| (call) => (call[0] as any) === 'keypress', |
| ); |
| expect(keypressCall).toBeDefined(); |
| const keypressHandler = keypressCall?.[1] as ( |
| str: string, |
| key: { name?: string; ctrl?: boolean }, |
| ) => void; |
|
|
| if (keypressHandler) { |
| |
| keypressHandler('\u0003', { ctrl: true, name: 'c' }); |
| } |
|
|
| await expect(runPromise).rejects.toThrow('Operation cancelled.'); |
|
|
| expect( |
| processStderrSpy.mock.calls.some( |
| |
| (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'), |
| ), |
| ).toBe(true); |
|
|
| |
| Object.defineProperty(process.stdin, 'isTTY', { |
| value: originalIsTTY, |
| configurable: true, |
| }); |
| if (originalSetRawMode) { |
| |
| (process.stdin as any).setRawMode = originalSetRawMode; |
| } else { |
| |
| delete (process.stdin as any).setRawMode; |
| } |
| |
| |
| }); |
|
|
| it('should honor cancellation that happens before session.send()', async () => { |
| const originalIsTTY = process.stdin.isTTY; |
| |
| const originalSetRawMode = (process.stdin as any).setRawMode; |
|
|
| Object.defineProperty(process.stdin, 'isTTY', { |
| value: true, |
| configurable: true, |
| }); |
| if (!originalSetRawMode) { |
| |
| (process.stdin as any).setRawMode = vi.fn(); |
| } |
|
|
| const stdinOnSpy = vi |
| .spyOn(process.stdin, 'on') |
| .mockImplementation( |
| (event: string | symbol, listener: (...args: unknown[]) => void) => { |
| if (event === 'keypress') { |
| listener('\u0003', { ctrl: true, name: 'c' }); |
| } |
| return process.stdin; |
| }, |
| ); |
| |
| vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true); |
| vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); |
| vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin); |
| vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation( |
| () => process.stdin, |
| ); |
|
|
| |
|
|
| const { LegacyAgentSession } = await import('@google/gemini-cli-core'); |
| const sendSpy = vi.spyOn(LegacyAgentSession.prototype, 'send'); |
|
|
| await expect( |
| runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Cancelled query', |
| prompt_id: 'prompt-id-pre-send-cancel', |
| }), |
| ).rejects.toThrow('Operation cancelled.'); |
|
|
| expect(sendSpy).not.toHaveBeenCalled(); |
| expect(stdinOnSpy).toHaveBeenCalled(); |
| sendSpy.mockRestore(); |
|
|
| Object.defineProperty(process.stdin, 'isTTY', { |
| value: originalIsTTY, |
| configurable: true, |
| }); |
| if (originalSetRawMode) { |
| |
| (process.stdin as any).setRawMode = originalSetRawMode; |
| } else { |
| |
| delete (process.stdin as any).setRawMode; |
| } |
| }); |
|
|
| it('should throw FatalInputError if a command requires confirmation', async () => { |
| const mockCommand = { |
| name: 'confirm', |
| description: 'a command that needs confirmation', |
| action: vi.fn().mockResolvedValue({ |
| type: 'confirm_shell_commands', |
| commands: ['rm -rf /'], |
| }), |
| }; |
| mockGetCommands.mockReturnValue([mockCommand]); |
|
|
| await expect( |
| runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/confirm', |
| prompt_id: 'prompt-id-confirm', |
| }), |
| ).rejects.toThrow( |
| 'Exiting due to a confirmation prompt requested by the command.', |
| ); |
| }); |
|
|
| it('should treat an unknown slash command as a regular prompt', async () => { |
| |
| mockGetCommands.mockReturnValue([]); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Response to unknown' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/unknowncommand', |
| prompt_id: 'prompt-id-unknown', |
| }); |
|
|
| |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( |
| [{ text: '/unknowncommand' }], |
| expect.any(AbortSignal), |
| 'prompt-id-unknown', |
| undefined, |
| '/unknowncommand', |
| ); |
|
|
| expect(getWrittenOutput()).toBe('Response to unknown\n'); |
| }); |
|
|
| it('should throw for unhandled command result types', async () => { |
| const mockCommand = { |
| name: 'noaction', |
| description: 'unhandled type', |
| action: vi.fn().mockResolvedValue({ |
| type: 'unhandled', |
| }), |
| }; |
| mockGetCommands.mockReturnValue([mockCommand]); |
|
|
| await expect( |
| runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/noaction', |
| prompt_id: 'prompt-id-unhandled', |
| }), |
| ).rejects.toThrow( |
| 'Exiting due to command result that is not supported in non-interactive mode.', |
| ); |
| }); |
|
|
| it('should pass arguments to the slash command action', async () => { |
| const mockAction = vi.fn().mockResolvedValue({ |
| type: 'submit_prompt', |
| content: [{ text: 'Prompt from command' }], |
| }); |
| const mockCommand = { |
| name: 'testargs', |
| description: 'a test command', |
| action: mockAction, |
| }; |
| mockGetCommands.mockReturnValue([mockCommand]); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Acknowledged' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/testargs arg1 arg2', |
| prompt_id: 'prompt-id-args', |
| }); |
|
|
| expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); |
|
|
| expect(getWrittenOutput()).toBe('Acknowledged\n'); |
| }); |
|
|
| it('should instantiate CommandService with correct loaders for slash commands', async () => { |
| |
| const { FileCommandLoader } = await import( |
| './services/FileCommandLoader.js' |
| ); |
| const { McpPromptLoader } = await import('./services/McpPromptLoader.js'); |
| const { BuiltinCommandLoader } = await import( |
| './services/BuiltinCommandLoader.js' |
| ); |
| mockGetCommands.mockReturnValue([]); |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Acknowledged' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: '/mycommand', |
| prompt_id: 'prompt-id-loaders', |
| }); |
|
|
| |
| expect(FileCommandLoader).toHaveBeenCalledTimes(1); |
| expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); |
| expect(McpPromptLoader).toHaveBeenCalledTimes(1); |
| expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); |
| expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); |
|
|
| |
| expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1); |
| const loadersArg = mockCommandServiceCreate.mock.calls[0][0]; |
| expect(loadersArg).toHaveLength(3); |
| expect(loadersArg[0]).toBe( |
| vi.mocked(BuiltinCommandLoader).mock.instances[0], |
| ); |
| expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); |
| expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); |
| }); |
|
|
| it('should allow a normally-excluded tool when --allowed-tools is set', async () => { |
| |
| |
| vi.mocked(mockConfig.getToolRegistry).mockReturnValue({ |
| getTool: vi.fn().mockReturnValue({ |
| name: 'ShellTool', |
| description: 'A shell tool', |
| run: vi.fn(), |
| }), |
| getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]), |
| } as unknown as ToolRegistry); |
|
|
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-shell-1', |
| name: 'ShellTool', |
| args: { command: 'ls' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-allowed', |
| }, |
| }; |
| const toolResponse: Part[] = [{ text: 'file.txt' }]; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: { |
| callId: 'tool-shell-1', |
| name: 'ShellTool', |
| args: { command: 'ls' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-allowed', |
| }, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: toolResponse, |
| callId: 'tool-shell-1', |
| error: undefined, |
| errorType: undefined, |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
|
|
| const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; |
| const secondCallEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'file.txt' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) |
| .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'List the files', |
| prompt_id: 'prompt-id-allowed', |
| }); |
|
|
| expect(mockSchedulerSchedule).toHaveBeenCalledWith( |
| [expect.objectContaining({ name: 'ShellTool' })], |
| expect.any(AbortSignal), |
| ); |
| expect(getWrittenOutput()).toBe('file.txt\n'); |
| }); |
|
|
| describe('CoreEvents Integration', () => { |
| it('subscribes to UserFeedback and drains backlog on start', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'prompt-id-events', |
| }); |
|
|
| expect(mockCoreEvents.on).toHaveBeenCalledWith( |
| CoreEvent.UserFeedback, |
| expect.any(Function), |
| ); |
| expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('unsubscribes from UserFeedback on finish', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'prompt-id-events', |
| }); |
|
|
| expect(mockCoreEvents.off).toHaveBeenCalledWith( |
| CoreEvent.UserFeedback, |
| expect.any(Function), |
| ); |
| }); |
|
|
| it('logs to process.stderr when UserFeedback event is received', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'prompt-id-events', |
| }); |
|
|
| |
| const handler = mockCoreEvents.on.mock.calls.find( |
| (call: unknown[]) => call[0] === CoreEvent.UserFeedback, |
| )?.[1]; |
| expect(handler).toBeDefined(); |
|
|
| |
| const payload: UserFeedbackPayload = { |
| severity: 'error', |
| message: 'Test error message', |
| }; |
| handler(payload); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| '[ERROR] Test error message\n', |
| ); |
| }); |
|
|
| it('logs optional error object to process.stderr in debug mode', async () => { |
| vi.mocked(mockConfig.getDebugMode).mockReturnValue(true); |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'prompt-id-events', |
| }); |
|
|
| |
| const handler = mockCoreEvents.on.mock.calls.find( |
| (call: unknown[]) => call[0] === CoreEvent.UserFeedback, |
| )?.[1]; |
| expect(handler).toBeDefined(); |
|
|
| |
| const errorObj = new Error('Original error'); |
| |
| errorObj.stack = 'Error: Original error\n at test'; |
| const payload: UserFeedbackPayload = { |
| severity: 'warning', |
| message: 'Test warning message', |
| error: errorObj, |
| }; |
| handler(payload); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| '[WARNING] Test warning message\n', |
| ); |
| expect(processStderrSpy).toHaveBeenCalledWith( |
| 'Error: Original error\n at test\n', |
| ); |
| }); |
| }); |
|
|
| it('should emit appropriate events for streaming JSON output', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-stream', |
| }, |
| }; |
|
|
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: toolCallEvent.value, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: [{ text: 'Tool response' }], |
| callId: 'tool-1', |
| error: undefined, |
| errorType: undefined, |
| contentLength: undefined, |
| resultDisplay: 'Tool executed successfully', |
| }, |
| }, |
| ]); |
|
|
| const firstCallEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Thinking...' }, |
| toolCallEvent, |
| ]; |
| const secondCallEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) |
| .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Stream test', |
| prompt_id: 'prompt-id-stream', |
| }); |
|
|
| const output = getWrittenOutput(); |
| const sanitizedOutput = output |
| .replace(/"timestamp":"[^"]+"/g, '"timestamp":"<TIMESTAMP>"') |
| .replace(/"duration_ms":\d+/g, '"duration_ms":<DURATION>'); |
| expect(sanitizedOutput).toMatchSnapshot(); |
| }); |
|
|
| it('should handle EPIPE error gracefully', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Hello' }, |
| { type: GeminiEventType.Content, value: ' World' }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| |
| vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never); |
|
|
| |
| const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find( |
| (call) => call[0] === 'error', |
| )?.[1]; |
|
|
| if (stdoutErrorCallback) { |
| stdoutErrorCallback({ code: 'EPIPE' }); |
| } |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'EPIPE test', |
| prompt_id: 'prompt-id-epipe', |
| }); |
|
|
| |
| |
| expect(process.stdout.on).toHaveBeenCalledWith( |
| 'error', |
| expect.any(Function), |
| ); |
| }); |
|
|
| it('should resume chat when resumedSessionData is provided', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Resumed' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| const resumedSessionData = { |
| conversation: { |
| sessionId: 'resumed-session-id', |
| messages: [ |
| { role: 'user', parts: [{ text: 'Previous message' }] }, |
| ] as any, |
| startTime: new Date().toISOString(), |
| lastUpdated: new Date().toISOString(), |
| firstUserMessage: 'Previous message', |
| projectHash: 'test-hash', |
| }, |
| filePath: '/path/to/session.json', |
| }; |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Continue', |
| prompt_id: 'prompt-id-resume', |
| resumedSessionData, |
| }); |
|
|
| expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( |
| expect.any(Array), |
| resumedSessionData, |
| ); |
| expect(getWrittenOutput()).toBe('Resumed\n'); |
| }); |
|
|
| it.each([ |
| { |
| name: 'loop detected', |
| events: [ |
| { type: GeminiEventType.LoopDetected }, |
| ] as ServerGeminiStreamEvent[], |
| input: 'Loop test', |
| promptId: 'prompt-id-loop', |
| }, |
| { |
| name: 'max session turns', |
| events: [ |
| { type: GeminiEventType.MaxSessionTurns }, |
| ] as ServerGeminiStreamEvent[], |
| input: 'Max turns test', |
| promptId: 'prompt-id-max-turns', |
| }, |
| ])( |
| 'should emit appropriate error event in streaming JSON mode: $name', |
| async ({ events, input, promptId }) => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const streamEvents: ServerGeminiStreamEvent[] = [ |
| ...events, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(streamEvents), |
| ); |
|
|
| try { |
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input, |
| prompt_id: promptId, |
| }); |
| } catch { |
| |
| } |
|
|
| const output = getWrittenOutput(); |
| const sanitizedOutput = output |
| .replace(/"timestamp":"[^"]+"/g, '"timestamp":"<TIMESTAMP>"') |
| .replace(/"duration_ms":\d+/g, '"duration_ms":<DURATION>'); |
| expect(sanitizedOutput).toMatchSnapshot(); |
| }, |
| ); |
|
|
| it.each([ |
| { |
| name: 'loop detected', |
| events: [ |
| { type: GeminiEventType.LoopDetected }, |
| ] as ServerGeminiStreamEvent[], |
| expectedWarning: 'Loop detected, stopping execution', |
| }, |
| ])( |
| 'should include warning in JSON mode for: $name', |
| async ({ events, expectedWarning }) => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const streamEvents: ServerGeminiStreamEvent[] = [ |
| ...events, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(streamEvents), |
| ); |
|
|
| try { |
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'test', |
| }); |
| } catch { |
| |
| } |
|
|
| const output = JSON.parse(getWrittenOutput()); |
| expect(output.warnings).toBeDefined(); |
| expect(output.warnings[0]).toContain(expectedWarning); |
| }, |
| ); |
|
|
| it('should log error when tool recording fails', async () => { |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-tool-error', |
| }, |
| }; |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Success, |
| request: toolCallEvent.value, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| responseParts: [], |
| callId: 'tool-1', |
| error: undefined, |
| errorType: undefined, |
| contentLength: undefined, |
| }, |
| }, |
| ]); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| toolCallEvent, |
| { type: GeminiEventType.Content, value: 'Done' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(events)) |
| .mockReturnValueOnce( |
| createStreamFromEvents([ |
| { type: GeminiEventType.Content, value: 'Done' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]), |
| ); |
|
|
| |
| const mockChat = { |
| recordCompletedToolCalls: vi.fn().mockImplementation(() => { |
| throw new Error('Recording failed'); |
| }), |
| }; |
| mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat); |
| mockGeminiClient.getCurrentSequenceModel = vi |
| .fn() |
| .mockReturnValue('model-1'); |
|
|
| |
| const { debugLogger } = await import('@google/gemini-cli-core'); |
| const debugLoggerErrorSpy = vi |
| .spyOn(debugLogger, 'error') |
| .mockImplementation(() => {}); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Tool recording error test', |
| prompt_id: 'prompt-id-tool-error', |
| }); |
|
|
| expect(debugLoggerErrorSpy).toHaveBeenCalledWith( |
| expect.stringContaining( |
| 'Error recording completed tool call information: Error: Recording failed', |
| ), |
| ); |
| 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', |
| }, |
| }; |
|
|
| |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.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, |
| ]; |
|
|
| |
| |
| |
| mockGeminiClient.sendMessageStream |
| .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) |
| .mockReturnValueOnce(createStreamFromEvents([])); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Run stop tool', |
| prompt_id: 'prompt-id-stop', |
| }); |
|
|
| expect(mockSchedulerSchedule).toHaveBeenCalled(); |
|
|
| |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| 'Agent execution stopped: Stop reason from hook\n', |
| ); |
| }); |
|
|
| it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'stop-call', |
| name: 'stopTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-stop-json', |
| }, |
| }; |
|
|
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.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'), |
| resultDisplay: undefined, |
| }, |
| }, |
| ]); |
|
|
| const firstCallEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Partial content' }, |
| toolCallEvent, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(firstCallEvents), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Run stop tool', |
| prompt_id: 'prompt-id-stop-json', |
| }); |
|
|
| expect(processStdoutSpy).toHaveBeenCalledWith( |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| response: 'Partial content', |
| stats: MOCK_SESSION_METRICS, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'stop-call', |
| name: 'stopTool', |
| args: {}, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-stop-stream', |
| }, |
| }; |
|
|
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.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'), |
| resultDisplay: undefined, |
| }, |
| }, |
| ]); |
|
|
| const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(firstCallEvents), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Run stop tool', |
| prompt_id: 'prompt-id-stop-stream', |
| }); |
|
|
| const output = getWrittenOutput(); |
| expect(output).toContain('"type":"result"'); |
| expect(output).toContain('"status":"success"'); |
| }); |
|
|
| describe('Agent Execution Events', () => { |
| it('should handle AgentExecutionStopped event', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.AgentExecutionStopped, |
| value: { reason: 'Stopped by hook' }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test stop', |
| prompt_id: 'prompt-id-stop', |
| }); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| 'Agent execution stopped: Stopped by hook\n', |
| ); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should write JSON output when AgentExecutionStopped event occurs', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Partial content' }, |
| { |
| type: GeminiEventType.AgentExecutionStopped, |
| value: { reason: 'Stopped by hook' }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test stop', |
| prompt_id: 'prompt-id-stop-json', |
| }); |
|
|
| expect(processStdoutSpy).toHaveBeenCalledWith( |
| JSON.stringify( |
| { |
| session_id: 'test-session-id', |
| response: 'Partial content', |
| stats: MOCK_SESSION_METRICS, |
| }, |
| null, |
| 2, |
| ), |
| ); |
| }); |
|
|
| it('should emit result event when AgentExecutionStopped event occurs in streaming JSON mode', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Partial content' }, |
| { |
| type: GeminiEventType.AgentExecutionStopped, |
| value: { reason: 'Stopped by hook' }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test stop', |
| prompt_id: 'prompt-id-stop-stream', |
| }); |
|
|
| const output = getWrittenOutput(); |
| expect(output).toContain('"type":"result"'); |
| expect(output).toContain('"status":"success"'); |
| }); |
|
|
| it('should handle AgentExecutionBlocked event', async () => { |
| const allEvents: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.AgentExecutionBlocked, |
| value: { reason: 'Blocked by hook' }, |
| }, |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(allEvents), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test block', |
| prompt_id: 'prompt-id-block', |
| }); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| '[WARNING] Agent execution blocked: Blocked by hook\n', |
| ); |
| |
| expect(getWrittenOutput()).toBe('Final answer\n'); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => { |
| const allEvents: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.AgentExecutionBlocked, |
| value: { reason: 'Blocked by hook' }, |
| }, |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(allEvents), |
| ); |
|
|
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test block', |
| prompt_id: 'prompt-id-block', |
| }); |
|
|
| const calls = processStdoutSpy.mock.calls.map((call) => |
| JSON.parse(call[0] as string), |
| ); |
| const errorEvent = calls.find( |
| (c) => c.type === JsonStreamEventType.ERROR, |
| ); |
|
|
| expect(errorEvent).toBeDefined(); |
| expect(errorEvent.message).toContain( |
| 'Agent execution blocked: Blocked by hook', |
| ); |
| expect(errorEvent.severity).toBe('warning'); |
| }); |
|
|
| it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => { |
| const allEvents: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.AgentExecutionBlocked, |
| value: { reason: 'Blocked by hook' }, |
| }, |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(allEvents), |
| ); |
|
|
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test block', |
| prompt_id: 'prompt-id-block', |
| }); |
|
|
| const output = JSON.parse(getWrittenOutput()); |
| expect(output.warnings).toBeDefined(); |
| expect(output.warnings).toContain( |
| 'Agent execution blocked: Blocked by hook', |
| ); |
| }); |
|
|
| it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => { |
| const allEvents: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.AgentExecutionBlocked, |
| value: { reason: 'Block 1', systemMessage: 'Reason 1' }, |
| }, |
| { |
| type: GeminiEventType.AgentExecutionBlocked, |
| value: { reason: 'Block 2', systemMessage: 'Reason 2' }, |
| }, |
| { type: GeminiEventType.Content, value: 'Final answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockImplementation(() => |
| createStreamFromEvents(allEvents), |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'test', |
| }); |
|
|
| const output = JSON.parse(getWrittenOutput()); |
| expect(output.warnings).toHaveLength(2); |
| expect(output.warnings).toContain('Agent execution blocked: Reason 1'); |
| expect(output.warnings).toContain('Agent execution blocked: Reason 2'); |
| }); |
|
|
| it('should not include warnings field in JSON output if no blocks occur', async () => { |
| const allEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: 'Clean answer' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockImplementation(() => |
| createStreamFromEvents(allEvents), |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test', |
| prompt_id: 'test', |
| }); |
|
|
| const output = JSON.parse(getWrittenOutput()); |
| expect(output.warnings).toBeUndefined(); |
| }); |
|
|
| it('should handle InvalidStream event gracefully in TEXT mode', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.InvalidStream, |
| value: { |
| type: 'NO_RESPONSE_TEXT', |
| message: 'Empty response', |
| }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test invalid stream', |
| prompt_id: 'prompt-id-invalid', |
| }); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| `[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`, |
| ); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => { |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
| vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.InvalidStream, |
| value: { |
| type: 'NO_RESPONSE_TEXT', |
| message: 'Empty response', |
| }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test invalid stream', |
| prompt_id: 'prompt-id-invalid', |
| }); |
|
|
| const output = getWrittenOutput(); |
| expect(output).toContain('"type":"error"'); |
| expect(output).toContain('"severity":"error"'); |
| expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should handle InvalidStream event gracefully in JSON mode', async () => { |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
| vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue( |
| OutputFormat.JSON, |
| ); |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.InvalidStream, |
| value: { |
| type: 'NO_RESPONSE_TEXT', |
| message: 'Empty response', |
| }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test invalid stream', |
| prompt_id: 'prompt-id-invalid', |
| }); |
|
|
| const output = getWrittenOutput(); |
| expect(output).toContain('"error": {'); |
| expect(output).toContain('"type": "INVALID_STREAM"'); |
| expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.InvalidStream, |
| value: { |
| type: 'MALFORMED_FUNCTION_CALL', |
| message: 'Malformed call', |
| }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'test invalid stream', |
| prompt_id: 'prompt-id-invalid', |
| }); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n'); |
| expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); |
| }); |
| }); |
|
|
| describe('Output Sanitization', () => { |
| const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m'; |
| const OSC_HYPERLINK = |
| '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\'; |
| const PLAIN_TEXT_RED = 'Red Text'; |
| const PLAIN_TEXT_LINK = 'Link'; |
|
|
| it('should sanitize ANSI output by default', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, |
| { type: GeminiEventType.Content, value: ' ' }, |
| { type: GeminiEventType.Content, value: OSC_HYPERLINK }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-sanitization', |
| }); |
|
|
| expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`); |
| }); |
|
|
| it('should allow ANSI output when rawOutput is true', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, |
| { type: GeminiEventType.Content, value: ' ' }, |
| { type: GeminiEventType.Content, value: OSC_HYPERLINK }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); |
| vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-raw', |
| }); |
|
|
| expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`); |
| }); |
|
|
| it('should allow ANSI output when only acceptRawOutputRisk is true', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); |
| vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-accept-only', |
| }); |
|
|
| expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`); |
| }); |
|
|
| it('should warn when rawOutput is true and acceptRisk is false', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); |
| vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-warn', |
| }); |
|
|
| expect(processStderrSpy).toHaveBeenCalledWith( |
| expect.stringContaining('[WARNING] --raw-output is enabled'), |
| ); |
| }); |
|
|
| it('should not warn when rawOutput is true and acceptRisk is true', async () => { |
| const events: ServerGeminiStreamEvent[] = [ |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); |
| vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-no-warn', |
| }); |
|
|
| expect(processStderrSpy).not.toHaveBeenCalledWith( |
| expect.stringContaining('[WARNING] --raw-output is enabled'), |
| ); |
| }); |
|
|
| it('should emit warning event for loop_detected in streaming JSON mode', async () => { |
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| const streamEvents: ServerGeminiStreamEvent[] = [ |
| { type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent, |
| { type: GeminiEventType.Content, value: 'Continuing after loop' }, |
| { |
| type: GeminiEventType.Finished, |
| value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, |
| }, |
| ]; |
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(streamEvents), |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Loop test explicit', |
| prompt_id: 'prompt-id-loop-explicit', |
| }); |
|
|
| const output = getWrittenOutput(); |
| |
| expect(output).toContain('"type":"error"'); |
| expect(output).toContain('"severity":"warning"'); |
| expect(output).toContain('Loop detected'); |
| }); |
|
|
| it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => { |
| const toolCallEvent: ServerGeminiStreamEvent = { |
| type: GeminiEventType.ToolCallRequest, |
| value: { |
| callId: 'tool-1', |
| name: 'testTool', |
| args: { arg1: 'value1' }, |
| isClientInitiated: false, |
| prompt_id: 'prompt-id-cancel', |
| }, |
| }; |
|
|
| |
| mockSchedulerSchedule.mockResolvedValue([ |
| { |
| status: CoreToolCallStatus.Cancelled, |
| request: toolCallEvent.value, |
| tool: {} as AnyDeclarativeTool, |
| invocation: {} as AnyToolInvocation, |
| response: { |
| callId: 'tool-1', |
| responseParts: [{ text: 'Operation cancelled' }], |
| resultDisplay: 'Cancelled', |
| }, |
| }, |
| ]); |
|
|
| const events: ServerGeminiStreamEvent[] = [ |
| toolCallEvent, |
| { |
| type: GeminiEventType.Content, |
| value: 'Model continues...', |
| }, |
| ]; |
|
|
| mockGeminiClient.sendMessageStream.mockReturnValue( |
| createStreamFromEvents(events), |
| ); |
|
|
| vi.mocked(mockConfig.getOutputFormat).mockReturnValue( |
| OutputFormat.STREAM_JSON, |
| ); |
| vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( |
| MOCK_SESSION_METRICS, |
| ); |
|
|
| await runNonInteractive({ |
| config: mockConfig, |
| settings: mockSettings, |
| input: 'Test input', |
| prompt_id: 'prompt-id-cancel', |
| }); |
|
|
| const output = getWrittenOutput(); |
| expect(output).toContain('"type":"tool_result"'); |
| expect(output).toContain('"status":"success"'); |
| }); |
| }); |
| }); |
|
|