mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
CLI: improve stream recovery and add configurable consecutive mistake limit (#11775)
* cli: improve stream error recovery and add mistake-limit flag * More progress
This commit is contained in:
parent
96d77fd2c8
commit
2f4ce367c4
14 changed files with 398 additions and 22 deletions
|
|
@ -185,6 +185,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
|
|||
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
|
||||
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
|
||||
| `--consecutive-mistake-limit <n>` | 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 <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
|
||||
|
|
|
|||
35
apps/cli/src/agent/__tests__/events.test.ts
Normal file
35
apps/cli/src/agent/__tests__/events.test.ts
Normal file
|
|
@ -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>): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.consecutiveMistakeLimit).toBe(DEFAULT_FLAGS.consecutiveMistakeLimit)
|
||||
})
|
||||
|
||||
it("should set consecutiveMistakeLimit from options", () => {
|
||||
const host = createTestHost({ consecutiveMistakeLimit: 8 })
|
||||
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.consecutiveMistakeLimit).toBe(8)
|
||||
})
|
||||
|
||||
it("should enable auto-approval in non-interactive mode", () => {
|
||||
const host = createTestHost({ nonInteractive: true })
|
||||
|
||||
|
|
|
|||
129
apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts
Normal file
129
apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts
Normal file
|
|
@ -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<string, unknown>[] } {
|
||||
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<string, unknown>)
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export class JsonEventEmitter {
|
|||
private stdout: NodeJS.WriteStream
|
||||
private events: JsonEvent[] = []
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private pendingWrites = new Set<Promise<void>>()
|
||||
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<void>((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<void> {
|
||||
while (this.pendingWrites.size > 0) {
|
||||
await Promise.all([...this.pendingWrites])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void>((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")
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function waitForTaskProgressAfterStdinClosed(
|
||||
host: ExtensionHost,
|
||||
getQueueState: () => { hasSeenQueueState: boolean; queueDepth: number },
|
||||
): Promise<void> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ program
|
|||
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
|
||||
DEFAULT_FLAGS.reasoningEffort,
|
||||
)
|
||||
.option(
|
||||
"--consecutive-mistake-limit <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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue