diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 6ec635ead1d..d124b4d5cad 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -21,6 +21,7 @@ import { FatalInputError, ApprovalMode, SendMessageType, + LoopType, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; @@ -370,6 +371,83 @@ describe('runNonInteractive', () => { expect(stdoutDestroySpy).toHaveBeenCalled(); }); + it('returns non-zero and skips pending tool calls after loop detection', async () => { + setupMetricsMock(); + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-loop-detected', + }, + }; + const events: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.LoopDetected, + value: { loopType: LoopType.TURN_TOOL_CALL_CAP }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + const exitCode = await runNonInteractive( + mockConfig, + mockSettings, + 'Use a tool', + 'prompt-id-loop-detected', + ); + + expect(exitCode).toBe(1); + expect(mockCoreExecuteToolCall).not.toHaveBeenCalled(); + expect(processStdoutSpy).not.toHaveBeenCalled(); + expect(processStderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Loop detection halted the run'), + ); + }); + + it('marks JSON output as an error when loop detection halts the run', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial work' }, + { + type: GeminiEventType.LoopDetected, + value: { loopType: LoopType.TURN_TOOL_CALL_CAP }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + const exitCode = await runNonInteractive( + mockConfig, + mockSettings, + 'Test input', + 'prompt-id-loop-json', + ); + + expect(exitCode).toBe(1); + const outputCalls = processStdoutSpy.mock.calls.filter( + (call) => typeof call[0] === 'string', + ); + const lastOutput = outputCalls.at(-1)?.[0]; + expect(typeof lastOutput).toBe('string'); + const parsed = JSON.parse(lastOutput as string) as Array<{ + type?: string; + is_error?: boolean; + error?: { message?: string }; + }>; + const resultMessage = parsed.find((msg) => msg.type === 'result'); + expect(resultMessage?.is_error).toBe(true); + expect(resultMessage?.error?.message).toContain( + 'Loop detection halted the run', + ); + }); + it('should handle a single tool call and respond', async () => { setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index b93f0482c87..a880e064864 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -118,15 +118,7 @@ const LOOP_TYPE_LABELS: Record = { 'the model exceeded the maximum number of tool calls allowed in a single turn', }; -function emitLoopDetectedMessage( - config: Config, - loopType: LoopType | undefined, -): void { - // In TEXT mode the adapter swallows LoopDetected, so we print here. In - // JSON modes the adapter emits a structured result, which is enough. - if (config.getOutputFormat() !== OutputFormat.TEXT) { - return; - } +function formatLoopDetectedMessage(loopType: LoopType | undefined): string { const reason = loopType ? LOOP_TYPE_LABELS[loopType] : undefined; const detail = reason ? ` (${loopType}: ${reason})` : ''; // The turn cap runs before the skipLoopDetection gate, so that setting can't @@ -135,7 +127,21 @@ function emitLoopDetectedMessage( loopType === LoopType.TURN_TOOL_CALL_CAP ? ' This is an always-on per-turn tool-call cap and cannot be disabled via `model.skipLoopDetection`.' : ' Set the `model.skipLoopDetection` setting to true to disable.'; - process.stderr.write(`Loop detection halted the run${detail}.${hint}\n`); + return `Loop detection halted the run${detail}.${hint}`; +} + +function emitLoopDetectedMessage( + config: Config, + loopType: LoopType | undefined, +): string { + const message = formatLoopDetectedMessage(loopType); + // In TEXT mode the adapter swallows LoopDetected, so we print here. In + // JSON modes the adapter emits a structured result, which is enough. + if (config.getOutputFormat() !== OutputFormat.TEXT) { + return message; + } + process.stderr.write(`${message}\n`); + return message; } /** @@ -689,6 +695,8 @@ export async function runNonInteractive( // actually said instead of a static, context-free message. let plainTextPreview = ''; const PLAIN_TEXT_PREVIEW_LIMIT = 200; + let loopDetected = false; + let loopDetectedMessage = formatLoopDetectedMessage(undefined); // Shared terminal block for the structured-output success // contract. Both the main-turn loop and the drain-turn post-loop @@ -736,6 +744,33 @@ export async function runNonInteractive( return 0; }; + const emitLoopDetectedResult = (): 1 => { + registry.abortAll(); + flushQueuedNotificationsToSdk(localQueue); + finalizeOneShotMonitors(); + + if (outputFormat === OutputFormat.TEXT) { + return 1; + } + + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + adapter.emitResult({ + isError: true, + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + errorMessage: loopDetectedMessage, + usage, + stats, + }); + return 1; + }; + /** * Shared per-turn tool-call dispatch for the main-turn loop and * `drainBatch`. Both call sites used to reproduce ~120 lines of @@ -1092,7 +1127,13 @@ export async function runNonInteractive( plainTextPreview += String(event.value).slice(0, remaining); } if (event.type === GeminiEventType.LoopDetected) { - emitLoopDetectedMessage(config, event.value?.loopType); + if (!loopDetected) { + loopDetectedMessage = emitLoopDetectedMessage( + config, + event.value?.loopType, + ); + } + loopDetected = true; } if ( outputFormat === OutputFormat.TEXT && @@ -1115,6 +1156,10 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - apiStartTime; + if (loopDetected) { + return emitLoopDetectedResult(); + } + if (toolCallRequests.length > 0) { // Dispatch the per-turn tool-call batch through the shared // helper (see processToolCallBatch above). The helper handles @@ -1315,7 +1360,13 @@ export async function runNonInteractive( itemToolCallRequests.push(event.value); } if (event.type === GeminiEventType.LoopDetected) { - emitLoopDetectedMessage(config, event.value?.loopType); + if (!loopDetected) { + loopDetectedMessage = emitLoopDetectedMessage( + config, + event.value?.loopType, + ); + } + loopDetected = true; } if ( outputFormat === OutputFormat.TEXT && @@ -1336,6 +1387,10 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - itemApiStartTime; + if (loopDetected) { + return; + } + if (itemToolCallRequests.length > 0) { // Same shared dispatch as the main-turn loop. The only // call-site difference is `itemModelOverride` is local to @@ -1374,6 +1429,7 @@ export async function runNonInteractive( if (drainPromise) return drainPromise; const p = (async () => { while (localQueue.length > 0) { + if (loopDetected) return; // Stop draining once a queued item's structured_output // call captured the terminal contract — no point running // more queued prompts that can't influence the result. @@ -1428,6 +1484,12 @@ export async function runNonInteractive( }); const checkCronDone = () => { + if (loopDetected) { + abortController.signal.removeEventListener('abort', onAbort); + scheduler.stop(); + resolve(); + return; + } // A drain-turn structured_output makes the rest of the // cron schedule moot: we already have a terminal result // and the post-drain emit is about to fire. Stop the @@ -1489,6 +1551,7 @@ export async function runNonInteractive( // through the model, but later monitor output is SDK-only. captureMonitorTurnsInLocalQueue = false; await drainLocalQueue(); + if (loopDetected) return emitLoopDetectedResult(); // A drain-turn structured_output captured the terminal // contract — bail out of the holdback loop early and let the // post-loop code emit the success result.