Skip to content
Open
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
26 changes: 26 additions & 0 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,32 @@ describe("OpenRouterHandler", () => {
)
})

it.each([
{
modelId: "anthropic/claude-sonnet-4",
headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" },
},
{ modelId: "openai/gpt-4o", headers: undefined },
])("passes the abort signal with only the appropriate headers for $modelId", async ({ modelId, headers }) => {
const handler = new OpenRouterHandler({ ...mockOptions, openRouterModelId: modelId })
const mockCreate = vitest.fn().mockResolvedValue(asyncStreamFrom([]))
Object.defineProperty(OpenAI.prototype, "chat", {
configurable: true,
value: { completions: { create: mockCreate } },
})
const controller = new AbortController()

await collectStream(
handler.createMessage("test", [], { taskId: "test-task", abortSignal: controller.signal }),
)

expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: modelId }), {
signal: controller.signal,
...(headers ? { headers } : {}),
})
expect(mockCreate.mock.calls[0][1].signal).toBe(controller.signal)
})

it("adds cache control for supported models", async () => {
const handler = new OpenRouterHandler(
makeApiHandlerOptions({
Expand Down
12 changes: 9 additions & 3 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,15 @@
}

// Add Anthropic beta header for fine-grained tool streaming when using Anthropic models
const requestOptions = modelId.startsWith("anthropic/")
? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }
: undefined
const requestOptions =
modelId.startsWith("anthropic/") || metadata?.abortSignal

Check warning on line 343 in src/api/providers/openrouter.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openrouter.ts:343: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
? {
...(modelId.startsWith("anthropic/")
? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }
: {}),
...(metadata?.abortSignal ? { signal: metadata.abortSignal } : {}),
}
: undefined

let stream
try {
Expand Down
56 changes: 56 additions & 0 deletions src/core/task/ReasoningLoopDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const MIN_PATTERN_LENGTH = 80
const MAX_PATTERN_LENGTH = 2_048
const REQUIRED_REPETITIONS = 5
const CHECK_INTERVAL = 64
const BUFFER_LENGTH = MAX_PATTERN_LENGTH * (REQUIRED_REPETITIONS + 1)

/** Detects sustained, exact repetition in streamed model reasoning. */
export class ReasoningLoopDetector {
private buffer = ""

Check warning on line 9 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:9: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
private uncheckedCharacters = 0

add(text: string): boolean {
if (!text) {

Check warning on line 13 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:13: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return false

Check warning on line 14 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:14: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}

this.buffer = (this.buffer + text).slice(-BUFFER_LENGTH)

Check warning on line 17 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:17: Survived MethodExpression mutant (replacement: this.buffer + text). See the job summary for the complete list and resolution guidance.
this.uncheckedCharacters += text.length

if (this.uncheckedCharacters < CHECK_INTERVAL) {

Check warning on line 20 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:20: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return false

Check warning on line 21 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:21: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}
this.uncheckedCharacters = 0

const maxPatternLength = Math.min(MAX_PATTERN_LENGTH, Math.floor(this.buffer.length / REQUIRED_REPETITIONS))

Check warning on line 25 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:25: 2 mutation test gaps; example: Survived MethodExpression mutant (replacement: Math.max(MAX_PATTERN_LENGTH, Math.floor(this.buffer.length / REQUIRED_REPETITIONS))). See the job summary for the complete list and resolution guidance.
for (let patternLength = MIN_PATTERN_LENGTH; patternLength <= maxPatternLength; patternLength++) {
const patternStart = this.buffer.length - patternLength
const pattern = this.buffer.slice(patternStart)
let repeats = true

for (let repetition = 2; repetition <= REQUIRED_REPETITIONS; repetition++) {
const start = this.buffer.length - patternLength * repetition
if (this.buffer.slice(start, start + patternLength) !== pattern) {
repeats = false
break
}
}

if (repeats) {
return true
}
}

return false
}
}

export class RepetitiveReasoningError extends Error {
constructor() {
super(
"Repetitive reasoning detected. The model appears to be stuck in a loop, so Zoo Code stopped the request. Retry with less context or a different model.",
)
this.name = "RepetitiveReasoningError"
Object.setPrototypeOf(this, RepetitiveReasoningError.prototype)
}
}
16 changes: 13 additions & 3 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { AskIgnoredError } from "./AskIgnoredError"
import { RateLimitClock, createRateLimitClock } from "./RateLimitClock"
import { ReasoningLoopDetector, RepetitiveReasoningError } from "./ReasoningLoopDetector"

import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
Expand Down Expand Up @@ -4145,6 +4146,7 @@
})
let assistantMessage = ""
let reasoningMessage = ""
const reasoningLoopDetector = new ReasoningLoopDetector()
const pendingGroundingSources: GroundingSource[] = []
this.isStreaming = true

Expand Down Expand Up @@ -4181,6 +4183,11 @@
let item = await nextChunkWithAbort()
while (!item.done) {
const chunk = item.value
// Detect a loop before prefetching: the provider may stall on its next read.
if (chunk?.type === "reasoning" && reasoningLoopDetector.add(chunk.text)) {

Check warning on line 4187 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4187: 3 mutation test gaps; example: Survived LogicalOperator mutant (replacement: chunk?.type === "reasoning" || reasoningLoopDetector.add(chunk.text)). See the job summary for the complete list and resolution guidance.
this.cancelCurrentRequest()
throw new RepetitiveReasoningError()
}
item = await nextChunkWithAbort()
if (!chunk) {
// Sometimes chunk is undefined, no idea that can cause
Expand Down Expand Up @@ -4654,8 +4661,11 @@
// A cancel can land during abortStream after cancelReason was computed; setAbortReason keeps the first reason.
this.setAbortReason("user_cancelled")
await this.abortTask()
} else if (error instanceof OutputTokenLimitError) {
// Truncation repeats on an identical request, so never auto-retry it
} else if (
error instanceof OutputTokenLimitError ||
error instanceof RepetitiveReasoningError
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
// These failures repeat on an identical request, so never auto-retry them
// (even with auto-approval); let the user decide once.
const { response } = await this.ask("api_req_failed", rawErrorMessage)

Expand All @@ -4667,7 +4677,7 @@
stack.push({
userContent: currentUserContent,
includeFileDetails: false,
retryAttempt: 0,
retryAttempt: (currentItem.retryAttempt ?? 0) + 1,
})
continue
} else {
Expand Down
56 changes: 56 additions & 0 deletions src/core/task/__tests__/ReasoningLoopDetector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ReasoningLoopDetector } from "../ReasoningLoopDetector"

describe("ReasoningLoopDetector", () => {
it("detects the reported reasoning loop across arbitrary stream chunks", () => {
const detector = new ReasoningLoopDetector()
const cycle = "OK.\n\nHmm. Let me read them.\n\nOK.\n\nHmm. Let me just do it.\n\nLet me read the files.\n\n"
const output = `I will inspect the implementation first.\n${cycle.repeat(8)}`
let detected = false

for (let offset = 0; offset < output.length; offset += 17) {
detected ||= detector.add(output.slice(offset, offset + 17))
}

expect(detected).toBe(true)
})

it("does not flag ordinary long reasoning made of distinct lines", () => {
const detector = new ReasoningLoopDetector()
const reasoning = Array.from(
{ length: 40 },
(_, index) =>
`Step ${index}: inspect file ${index}, compare its behavior, and record the distinct result. OK.\n`,
).join("")

expect(detector.add(reasoning)).toBe(false)
})

it("does not flag a short phrase repeated a few times", () => {
const detector = new ReasoningLoopDetector()

expect(detector.add("Let me read the files.\n".repeat(10))).toBe(false)
})

// A cycle of `length` characters that is not periodic itself, so only its own length can match.
const cycleOf = (length: number) =>
Array.from({ length: 200 }, (_, index) => `w${index} `)
.join("")
.slice(0, length)

// MIN_PATTERN_LENGTH is 80 and REQUIRED_REPETITIONS is 5.
it.each([
{ length: 80, repetitions: 5, flagged: true },
{ length: 80, repetitions: 4, flagged: false },
{ length: 81, repetitions: 5, flagged: true },
{ length: 81, repetitions: 4, flagged: false },
{ length: 79, repetitions: 5, flagged: false },
// 9 repeats hold only 4.5 copies of the doubled 158-character cycle, which is still below the threshold.
{ length: 79, repetitions: 9, flagged: false },
// 10 repeats are 5 copies of the doubled cycle, so a short cycle is still caught once it persists.
{ length: 79, repetitions: 10, flagged: true },
])("flags a $length-character cycle repeated $repetitions times: $flagged", ({ length, repetitions, flagged }) => {
const detector = new ReasoningLoopDetector()

expect(detector.add(cycleOf(length).repeat(repetitions))).toBe(flagged)
})
})
95 changes: 92 additions & 3 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,23 +604,112 @@ describe("Cline", () => {
expect(askSpy).toHaveBeenCalledWith("api_req_failed", expect.stringContaining("Output token limit reached"))
})

it("retries from a fresh attempt count when the user confirms", async () => {
it("advances the retry count without duplicating the user message when the user confirms", async () => {
const task = await createAutoApprovedTask()
let retryHistory: ApiMessage[] | undefined
vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult)
const attemptApiRequestSpy = vi
.spyOn(task, "attemptApiRequest")
.mockImplementationOnce(() => truncatedStream())
.mockImplementationOnce(() => asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "done" }]))
.mockImplementationOnce(() => {
retryHistory = structuredClone(task.apiConversationHistory)
return asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "done" }])
})
.mockImplementation(() => {
throw new Error("stop after retry response")
})

