diff --git a/apps/cli/README.md b/apps/cli/README.md index b55ed604f1a..7f7f54f918f 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -185,6 +185,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo | `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | | `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | | `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--consecutive-mistake-limit ` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` | | `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | | `--oneshot` | Exit upon task completion | `false` | | `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | diff --git a/apps/cli/src/agent/__tests__/events.test.ts b/apps/cli/src/agent/__tests__/events.test.ts new file mode 100644 index 00000000000..6d5802fa3f3 --- /dev/null +++ b/apps/cli/src/agent/__tests__/events.test.ts @@ -0,0 +1,35 @@ +import type { ClineMessage } from "@roo-code/types" + +import { detectAgentState } from "../agent-state.js" +import { taskCompleted } from "../events.js" + +function createMessage(overrides: Partial): ClineMessage { + return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides } +} + +describe("taskCompleted", () => { + it("returns true for completion_result", () => { + const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })]) + const current = detectAgentState([createMessage({ type: "ask", ask: "completion_result", partial: false })]) + + expect(taskCompleted(previous, current)).toBe(true) + }) + + it("returns true for resume_completed_task", () => { + const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })]) + const current = detectAgentState([createMessage({ type: "ask", ask: "resume_completed_task", partial: false })]) + + expect(taskCompleted(previous, current)).toBe(true) + }) + + it("returns false for recoverable idle asks", () => { + const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })]) + const mistakeLimit = detectAgentState([ + createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false }), + ]) + const apiFailed = detectAgentState([createMessage({ type: "ask", ask: "api_req_failed", partial: false })]) + + expect(taskCompleted(previous, mistakeLimit)).toBe(false) + expect(taskCompleted(previous, apiFailed)).toBe(false) + }) +}) diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index 03a6957adf3..b9093bef0d7 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -5,6 +5,8 @@ import fs from "fs" import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" +import { DEFAULT_FLAGS } from "@/types/index.js" + import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js" import { ExtensionClient } from "../extension-client.js" import { AgentLoopState } from "../agent-state.js" @@ -593,6 +595,20 @@ describe("ExtensionHost", () => { expect(initialSettings.mode).toBe("architect") }) + it("should use default consecutiveMistakeLimit when not provided", () => { + const host = createTestHost() + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.consecutiveMistakeLimit).toBe(DEFAULT_FLAGS.consecutiveMistakeLimit) + }) + + it("should set consecutiveMistakeLimit from options", () => { + const host = createTestHost({ consecutiveMistakeLimit: 8 }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.consecutiveMistakeLimit).toBe(8) + }) + it("should enable auto-approval in non-interactive mode", () => { const host = createTestHost({ nonInteractive: true }) diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts new file mode 100644 index 00000000000..2be7adcbb53 --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts @@ -0,0 +1,129 @@ +import type { ClineMessage } from "@roo-code/types" +import { Writable } from "stream" + +import type { TaskCompletedEvent } from "../events.js" +import { JsonEventEmitter } from "../json-event-emitter.js" +import { AgentLoopState, type AgentStateInfo } from "../agent-state.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + const lines = () => + chunks + .join("") + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record) + + return { stdout: writable, lines } +} + +function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void { + ;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage( + message, + false, + ) +} + +function emitTaskCompleted(emitter: JsonEventEmitter, event: TaskCompletedEvent): void { + ;(emitter as unknown as { handleTaskCompleted: (taskCompleted: TaskCompletedEvent) => void }).handleTaskCompleted( + event, + ) +} + +function createAskCompletionMessage(ts: number, text = ""): ClineMessage { + return { + ts, + type: "ask", + ask: "completion_result", + partial: false, + text, + } as ClineMessage +} + +function createCompletedStateInfo(message: ClineMessage): AgentStateInfo { + return { + state: AgentLoopState.IDLE, + isWaitingForInput: true, + isRunning: false, + isStreaming: false, + currentAsk: "completion_result", + requiredAction: "start_task", + lastMessageTs: message.ts, + lastMessage: message, + description: "Task completed successfully. You can provide feedback or start a new task.", + } +} + +describe("JsonEventEmitter result emission", () => { + it("prefers current completion message content over stale cached completion text", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitMessage(emitter, { + ts: 100, + type: "say", + say: "completion_result", + partial: false, + text: "FIRST", + } as ClineMessage) + + const firstCompletionMessage = createAskCompletionMessage(101, "") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(firstCompletionMessage), + message: firstCompletionMessage, + }) + + const secondCompletionMessage = createAskCompletionMessage(102, "SECOND") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(secondCompletionMessage), + message: secondCompletionMessage, + }) + + const output = lines().filter((line) => line.type === "result") + expect(output).toHaveLength(2) + expect(output[0]?.content).toBe("FIRST") + expect(output[1]?.content).toBe("SECOND") + }) + + it("clears cached completion text after each result emission", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitMessage(emitter, { + ts: 200, + type: "say", + say: "completion_result", + partial: false, + text: "FIRST", + } as ClineMessage) + + const firstCompletionMessage = createAskCompletionMessage(201, "") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(firstCompletionMessage), + message: firstCompletionMessage, + }) + + const secondCompletionMessage = createAskCompletionMessage(202, "") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(secondCompletionMessage), + message: secondCompletionMessage, + }) + + const output = lines().filter((line) => line.type === "result") + expect(output).toHaveLength(2) + expect(output[0]?.content).toBe("FIRST") + expect(output[1]).not.toHaveProperty("content") + }) +}) diff --git a/apps/cli/src/agent/events.ts b/apps/cli/src/agent/events.ts index 9b374310ad7..f455bf0c9de 100644 --- a/apps/cli/src/agent/events.ts +++ b/apps/cli/src/agent/events.ts @@ -260,7 +260,7 @@ export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo * Helper to determine if task completed. */ export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean { - const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"] + const completionAsks = ["completion_result", "resume_completed_task"] const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk) const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk) return wasNotComplete && isNowComplete diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index aa7cee3f3a8..fc58aaf01d0 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -26,7 +26,7 @@ import type { import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli" -import type { SupportedProvider } from "@/types/index.js" +import { DEFAULT_FLAGS, type SupportedProvider } from "@/types/index.js" import type { User } from "@/lib/sdk/index.js" import { getProviderSettings } from "@/lib/utils/provider.js" import { createEphemeralStorageDir } from "@/lib/storage/index.js" @@ -66,6 +66,7 @@ const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot() export interface ExtensionHostOptions { mode: string reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" + consecutiveMistakeLimit?: number user: User | null provider: SupportedProvider apiKey?: string @@ -219,6 +220,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Populate initial settings. const baseSettings: RooCodeSettings = { mode: this.options.mode, + consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit, commandExecutionTimeout: 30, enableCheckpoints: false, experiments: { diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index a3962d0abf6..6b6c02a88b6 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -96,6 +96,7 @@ export class JsonEventEmitter { private stdout: NodeJS.WriteStream private events: JsonEvent[] = [] private unsubscribers: (() => void)[] = [] + private pendingWrites = new Set>() private lastCost: JsonEventCost | undefined private requestIdProvider: () => string | undefined private schemaVersion: number @@ -598,8 +599,9 @@ export class JsonEventEmitter { * Handle task completion and emit result event. */ private handleTaskCompleted(event: TaskCompletedEvent): void { - // Use tracked completion result content, falling back to event message - const resultContent = this.completionResultContent || event.message?.text || this.lastAssistantText + // Prefer the completion payload from the current event. If it is empty, + // fall back to the most recent tracked completion text, then assistant text. + const resultContent = event.message?.text || this.completionResultContent || this.lastAssistantText this.emitEvent({ type: "result", @@ -610,6 +612,10 @@ export class JsonEventEmitter { cost: this.lastCost, }) + // Prevent stale completion content from leaking into later turns. + this.completionResultContent = undefined + this.lastAssistantText = undefined + // For "json" mode, output the final accumulated result if (this.mode === "json") { this.outputFinalResult(event.success, resultContent) @@ -647,7 +653,7 @@ export class JsonEventEmitter { * Output a single JSON line (NDJSON format). */ private outputLine(data: unknown): void { - this.stdout.write(JSON.stringify(data) + "\n") + this.writeToStdout(JSON.stringify(data) + "\n") } /** @@ -662,7 +668,31 @@ export class JsonEventEmitter { events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself } - this.stdout.write(JSON.stringify(output, null, 2) + "\n") + this.writeToStdout(JSON.stringify(output, null, 2) + "\n") + } + + private writeToStdout(content: string): void { + const writePromise = new Promise((resolve, reject) => { + this.stdout.write(content, (error?: Error | null) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + + this.pendingWrites.add(writePromise) + + void writePromise.finally(() => { + this.pendingWrites.delete(writePromise) + }) + } + + async flush(): Promise { + while (this.pendingWrites.size > 0) { + await Promise.all([...this.pendingWrites]) + } } /** diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts index 2b9fd13602f..f841932dcf6 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -343,13 +343,16 @@ export class MessageProcessor { // Task completed if (taskCompleted(previousState, currentState)) { + const completedSuccessfully = + currentState.currentAsk === "completion_result" || currentState.currentAsk === "resume_completed_task" + if (this.options.debug) { debugLog("[MessageProcessor] EMIT taskCompleted", { - success: currentState.currentAsk === "completion_result", + success: completedSuccessfully, }) } const completedEvent: TaskCompletedEvent = { - success: currentState.currentAsk === "completion_result", + success: completedSuccessfully, stateInfo: currentState, message: currentState.lastMessage, } diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index d8ec6d50e88..b9d690e395e 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -3,8 +3,9 @@ import path from "path" import { fileURLToPath } from "url" import { createElement } from "react" -import type { HistoryItem } from "@roo-code/types" +import pWaitFor from "p-wait-for" +import type { HistoryItem } from "@roo-code/types" import { setLogger } from "@roo-code/vscode-shim" import { @@ -35,6 +36,17 @@ import { runStdinStreamMode } from "./stdin-stream.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const ROO_MODEL_WARMUP_TIMEOUT_MS = 10_000 const SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000 +const STREAM_RESUME_WAIT_TIMEOUT_MS = 2_000 + +async function bootstrapResumeForStdinStream(host: ExtensionHost, sessionId: string): Promise { + host.sendToExtension({ type: "showTaskWithId", text: sessionId }) + + // Best-effort wait so early stdin "message" commands can target the resumed task. + await pWaitFor(() => host.client.hasActiveTask() || host.isWaitingForInput(), { + interval: 25, + timeout: STREAM_RESUME_WAIT_TIMEOUT_MS, + }).catch(() => undefined) +} function normalizeError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) @@ -185,10 +197,21 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption (settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions) const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false + const rawConsecutiveMistakeLimit = + flagOptions.consecutiveMistakeLimit ?? settings.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit + const effectiveConsecutiveMistakeLimit = Number(rawConsecutiveMistakeLimit) + + if (!Number.isInteger(effectiveConsecutiveMistakeLimit) || effectiveConsecutiveMistakeLimit < 0) { + console.error( + `[CLI] Error: Invalid consecutive mistake limit: ${rawConsecutiveMistakeLimit}; must be a non-negative integer`, + ) + process.exit(1) + } const extensionHostOptions: ExtensionHostOptions = { mode: effectiveMode, reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort, + consecutiveMistakeLimit: effectiveConsecutiveMistakeLimit, user: null, provider: effectiveProvider, model: effectiveModel, @@ -336,12 +359,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.exit(1) } - if (flagOptions.stdinPromptStream && isResumeRequested) { - console.error("[CLI] Error: cannot use --session-id/--continue with --stdin-prompt-stream") - console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") - process.exit(1) - } - const useStdinPromptStream = flagOptions.stdinPromptStream if (!isTuiEnabled) { @@ -445,6 +462,27 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption keepAliveInterval = undefined } + const flushStdout = async () => { + try { + if (!process.stdout.writable || process.stdout.destroyed) { + return + } + + await new Promise((resolve, reject) => { + process.stdout.write("", (error?: Error | null) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + } catch { + // Best effort: shutdown should proceed even if stdout flush fails. + } + } + const ensureKeepAliveInterval = () => { if (!signalOnlyExit || keepAliveInterval) { return @@ -521,6 +559,10 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() process.exit(exitCode) } @@ -551,6 +593,25 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events") } + if (isResumeRequested) { + const resolvedSessionId = + requestedSessionId || + getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath) + + if (requestedSessionId && taskHistorySnapshot.length > 0) { + const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId) + if (!hasRequestedTask) { + throw new Error(`Session not found in task history: ${requestedSessionId}`) + } + } + + if (!resolvedSessionId) { + throw new Error("No previous tasks found to continue in this workspace.") + } + + await bootstrapResumeForStdinStream(host, resolvedSessionId) + } + await runStdinStreamMode({ host, jsonEmitter, @@ -582,6 +643,10 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() if (signalOnlyExit) { await parkUntilSignal("Task loop completed") @@ -595,6 +660,10 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } catch (error) { emitRuntimeError(normalizeError(error)) await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() if (signalOnlyExit) { await parkUntilSignal("Task loop failed") diff --git a/apps/cli/src/commands/cli/stdin-stream.ts b/apps/cli/src/commands/cli/stdin-stream.ts index 3d74dd126e0..aaaf11f0c10 100644 --- a/apps/cli/src/commands/cli/stdin-stream.ts +++ b/apps/cli/src/commands/cli/stdin-stream.ts @@ -186,6 +186,10 @@ function isCancellationLikeError(error: unknown): boolean { const RESUME_ASKS = new Set(["resume_task", "resume_completed_task"]) const CANCEL_RECOVERY_WAIT_TIMEOUT_MS = 8_000 const CANCEL_RECOVERY_POLL_INTERVAL_MS = 100 +const STDIN_EOF_RESUME_WAIT_TIMEOUT_MS = 2_000 +const STDIN_EOF_POLL_INTERVAL_MS = 100 +const STDIN_EOF_IDLE_ASKS = new Set(["completion_result", "resume_completed_task"]) +const STDIN_EOF_IDLE_STABLE_POLLS = 2 function isResumableState(host: ExtensionHost): boolean { const agentState = host.client.getAgentState() @@ -208,6 +212,69 @@ async function waitForPostCancelRecovery(host: ExtensionHost): Promise { } } +async function waitForTaskProgressAfterStdinClosed( + host: ExtensionHost, + getQueueState: () => { hasSeenQueueState: boolean; queueDepth: number }, +): Promise { + while (host.client.hasActiveTask()) { + if (!host.isWaitingForInput()) { + await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS)) + continue + } + + const deadline = Date.now() + STDIN_EOF_RESUME_WAIT_TIMEOUT_MS + + while (Date.now() < deadline) { + if (!host.client.hasActiveTask() || !host.isWaitingForInput()) { + break + } + + await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS)) + } + + if (host.client.hasActiveTask() && host.isWaitingForInput()) { + const currentAsk = host.client.getCurrentAsk() + const { hasSeenQueueState, queueDepth } = getQueueState() + + // EOF is allowed when the task has reached an idle completion boundary and + // there is no queued user input waiting to be processed. + if ( + hasSeenQueueState && + queueDepth === 0 && + typeof currentAsk === "string" && + STDIN_EOF_IDLE_ASKS.has(currentAsk) + ) { + let isStable = true + for (let i = 1; i < STDIN_EOF_IDLE_STABLE_POLLS; i++) { + await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS)) + + if (!host.client.hasActiveTask() || !host.isWaitingForInput()) { + isStable = false + break + } + + const nextAsk = host.client.getCurrentAsk() + const nextQueueState = getQueueState() + if ( + nextAsk !== currentAsk || + !nextQueueState.hasSeenQueueState || + nextQueueState.queueDepth !== 0 + ) { + isStable = false + break + } + } + + if (isStable) { + return + } + } + + throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`) + } + } +} + export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }: StdinStreamModeOptions) { let hasReceivedStdinCommand = false let shouldShutdown = false @@ -633,13 +700,15 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId host.client.cancelTask() } - if (!shouldShutdown && host.client.hasActiveTask() && host.isWaitingForInput()) { - const currentAsk = host.client.getCurrentAsk() - throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`) - } - - if (!shouldShutdown && activeTaskPromise) { - await activeTaskPromise + if (!shouldShutdown) { + if (activeTaskPromise) { + await activeTaskPromise + } else if (host.client.hasActiveTask()) { + await waitForTaskProgressAfterStdinClosed(host, () => ({ + hasSeenQueueState, + queueDepth: lastQueueDepth, + })) + } } } finally { offClientError() diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 757339be1ab..e11953c357a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -50,6 +50,11 @@ program "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", DEFAULT_FLAGS.reasoningEffort, ) + .option( + "--consecutive-mistake-limit ", + "Consecutive error/repetition limit before guidance prompt (0 disables the limit)", + (value) => Number.parseInt(value, 10), + ) .option("--exit-on-error", "Exit on API request errors instead of retrying", false) .option("--ephemeral", "Run without persisting state (uses temporary storage)", false) .option("--oneshot", "Exit upon task completion", false) diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts index 30f1dbe8ecb..f19b5c3a253 100644 --- a/apps/cli/src/lib/storage/__tests__/settings.test.ts +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -105,6 +105,7 @@ describe("Settings Storage", () => { provider: "anthropic" as const, model: "claude-opus-4.6", reasoningEffort: "medium" as const, + consecutiveMistakeLimit: 5, }) const savedData = await fs.readFile(expectedSettingsFile, "utf-8") @@ -114,6 +115,7 @@ describe("Settings Storage", () => { expect(settings.provider).toBe("anthropic") expect(settings.model).toBe("claude-opus-4.6") expect(settings.reasoningEffort).toBe("medium") + expect(settings.consecutiveMistakeLimit).toBe(5) }) it("should create config directory if it doesn't exist", async () => { @@ -168,6 +170,7 @@ describe("Settings Storage", () => { provider: "openai-native" as const, model: "gpt-4o", reasoningEffort: "low" as const, + consecutiveMistakeLimit: 7, } await saveSettings(defaultSettings) @@ -177,6 +180,14 @@ describe("Settings Storage", () => { expect(loaded.provider).toBe("openai-native") expect(loaded.model).toBe("gpt-4o") expect(loaded.reasoningEffort).toBe("low") + expect(loaded.consecutiveMistakeLimit).toBe(7) + }) + + it("should support consecutiveMistakeLimit setting", async () => { + await saveSettings({ consecutiveMistakeLimit: 0 }) + const loaded = await loadSettings() + + expect(loaded.consecutiveMistakeLimit).toBe(0) }) it("should support requireApproval setting", async () => { @@ -218,6 +229,7 @@ describe("Settings Storage", () => { provider: "anthropic" as const, model: "claude-sonnet-4-20250514", reasoningEffort: "high" as const, + consecutiveMistakeLimit: 9, requireApproval: true, oneshot: true, } @@ -229,6 +241,7 @@ describe("Settings Storage", () => { expect(loaded.provider).toBe("anthropic") expect(loaded.model).toBe("claude-sonnet-4-20250514") expect(loaded.reasoningEffort).toBe("high") + expect(loaded.consecutiveMistakeLimit).toBe(9) expect(loaded.requireApproval).toBe(true) expect(loaded.oneshot).toBe(true) }) diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 6c54348a9ca..b291b5f90e1 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -4,6 +4,7 @@ export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, model: "anthropic/claude-opus-4.6", + consecutiveMistakeLimit: 10, } export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 80b55cfeb2b..50a8bc9c45e 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -35,6 +35,7 @@ export type FlagOptions = { model?: string mode?: string reasoningEffort?: ReasoningEffortFlagOptions + consecutiveMistakeLimit?: number ephemeral: boolean oneshot: boolean outputFormat?: OutputFormat @@ -61,6 +62,8 @@ export interface CliSettings { model?: string /** Default reasoning effort level */ reasoningEffort?: ReasoningEffortFlagOptions + /** Default consecutive error/repetition limit before guidance prompts */ + consecutiveMistakeLimit?: number /** Require manual approval for tools/commands/browser/MCP actions */ requireApproval?: boolean /** @deprecated Legacy inverse setting kept for backward compatibility */