Skip to content

Commit 9a0142c

Browse files
committed
Merge branch 'main' into fix/forward-configuration-in-stdin-stream
2 parents 01783ed + 81047a6 commit 9a0142c

19 files changed

Lines changed: 592 additions & 106 deletions

apps/cli/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
185185
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
186186
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
187187
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
188+
| `--consecutive-mistake-limit <n>` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` |
188189
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
189190
| `--oneshot` | Exit upon task completion | `false` |
190191
| `--output-format <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ClineMessage } from "@roo-code/types"
2+
3+
import { detectAgentState } from "../agent-state.js"
4+
import { taskCompleted } from "../events.js"
5+
6+
function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
7+
return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides }
8+
}
9+
10+
describe("taskCompleted", () => {
11+
it("returns true for completion_result", () => {
12+
const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })])
13+
const current = detectAgentState([createMessage({ type: "ask", ask: "completion_result", partial: false })])
14+
15+
expect(taskCompleted(previous, current)).toBe(true)
16+
})
17+
18+
it("returns true for resume_completed_task", () => {
19+
const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })])
20+
const current = detectAgentState([createMessage({ type: "ask", ask: "resume_completed_task", partial: false })])
21+
22+
expect(taskCompleted(previous, current)).toBe(true)
23+
})
24+
25+
it("returns false for recoverable idle asks", () => {
26+
const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })])
27+
const mistakeLimit = detectAgentState([
28+
createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false }),
29+
])
30+
const apiFailed = detectAgentState([createMessage({ type: "ask", ask: "api_req_failed", partial: false })])
31+
32+
expect(taskCompleted(previous, mistakeLimit)).toBe(false)
33+
expect(taskCompleted(previous, apiFailed)).toBe(false)
34+
})
35+
})

apps/cli/src/agent/__tests__/extension-host.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import fs from "fs"
55

66
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
77

8+
import { DEFAULT_FLAGS } from "@/types/index.js"
9+
810
import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
911
import { ExtensionClient } from "../extension-client.js"
1012
import { AgentLoopState } from "../agent-state.js"
@@ -593,6 +595,20 @@ describe("ExtensionHost", () => {
593595
expect(initialSettings.mode).toBe("architect")
594596
})
595597