await task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }])

expect(attemptApiRequestSpy.mock.calls.slice(0, 2).map(([retryAttempt]) => retryAttempt)).toEqual([0, 0])
expect(attemptApiRequestSpy.mock.calls.slice(0, 2).map(([retryAttempt]) => retryAttempt)).toEqual([0, 1])
expect(retryHistory?.filter((message) => message.role === "user")).toHaveLength(1)
})
})

describe("repetitive reasoning mid-stream", () => {
it.each(["noButtonClicked", "yesButtonClicked"] as const)(
"cancels before a stalled read, then handles %s",
async (response) => {
vi.spyOn(mockProvider, "getState").mockResolvedValue(
providerStateWith({ autoApprovalEnabled: true, requestDelaySeconds: 0 }),
)
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined)
vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo)
vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {})
let answerFailure!: (answer: TaskAskResult) => void
const failureResponse = new Promise<TaskAskResult>((resolve) => {
answerFailure = resolve
})
const askSpy = vi
.spyOn(task, "ask")
.mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult)
.mockReturnValueOnce(failureResponse)
const cancelSpy = vi.spyOn(task, "cancelCurrentRequest")
const controller = new AbortController()
let releaseRead!: () => void
const stalledRead = new Promise<void>((resolve) => {
releaseRead = resolve
})
let retryHistory: ApiMessage[] | undefined
const cycle =
"OK.\n\nHmm. Let me read them.\n\nOK.\n\nHmm. Let me just do it.\n\nLet me read the files.\n\n"
const attemptApiRequestSpy = vi
.spyOn(task, "attemptApiRequest")
.mockImplementationOnce(async function* () {
task.currentRequestAbortController = controller
yield { type: "reasoning", text: cycle.repeat(8) }
await stalledRead
})
.mockImplementationOnce(() => {
retryHistory = structuredClone(task.apiConversationHistory)
return asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "done" }])
})
.mockImplementation(() => {
throw new Error("stop after retry response")
})

