diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 284c0f0ade41..5d70a3054aa1 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -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" @@ -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) diff --git a/packages/opencode/test/cli/acp/prompt-error.test.ts b/packages/opencode/test/cli/acp/prompt-error.test.ts new file mode 100644 index 000000000000..ef2f99b70c99 --- /dev/null +++ b/packages/opencode/test/cli/acp/prompt-error.test.ts @@ -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("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("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("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, + ) +}) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280f5..6288e66de808 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -46,6 +46,7 @@ type HttpError = { type: "http-error" status: number body: unknown + headers?: Record } export type Item = Sse | HttpError @@ -446,6 +447,7 @@ function fail(item: HttpError) { return HttpServerResponse.text(JSON.stringify(item.body), { status: item.status, contentType: "application/json", + headers: item.headers, }) }