diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 5943e6cc84b..f4354d62082 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -4,6 +4,7 @@ import type { OpenCodeClient, SessionMessageAssistant, SessionMessageInfo, + SessionStructuredError, } from "@opencode/client/promise" import type { ACPConnection } from "./connection" import { partsToContentChunks, type ReplayPart } from "./content" @@ -40,6 +41,13 @@ export type TurnStart = export const ChildSessionUpdatesCapability = "opencode/child-session-updates" export const ChildSessionUpdateMethod = "opencode/session/child_update" +const RetryMeta = "opencode/retry" + +type RetryStatus = { + readonly attempt: number + readonly nextRetryAt: string + readonly error: SessionStructuredError +} type ChildSessionUpdateBase = { readonly rootSessionId: string @@ -97,6 +105,7 @@ export async function streamTurn(input: { let finish: SessionMessageAssistant["finish"] let executionError: { readonly type: string; readonly message: string } | undefined const tools = new Map() + const retries = new Map() const children = new Map() const openChildren = new Set() let handedOff = false @@ -188,6 +197,18 @@ export async function streamTurn(input: { if (event.type === "session.step.started") { if (!child) assistantMessageID = event.data.assistantMessageID + if (retries.delete(eventSessionID)) + await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }) + continue + } + if (event.type === "session.retry.scheduled") { + const retry = { + attempt: event.data.attempt, + nextRetryAt: new Date(event.data.at).toISOString(), + error: event.data.error, + } + retries.set(eventSessionID, retry) + await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }) continue } if (event.type === "session.text.delta") { @@ -381,6 +402,7 @@ export async function streamTurn(input: { terminal, control.cancelled, finish, + retries.get(input.sessionID), ) } catch (error) { streamController.abort() @@ -560,6 +582,7 @@ function response( terminal: "succeeded" | "failed" | "interrupted", cancelled: boolean, finish: SessionMessageAssistant["finish"], + retry?: RetryStatus, ): PromptResponse { const error = assistant?.error ?? executionError if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError() @@ -582,7 +605,8 @@ function response( } : undefined const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type }) - return { stopReason, ...(usage ? { usage } : {}), _meta: {} } + // Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here. + return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} } } function resolveStopReason(input: { diff --git a/packages/cli/test/acp/retry.subprocess.test.ts b/packages/cli/test/acp/retry.subprocess.test.ts new file mode 100644 index 00000000000..f3bdf65a7bb --- /dev/null +++ b/packages/cli/test/acp/retry.subprocess.test.ts @@ -0,0 +1,35 @@ +import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk" +import { describe, expect, test } from "bun:test" +import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess" + +describe("acp retry subprocess", () => { + test("reports provider retries during the turn and on cancellation", async () => { + await using fixture = await createAcpFixture({ + respond: () => + Response.json( + { error: { message: "rate-limited upstream", type: "rate_limit_error" } }, + // A long backoff keeps the retry pending until the cancel lands. + { status: 429, headers: { "retry-after": "30" } }, + ), + }) + const acp = fixture.spawn() + await initialize(acp) + const session = await newSession(acp, fixture.home) + + const prompt = acp.send("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }) + const scheduled = await acp.waitForNotification( + "session/update", + (params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update", + ) + const retry = scheduled.params.update._meta?.["opencode/retry"] + expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } }) + + await acp.notify("session/cancel", { sessionId: session.sessionId }) + const response = expectOk(await prompt.response) + expect(response.stopReason).toBe("cancelled") + expect(response._meta?.["opencode/retry"]).toEqual(retry) + }, 60_000) +}) diff --git a/packages/cli/test/acp/subprocess.ts b/packages/cli/test/acp/subprocess.ts index 1032c541767..880e28a30a6 100644 --- a/packages/cli/test/acp/subprocess.ts +++ b/packages/cli/test/acp/subprocess.ts @@ -70,7 +70,10 @@ description: Verifier compatibility skill. ` export async function createAcpFixture( - options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise } = {}, + options: { + readonly skill?: string + readonly respond?: (request: unknown) => string | Response | Promise + } = {}, ) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-")) const home = path.join(root, "workspace") @@ -93,7 +96,9 @@ export async function createAcpFixture( } const body: unknown = await request.json().catch(() => undefined) requests.push(body) - return new Response(completion(await (options.respond?.(body) ?? "accepted")), { + const result = await (options.respond?.(body) ?? "accepted") + if (result instanceof Response) return result + return new Response(completion(result), { headers: { "content-type": "text/event-stream" }, }) },