const request = task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }])
try {
// The provider's next read and the user's retry decision are still unreleased.
await vi.waitFor(() => expect(controller.signal.aborted).toBe(true))
expect(cancelSpy).toHaveBeenCalledOnce()
expect(attemptApiRequestSpy).toHaveBeenCalledOnce()
await vi.waitFor(() =>
expect(askSpy).toHaveBeenCalledWith(
"api_req_failed",
expect.stringContaining("Repetitive reasoning detected"),
),
)
} finally {
releaseRead()
answerFailure({ response })
await request
}

expect(cancelSpy).toHaveBeenCalledOnce()
if (response === "yesButtonClicked") {
expect(attemptApiRequestSpy.mock.calls.slice(0, 2).map(([retryAttempt]) => retryAttempt)).toEqual([
0, 1,
])
expect(retryHistory?.filter((message) => message.role === "user")).toHaveLength(1)
expect(retryHistory?.[0]).toMatchObject({
role: "user",
content: expect.arrayContaining([{ type: "text", text: "long request" }]),
})
} else {
expect(attemptApiRequestSpy).toHaveBeenCalledOnce()
expect(askSpy).toHaveBeenCalledOnce()
}
},
)
})

describe("native tool-call request isolation", () => {
it("derives readiness only from a closed, idle, fully paired tool turn", () => {
const task = new Task({
Expand Down
Loading