Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down
87 changes: 75 additions & 12 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,7 @@ const LOOP_TYPE_LABELS: Record<LoopType, string> = {
'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
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand All @@ -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
Expand Down Expand Up @@ -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 &&
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading