fix(acp): forward provider retry status to clients (#50752)

This commit is contained in:
Shoubhit Dash
2026-09-23 04:11:23 +05:30
committed by GitHub
parent 5c53cfc342
commit 38c320ea4c
3 changed files with 67 additions and 3 deletions
+25 -1
View File
@@ -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<string, ToolState>()
const retries = new Map<string, RetryStatus>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
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: {
@@ -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<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
const scheduled = await acp.waitForNotification<SessionNotification>(
"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)
})
+7 -2
View File
@@ -70,7 +70,10 @@ description: Verifier compatibility skill.
`
export async function createAcpFixture(
options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise<string> } = {},
options: {
readonly skill?: string
readonly respond?: (request: unknown) => string | Response | Promise<string | Response>
} = {},
) {
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" },
})
},