598+
it("should use default consecutiveMistakeLimit when not provided", () => {
599+
const host = createTestHost()
600+
601+
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
602+
expect(initialSettings.consecutiveMistakeLimit).toBe(DEFAULT_FLAGS.consecutiveMistakeLimit)
603+
})
604+
605+
it("should set consecutiveMistakeLimit from options", () => {
606+
const host = createTestHost({ consecutiveMistakeLimit: 8 })
607+
608+
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
609+
expect(initialSettings.consecutiveMistakeLimit).toBe(8)
610+
})
611+
596612
it("should enable auto-approval in non-interactive mode", () => {
597613
const host = createTestHost({ nonInteractive: true })
598614

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { ClineMessage } from "@roo-code/types"
2+
import { Writable } from "stream"
3+
4+
import type { TaskCompletedEvent } from "../events.js"
5+
import { JsonEventEmitter } from "../json-event-emitter.js"
6+
import { AgentLoopState, type AgentStateInfo } from "../agent-state.js"
7+
8+
function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record<string, unknown>[] } {
9+
const chunks: string[] = []
10+
11+
const writable = new Writable({
12+
write(chunk, _encoding, callback) {
13+
chunks.push(chunk.toString())
14+
callback()
15+
},
16+
}) as unknown as NodeJS.WriteStream
17+
18+
const lines = () =>
19+
chunks
20+
.join("")
21+
.split("\n")
22+
.filter((line) => line.length > 0)
23+
.map((line) => JSON.parse(line) as Record<string, unknown>)
24+
25+
return { stdout: writable, lines }
26+
}
27+
28+
function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void {
29+
;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage(
30+
message,
31+
false,
32+
)
33+
}
34+
35+
function emitTaskCompleted(emitter: JsonEventEmitter, event: TaskCompletedEvent): void {
36+
;(emitter as unknown as { handleTaskCompleted: (taskCompleted: TaskCompletedEvent) => void }).handleTaskCompleted(
37+
event,
38+
)
39+
}
40+
41+
function createAskCompletionMessage(ts: number, text = ""): ClineMessage {
42+
return {
43+
ts,
44+
type: "ask",
45+
ask: "completion_result",
46+
partial: false,
47+
text,
48+
} as ClineMessage
49+
}
50+
51+
function createCompletedStateInfo(message: ClineMessage): AgentStateInfo {
52+
return {
53+
state: AgentLoopState.IDLE,
54+
isWaitingForInput: true,
55+
isRunning: false,
56+
isStreaming: false,
57+
currentAsk: "completion_result",
58+
requiredAction: "start_task",
59+
lastMessageTs: message.ts,
60+
lastMessage: message,
61+
description: "Task completed successfully. You can provide feedback or start a new task.",
62+
}
63+
}
64+
65+
describe("JsonEventEmitter result emission", () => {
66+
it("prefers current completion message content over stale cached completion text", () => {
67+
const { stdout, lines } = createMockStdout()
68+
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
69+
70+
emitMessage(emitter, {
71+
ts: 100,
72+
type: "say",
73+
say: "completion_result",
74+
partial: false,
75+
text: "FIRST",
76+
} as ClineMessage)
77+
78+
const firstCompletionMessage = createAskCompletionMessage(101, "")
79+
emitTaskCompleted(emitter, {
80+
success: true,
81+
stateInfo: createCompletedStateInfo(firstCompletionMessage),
82+
message: firstCompletionMessage,
83+
})
84+
85+
const secondCompletionMessage = createAskCompletionMessage(102, "SECOND")
86+
emitTaskCompleted(emitter, {
87+
success: true,
88+
stateInfo: createCompletedStateInfo(secondCompletionMessage),
89+
message: secondCompletionMessage,
90+
})
91+
92+
const output = lines().filter((line) => line.type === "result")
93+
expect(output).toHaveLength(2)
94+
expect(output[0]?.content).toBe("FIRST")
95+
expect(output[1]?.content).toBe("SECOND")
96+
})
97+
98+
it("clears cached completion text after each result emission", () => {
99+
const { stdout, lines } = createMockStdout()
100+
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
101+
102+
emitMessage(emitter, {
103+
ts: 200,
104+
type: "say",
105+
say: "completion_result",
106+
partial: false,
107+
text: "FIRST",
108+
} as ClineMessage)
109+
110+
const firstCompletionMessage = createAskCompletionMessage(201, "")
111+
emitTaskCompleted(emitter, {
112+
success: true,
113+
stateInfo: createCompletedStateInfo(firstCompletionMessage),
114+
message: firstCompletionMessage,
115+
})
116+
117+
const secondCompletionMessage = createAskCompletionMessage(202, "")
118+
emitTaskCompleted(emitter, {
119+
success: true,
120+
stateInfo: createCompletedStateInfo(secondCompletionMessage),
121+
message: secondCompletionMessage,
122+
})
123+
124+
const output = lines().filter((line) => line.type === "result")
125+
expect(output).toHaveLength(2)
126+
expect(output[0]?.content).toBe("FIRST")
127+
expect(output[1]).not.toHaveProperty("content")
128+
})
129+
})

apps/cli/src/agent/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo
260260
* Helper to determine if task completed.
261261
*/
262262
export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
263-
const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"]
263+
const completionAsks = ["completion_result", "resume_completed_task"]
264264
const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
265265
const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
266266
return wasNotComplete && isNowComplete

apps/cli/src/agent/extension-host.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type {
2626
import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
2727
import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli"
2828

29-
import type { SupportedProvider } from "@/types/index.js"
29+
import { DEFAULT_FLAGS, type SupportedProvider } from "@/types/index.js"
3030
import type { User } from "@/lib/sdk/index.js"
3131
import { getProviderSettings } from "@/lib/utils/provider.js"
3232
import { createEphemeralStorageDir } from "@/lib/storage/index.js"
@@ -66,6 +66,7 @@ const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot()
6666
export interface ExtensionHostOptions {
6767
mode: string
6868
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
69+
consecutiveMistakeLimit?: number
6970
user: User | null
7071
provider: SupportedProvider
7172
apiKey?: string
@@ -219,6 +220,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
219220
// Populate initial settings.
220221
const baseSettings: RooCodeSettings = {
221222
mode: this.options.mode,
223+
consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit,
222224
commandExecutionTimeout: 30,
223225
enableCheckpoints: false,
224226
experiments: {

apps/cli/src/agent/json-event-emitter.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export class JsonEventEmitter {
9696
private stdout: NodeJS.WriteStream
9797
private events: JsonEvent[] = []
9898
private unsubscribers: (() => void)[] = []
99+
private pendingWrites = new Set<Promise<void>>()
99100
private lastCost: JsonEventCost | undefined
100101
private requestIdProvider: () => string | undefined
101102
private schemaVersion: number
@@ -598,8 +599,9 @@ export class JsonEventEmitter {
598599
* Handle task completion and emit result event.
599600
*/
600601
private handleTaskCompleted(event: TaskCompletedEvent): void {
601-
// Use tracked completion result content, falling back to event message
602-
const resultContent = this.completionResultContent || event.message?.text || this.lastAssistantText
602+
// Prefer the completion payload from the current event. If it is empty,
603+
// fall back to the most recent tracked completion text, then assistant text.
604+
const resultContent = event.message?.text || this.completionResultContent || this.lastAssistantText
603605

604606
this.emitEvent({
605607
type: "result",
@@ -610,6 +612,10 @@ export class JsonEventEmitter {
610612
cost: this.lastCost,
611613
})
612614

615+
// Prevent stale completion content from leaking into later turns.
616+
this.completionResultContent = undefined
617+
this.lastAssistantText = undefined
618+
613619
// For "json" mode, output the final accumulated result
614620
if (this.mode === "json") {
615621
this.outputFinalResult(event.success, resultContent)
@@ -647,7 +653,7 @@ export class JsonEventEmitter {
647653
* Output a single JSON line (NDJSON format).
648654
*/
649655
private outputLine(data: unknown): void {
650-
this.stdout.write(JSON.stringify(data) + "\n")
656+
this.writeToStdout(JSON.stringify(data) + "\n")
651657
}
652658

653659
/**
@@ -662,7 +668,31 @@ export class JsonEventEmitter {
662668
events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself
663669
}
664670

665-
this.stdout.write(JSON.stringify(output, null, 2) + "\n")
671+
this.writeToStdout(JSON.stringify(output, null, 2) + "\n")
672+
}
673+
674+
private writeToStdout(content: string): void {
675+
const writePromise = new Promise<void>((resolve, reject) => {
676+
this.stdout.write(content, (error?: Error | null) => {
677+
if (error) {
678+
reject(error)
679+
return
680+
}
681+
resolve()
682+
})
683+
})
684+
685+
this.pendingWrites.add(writePromise)
686+
687+
void writePromise.finally(() => {
688+
this.pendingWrites.delete(writePromise)
689+
})
690+
}
691+
692+
async flush(): Promise<void> {
693+
while (this.pendingWrites.size > 0) {
694+
await Promise.all([...this.pendingWrites])
695+
}
666696
}
667697

668698
/**

apps/cli/src/agent/message-processor.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,16 @@ export class MessageProcessor {
343343

344344
// Task completed
345345
if (taskCompleted(previousState, currentState)) {
346+
const completedSuccessfully =
347+
currentState.currentAsk === "completion_result" || currentState.currentAsk === "resume_completed_task"
348+
346349
if (this.options.debug) {
347350
debugLog("[MessageProcessor] EMIT taskCompleted", {
348-
success: currentState.currentAsk === "completion_result",
351+
success: completedSuccessfully,
349352
})
350353
}
351354
const completedEvent: TaskCompletedEvent = {
352-
success: currentState.currentAsk === "completion_result",
355+
success: completedSuccessfully,
353356
stateInfo: currentState,
354357
message: currentState.lastMessage,
355358
}

0 commit comments

Comments
 (0)