Skip to content
Closed
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
3 changes: 3 additions & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NamedError } from "@opencode-ai/core/util/error"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
Expand Down Expand Up @@ -190,6 +191,8 @@ export function policy(opts: {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
// ACP cannot present the interactive retry action; return the provider error instead of silently waiting.
if (retry.action && Flag.OPENCODE_CLIENT === "acp") return Cause.done(meta.attempt)
if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
Expand Down
117 changes: 117 additions & 0 deletions packages/opencode/test/cli/acp/prompt-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect } from "bun:test"
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk } from "./acp-test-client"
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"

describe("opencode acp provider errors subprocess", () => {
for (const type of ["GoUsageLimitError", "FreeUsageLimitError"]) {
cliIt.live(
`returns ${type} after a completed tool without waiting for quota reset`,
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{
OPENCODE_CONFIG_CONTENT: JSON.stringify({
...verifierConfig(llm.url),
permission: { bash: "allow" },
}),
},
)
yield* initialize(acp)
const session = yield* newSession(acp, home)

// Go message and order: louiselm-ygtu, OpenCode 1.18.29 Session
// ses_f841b1a05ffeaLc8MroaF2snhv, proxy log.jsonl:20297 and
// ~/.local/share/opencode/log/opencode.log:6327 (2026-09-07).
// HTTP envelope/Retry-After reconstructed from console's zen handler;
// not captured on the wire. Free quota is a synthetic sibling case.
const message =
type === "GoUsageLimitError" ? "5-hour usage limit reached. Resets in 4hr 10min." : "Free usage exceeded"
yield* llm.tool("bash", { command: "pwd", description: "Print working directory" })
yield* llm.push({
type: "http-error",
status: 429,
headers: { "retry-after": "15000" },
body: {
type: "error",
error: { type, message },
metadata: { workspace: "wrk_test", limitName: "5-hour" },
},
})

const response = yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "Print the working directory." }],
})
expect(response.result).toBeUndefined()
expect(response.error).toMatchObject({ code: -32603, message: expect.stringContaining(message) })
expect(yield* llm.pending).toBe(0)

yield* llm.text("quota restored")
const next = expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "Try again." }],
}),
)
expect(next.stopReason).toBe("end_turn")
}),
60_000,
)
}

for (const status of [429, 503]) {
cliIt.live(
`still retries transient HTTP ${status} errors`,
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
yield* llm.push({
type: "http-error",
status,
headers: { "retry-after": "0" },
body: { error: { type: "RateLimitError", message: "Try again later" } },
})
yield* llm.text("recovered")

const response = expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "Hello." }],
}),
)
expect(response.stopReason).toBe("end_turn")
expect(yield* llm.pending).toBe(0)
}),
60_000,
)
}

cliIt.live(
"keeps interactive quota retries for the default CLI client",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push({
type: "http-error",
status: 429,
headers: { "retry-after": "0" },
body: { error: { type: "GoUsageLimitError", message: "Quota exceeded" } },
})
yield* llm.text("recovered after quota reset")

const result = yield* opencode.run("Hello.", { format: "json" })
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("recovered after quota reset")
expect(yield* llm.pending).toBe(0)
}),
60_000,
)
})
2 changes: 2 additions & 0 deletions packages/opencode/test/lib/llm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type HttpError = {
type: "http-error"
status: number
body: unknown
headers?: Record<string, string>
}

export type Item = Sse | HttpError
Expand Down Expand Up @@ -446,6 +447,7 @@ function fail(item: HttpError) {
return HttpServerResponse.text(JSON.stringify(item.body), {
status: item.status,
contentType: "application/json",
headers: item.headers,
})
}

Expand Down
Loading