diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 35f9c4100e..f937639ac4 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -386,13 +386,13 @@ export class TestRig { }; if (typeof promptOrOptions === 'string') { - commandArgs.push('--prompt', promptOrOptions); + commandArgs.push(promptOrOptions); } else if ( typeof promptOrOptions === 'object' && promptOrOptions !== null ) { if (promptOrOptions.prompt) { - commandArgs.push('--prompt', promptOrOptions.prompt); + commandArgs.push(promptOrOptions.prompt); } if (promptOrOptions.stdin) { execOptions.input = promptOrOptions.stdin; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f8e16d9313..13aae88d38 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -439,6 +439,7 @@ describe('startInteractiveUI', () => { vi.mock('./utils/cleanup.js', () => ({ cleanupCheckpoints: vi.fn(() => Promise.resolve()), registerCleanup: vi.fn(), + runExitCleanup: vi.fn(), })); vi.mock('ink', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8aa68e72c2..17c5b807ef 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -477,7 +477,16 @@ export async function main() { debugLogger.log('Session ID: %s', sessionId); } - await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id); + const hasDeprecatedPromptArg = process.argv.some((arg) => + arg.startsWith('--prompt'), + ); + await runNonInteractive({ + config: nonInteractiveConfig, + settings, + input, + prompt_id, + hasDeprecatedPromptArg, + }); // Call cleanup before process.exit, which causes cleanup to not run await runExitCleanup(); process.exit(0); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index cff544305d..d740f08e56 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -95,6 +95,26 @@ describe('runNonInteractive', () => { sendMessageStream: Mock; getChatRecordingService: 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 () => { mockCoreExecuteToolCall = vi.mocked(executeToolCall); @@ -206,12 +226,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - 'Test input', - 'prompt-id-1', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-1', + }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Test input' }], @@ -267,12 +287,12 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); - await runNonInteractive( - mockConfig, - mockSettings, - 'Use a tool', - 'prompt-id-2', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use a tool', + prompt_id: 'prompt-id-2', + }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( @@ -343,12 +363,12 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents(modelTurn3)); // 4. Run the command. - await runNonInteractive( - mockConfig, - mockSettings, - 'Use mock tool multiple times', - 'prompt-id-multi', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use mock tool multiple times', + prompt_id: 'prompt-id-multi', + }); // 5. Verify the output. // The rendered output should contain the text from each turn, separated by a @@ -412,12 +432,12 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); - await runNonInteractive( - mockConfig, - mockSettings, - 'Trigger tool error', - 'prompt-id-3', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger tool error', + prompt_id: 'prompt-id-3', + }); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -449,12 +469,12 @@ describe('runNonInteractive', () => { }); await expect( - runNonInteractive( - mockConfig, - mockSettings, - 'Initial fail', - 'prompt-id-4', - ), + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Initial fail', + prompt_id: 'prompt-id-4', + }), ).rejects.toThrow(apiError); }); @@ -502,12 +522,12 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); - await runNonInteractive( - mockConfig, - mockSettings, - 'Trigger tool not found', - 'prompt-id-5', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger tool not found', + prompt_id: 'prompt-id-5', + }); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -520,12 +540,12 @@ describe('runNonInteractive', () => { it('should exit when max session turns are exceeded', async () => { vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); await expect( - runNonInteractive( - mockConfig, - mockSettings, - 'Trigger loop', - 'prompt-id-6', - ), + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger loop', + prompt_id: 'prompt-id-6', + }), ).rejects.toThrow('process.exit(53) called'); }); @@ -564,7 +584,12 @@ describe('runNonInteractive', () => { ); // 4. Run the non-interactive mode with the raw input - await runNonInteractive(mockConfig, mockSettings, rawInput, 'prompt-id-7'); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: rawInput, + prompt_id: 'prompt-id-7', + }); // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( @@ -589,42 +614,28 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - const mockMetrics: 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, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); - - await runNonInteractive( - mockConfig, - mockSettings, - 'Test input', - 'prompt-id-1', + vi.mocked(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', ); expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: 'Hello World', stats: mockMetrics }, null, 2), + JSON.stringify( + { response: 'Hello World', stats: MOCK_SESSION_METRICS }, + null, + 2, + ), ); }); @@ -684,48 +695,17 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, - tools: { - totalCalls: 1, - totalSuccess: 1, - totalFail: 0, - totalDurationMs: 100, - totalDecisions: { - accept: 1, - reject: 0, - modify: 0, - auto_accept: 0, - }, - byName: { - testTool: { - count: 1, - success: 1, - fail: 0, - durationMs: 100, - decisions: { - accept: 1, - reject: 0, - modify: 0, - auto_accept: 0, - }, - }, - }, - }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); - - await runNonInteractive( - mockConfig, - mockSettings, - 'Execute tool only', - 'prompt-id-tool-only', + vi.mocked(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(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( mockConfig, @@ -735,7 +715,7 @@ describe('runNonInteractive', () => { // This should output JSON with empty response but include stats expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: '', stats: mockMetrics }, null, 2), + JSON.stringify({ response: '', stats: MOCK_SESSION_METRICS }, null, 2), ); }); @@ -751,35 +731,17 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - const mockMetrics: 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, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); - - await runNonInteractive( - mockConfig, - mockSettings, - 'Empty response test', - 'prompt-id-empty', + vi.mocked(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), @@ -788,7 +750,7 @@ describe('runNonInteractive', () => { // This should output JSON with empty response but include stats expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: '', stats: mockMetrics }, null, 2), + JSON.stringify({ response: '', stats: MOCK_SESSION_METRICS }, null, 2), ); }); @@ -807,12 +769,12 @@ describe('runNonInteractive', () => { let thrownError: Error | null = null; try { - await runNonInteractive( - mockConfig, - mockSettings, - 'Test input', - 'prompt-id-error', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-error', + }); // Should not reach here expect.fail('Expected process.exit to be called'); } catch (error) { @@ -852,12 +814,12 @@ describe('runNonInteractive', () => { let thrownError: Error | null = null; try { - await runNonInteractive( - mockConfig, - mockSettings, - 'Invalid syntax', - 'prompt-id-fatal', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Invalid syntax', + prompt_id: 'prompt-id-fatal', + }); // Should not reach here expect.fail('Expected process.exit to be called'); } catch (error) { @@ -904,12 +866,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - '/testcommand', - 'prompt-id-slash', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testcommand', + prompt_id: 'prompt-id-slash', + }); // Ensure the prompt sent to the model is from the command, not the raw input expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( @@ -933,12 +895,12 @@ describe('runNonInteractive', () => { mockGetCommands.mockReturnValue([mockCommand]); await expect( - runNonInteractive( - mockConfig, - mockSettings, - '/confirm', - 'prompt-id-confirm', - ), + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/confirm', + prompt_id: 'prompt-id-confirm', + }), ).rejects.toThrow( 'Exiting due to a confirmation prompt requested by the command.', ); @@ -959,12 +921,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - '/unknowncommand', - 'prompt-id-unknown', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/unknowncommand', + prompt_id: 'prompt-id-unknown', + }); // Ensure the raw input is sent to the model expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( @@ -987,12 +949,12 @@ describe('runNonInteractive', () => { mockGetCommands.mockReturnValue([mockCommand]); await expect( - runNonInteractive( - mockConfig, - mockSettings, - '/noaction', - 'prompt-id-unhandled', - ), + 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.', ); @@ -1021,12 +983,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - '/testargs arg1 arg2', - 'prompt-id-args', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testargs arg1 arg2', + prompt_id: 'prompt-id-args', + }); expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); @@ -1052,12 +1014,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - '/mycommand', - 'prompt-id-loaders', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/mycommand', + prompt_id: 'prompt-id-loaders', + }); // Check that loaders were instantiated with the config expect(FileCommandLoader).toHaveBeenCalledTimes(1); @@ -1129,12 +1091,12 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); - await runNonInteractive( - mockConfig, - mockSettings, - 'List the files', - 'prompt-id-allowed', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'List the files', + prompt_id: 'prompt-id-allowed', + }); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( mockConfig, @@ -1156,12 +1118,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - 'test', - 'prompt-id-events', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, @@ -1181,12 +1143,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - 'test', - 'prompt-id-events', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); expect(mockCoreEvents.off).toHaveBeenCalledWith( CoreEvent.UserFeedback, @@ -1205,12 +1167,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - 'test', - 'prompt-id-events', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); // Get the registered handler const handler = mockCoreEvents.on.mock.calls.find( @@ -1242,12 +1204,12 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); - await runNonInteractive( - mockConfig, - mockSettings, - 'test', - 'prompt-id-events', - ); + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); // Get the registered handler const handler = mockCoreEvents.on.mock.calls.find( @@ -1274,4 +1236,56 @@ describe('runNonInteractive', () => { ); }); }); + + it('should display a deprecation warning if hasDeprecatedPromptArg is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final Answer' }, + { + 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-deprecated', + hasDeprecatedPromptArg: true, + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n', + ); + expect(processStdoutSpy).toHaveBeenCalledWith('Final Answer'); + }); + + it('should display a deprecation warning for JSON format', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final Answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-deprecated-json', + hasDeprecatedPromptArg: true, + }); + + const deprecateText = + 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n'; + expect(processStderrSpy).toHaveBeenCalledWith(deprecateText); + }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index efb0e3186d..bc43cc7a20 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -42,12 +42,21 @@ import { } from './utils/errors.js'; import { TextOutput } from './ui/utils/textOutput.js'; -export async function runNonInteractive( - config: Config, - settings: LoadedSettings, - input: string, - prompt_id: string, -): Promise { +interface RunNonInteractiveParams { + config: Config; + settings: LoadedSettings; + input: string; + prompt_id: string; + hasDeprecatedPromptArg?: boolean; +} + +export async function runNonInteractive({ + config, + settings, + input, + prompt_id, + hasDeprecatedPromptArg, +}: RunNonInteractiveParams): Promise { return promptIdContext.run(prompt_id, async () => { const consolePatcher = new ConsolePatcher({ stderr: true, @@ -151,6 +160,21 @@ export async function runNonInteractive( let currentMessages: Content[] = [{ role: 'user', parts: query }]; let turnCount = 0; + const deprecateText = + 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n'; + if (hasDeprecatedPromptArg) { + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp: new Date().toISOString(), + role: 'assistant', + content: deprecateText, + delta: true, + }); + } else { + process.stderr.write(deprecateText); + } + } while (true) { turnCount++; if (