From 1088e1febf2cfdda3a5acb01743e59c78623a047 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 23 Mar 2026 18:47:11 -0400 Subject: [PATCH] fix(cli): preserve max-turns non-interactive parity --- .../nonInteractiveCli.test.ts.snap | 8 +++ packages/cli/src/nonInteractiveCli.test.ts | 71 +++---------------- packages/cli/src/nonInteractiveCli.ts | 15 +++- 3 files changed, 31 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap index 463ce3b144..92f396a59c 100644 --- a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap +++ b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap @@ -8,6 +8,14 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS " `; +exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Max turns test"} +{"type":"error","timestamp":"","severity":"error","message":"Maximum session turns exceeded"} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = ` "{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} {"type":"message","timestamp":"","role":"user","content":"Stream test"} diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index ade6275b2a..fe3128be63 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -679,21 +679,6 @@ describe('runNonInteractive', () => { ).rejects.toThrow('process.exit(53) called'); }); - it('should exit when the session reports max turns through agent_end', async () => { - mockGeminiClient.sendMessageStream.mockReturnValue( - createStreamFromEvents([{ type: GeminiEventType.MaxSessionTurns }]), - ); - - await expect( - runNonInteractive({ - config: mockConfig, - settings: mockSettings, - input: 'Trigger max turns event', - prompt_id: 'prompt-id-max-turns-event', - }), - ).rejects.toThrow('process.exit(53) called'); - }); - it('should preprocess @include commands before sending to the model', async () => { // 1. Mock the imported atCommandProcessor const { handleAtCommand } = await import( @@ -1855,9 +1840,17 @@ describe('runNonInteractive', () => { 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 ({ name, events, input, promptId }) => { + async ({ events, input, promptId }) => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue( OutputFormat.STREAM_JSON, ); @@ -1895,52 +1888,6 @@ describe('runNonInteractive', () => { }, ); - it('should emit a terminal max-turns error event in streaming JSON mode', async () => { - vi.mocked(mockConfig.getOutputFormat).mockReturnValue( - OutputFormat.STREAM_JSON, - ); - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( - MOCK_SESSION_METRICS, - ); - - mockGeminiClient.sendMessageStream.mockReturnValue( - createStreamFromEvents([ - { type: GeminiEventType.MaxSessionTurns }, - { - type: GeminiEventType.Finished, - value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, - }, - ]), - ); - - try { - await runNonInteractive({ - config: mockConfig, - settings: mockSettings, - input: 'Max turns test', - prompt_id: 'prompt-id-max-turns', - }); - } catch (_error) { - // Expected exit - } - - const streamEvents = getWrittenOutput() - .trim() - .split('\n') - .map((line) => JSON.parse(line) as Record); - - expect(streamEvents).toHaveLength(3); - expect(streamEvents[2]).toMatchObject({ - type: 'result', - status: 'error', - error: { - type: 'FatalTurnLimitedError', - message: - 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', - }, - }); - }); - it('should log error when tool recording fails', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 3eb17d507f..6311247911 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -523,7 +523,20 @@ export async function runNonInteractive({ if (event.reason === 'aborted') { runTerminalExitHandler(() => handleCancellationError(config)); } else if (event.reason === 'max_turns') { - runTerminalExitHandler(() => handleMaxTurnsExceededError(config)); + const isConfiguredTurnLimit = + typeof event.data?.['maxTurns'] === 'number' || + typeof event.data?.['turnCount'] === 'number'; + + if (isConfiguredTurnLimit) { + runTerminalExitHandler(() => handleMaxTurnsExceededError(config)); + } else if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'error', + message: 'Maximum session turns exceeded', + }); + } } const stopMessage =