Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline 4fa56a8a71 fix(opencode): expand retryable error patterns 2026-08-05 13:13:19 -05:00
Aiden Cline b76df6c359 test(opencode): cover serialized rate limits 2026-08-05 12:31:32 -05:00
Aiden Cline e3366180ae refactor(opencode): simplify retry error matching 2026-08-05 12:30:50 -05:00
6 changed files with 98 additions and 325 deletions
+53 -158
View File
@@ -1,4 +1,4 @@
import type { AgentSideConnection, SessionUpdate } from "@agentclientprotocol/sdk"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type {
Event,
EventMessagePartDelta,
@@ -29,13 +29,6 @@ type GlobalEventEnvelope = {
type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope>
}
type ChildSession = {
id: string
parentID: string
rootID: string
depth: number
title?: string
}
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
const subscription = new Subscription(input)
@@ -49,7 +42,6 @@ export class Subscription {
private readonly toolStarts = new Set<string>()
private readonly connectionWaiters = new Set<() => void>()
private readonly idleWaiters = new Map<string, Set<ReturnType<typeof signal>>>()
private readonly children = new Map<string, ChildSession>()
private readonly permission: ACPPermission.Handler
private connected = false
private started = false
@@ -100,30 +92,12 @@ export class Subscription {
async handle(event: Event) {
switch (event.type) {
case "session.created":
await this.registerChild(event.properties.info)
return
case "session.deleted":
this.children.delete(event.properties.sessionID)
return
case "session.status":
if (event.properties.status.type === "idle") this.idle(event.properties.sessionID)
return
case "permission.asked": {
const target = await this.resolveSession(event.properties.sessionID)
if (!target) return
this.permission.handle(event, {
sessionId: target.session.id,
cwd: target.session.cwd,
...(target.child
? {
toolCallPrefix: target.child.id,
titlePrefix: target.child.title,
}
: {}),
})
case "permission.asked":
this.permission.handle(event)
return
}
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
@@ -138,7 +112,7 @@ export class Subscription {
for (const part of message.parts) {
await this.recordFetchedPart(message.info.sessionID, message, part)
if (part.type === "tool") {
await this.handleToolPart(message.info.sessionID, message.info.sessionID, part, cwd ?? process.cwd())
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
continue
}
await this.replayContentPart(message, part)
@@ -216,13 +190,13 @@ export class Subscription {
private async handlePartUpdated(event: EventMessagePartUpdated) {
const part = event.properties.part
const sourceSessionId = part.sessionID || event.properties.sessionID
const target = await this.resolveSession(sourceSessionId)
if (!target) return
const sessionId = part.sessionID || event.properties.sessionID
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
if (!session) return
await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId: target.session.id,
sessionId: session.id,
messageId: part.messageID,
partId: part.id,
partType: part.type,
@@ -233,18 +207,18 @@ export class Subscription {
}),
)
if (part.type === "tool") {
await this.handleToolPart(target.session.id, sourceSessionId, part, target.session.cwd, target.child)
await this.handleToolPart(session.id, part, session.cwd)
}
}
private async handlePartDelta(event: EventMessagePartDelta) {
const props = event.properties
const target = await this.resolveSession(props.sessionID)
if (!target) return
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
if (!session) return
const known = await Effect.runPromise(
this.input.session.tryGetPartMetadata({
sessionId: target.session.id,
sessionId: session.id,
messageId: props.messageID,
partId: props.partID,
}),
@@ -252,18 +226,12 @@ export class Subscription {
const metadata =
known?.role && known.partType
? known
: await this.fetchPartMetadata(
props.sessionID,
target.session.id,
target.session.cwd,
props.messageID,
props.partID,
)
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
if (metadata?.role !== "assistant") return
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
await this.update(
target.session.id,
{
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_message_chunk",
messageId: props.messageID,
content: {
@@ -271,15 +239,14 @@ export class Subscription {
text: props.delta,
},
},
target.child,
)
})
return
}
if (metadata.partType === "reasoning" && props.field === "text") {
await this.update(
target.session.id,
{
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: props.messageID,
content: {
@@ -287,22 +254,15 @@ export class Subscription {
text: props.delta,
},
},
target.child,
)
})
}
}
private async fetchPartMetadata(
sourceSessionId: string,
targetSessionId: string,
cwd: string,
messageId: string,
partId: string,
) {
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
const message = await this.input.sdk.session
.message(
{
sessionID: sourceSessionId,
sessionID: sessionId,
messageID: messageId,
directory: cwd,
},
@@ -314,7 +274,7 @@ export class Subscription {
const part = message.parts.find((item) => item.id === partId)
if (!part) return
return await this.recordFetchedPart(targetSessionId, message, part)
return await this.recordFetchedPart(sessionId, message, part)
}
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
@@ -332,30 +292,23 @@ export class Subscription {
)
}
private async handleToolPart(
sessionId: string,
sourceSessionId: string,
part: ToolPart,
cwd: string,
child?: ChildSession,
) {
const key = toolKey(sourceSessionId, part.callID)
await this.toolStart(sessionId, key, part, cwd, child)
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
await this.toolStart(sessionId, part, cwd)
switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(key)
this.shellSnapshots.delete(part.callID)
return
case "running":
await this.runningTool(sessionId, key, part, cwd, child)
await this.runningTool(sessionId, part, cwd)
return
case "completed":
this.clearTool(key)
await this.update(
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
{
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.callID,
@@ -364,15 +317,14 @@ export class Subscription {
cwd,
}),
},
child,
)
})
return
case "error":
this.clearTool(key)
await this.update(
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
{
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.callID,
@@ -381,21 +333,20 @@ export class Subscription {
cwd,
}),
},
child,
)
})
return
}
}
private async runningTool(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
if (part.state.status !== "running") return
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
if (output !== undefined) {
if (this.shellSnapshots.get(key) === output) {
await this.update(
if (this.shellSnapshots.get(part.callID) === output) {
await this.input.connection.sessionUpdate({
sessionId,
{
update: {
sessionUpdate: "tool_call_update",
...duplicateRunningToolUpdate({
toolCallId: part.callID,
@@ -404,16 +355,15 @@ export class Subscription {
cwd,
}),
},
child,
)
})
return
}
this.shellSnapshots.set(key, output)
this.shellSnapshots.set(part.callID, output)
}
await this.update(
await this.input.connection.sessionUpdate({
sessionId,
{
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.callID,
@@ -423,16 +373,15 @@ export class Subscription {
cwd,
}),
},
child,
)
})
}
private async toolStart(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
if (this.toolStarts.has(key)) return
this.toolStarts.add(key)
await this.update(
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
if (this.toolStarts.has(part.callID)) return
this.toolStarts.add(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
{
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.callID,
@@ -441,67 +390,13 @@ export class Subscription {
cwd,
}),
},
child,
)
}
private clearTool(key: string) {
this.toolStarts.delete(key)
this.shellSnapshots.delete(key)
}
private async registerChild(info: Extract<Event, { type: "session.created" }>["properties"]["info"]) {
if (!info.parentID) return
const parent = this.children.get(info.parentID)
const rootID = parent?.rootID ?? (await Effect.runPromise(this.input.session.tryGet(info.parentID)))?.id
if (!rootID) return
this.children.set(info.id, {
id: info.id,
parentID: info.parentID,
rootID,
depth: (parent?.depth ?? 0) + 1,
title: info.title,
})
}
private async resolveSession(sessionId: string) {
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
if (session) return { session, child: undefined }
const child = this.children.get(sessionId)
if (!child) return
const root = await Effect.runPromise(this.input.session.tryGet(child.rootID))
if (!root) return
return { session: root, child }
private clearTool(toolCallId: string) {
this.toolStarts.delete(toolCallId)
this.shellSnapshots.delete(toolCallId)
}
private update(sessionId: string, update: SessionUpdate, child?: ChildSession) {
return this.input.connection.sessionUpdate({
sessionId,
update: child ? projectChildUpdate(update, child) : update,
})
}
}
function toolKey(sessionId: string, toolCallId: string) {
return `${sessionId}:${toolCallId}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
function signal() {
+12 -29
View File
@@ -16,12 +16,6 @@ import { Effect } from "effect"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
export type Context = {
sessionId: string
cwd: string
toolCallPrefix?: string
titlePrefix?: string
}
const permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
@@ -40,11 +34,11 @@ export class Handler {
},
) {}
handle(event: PermissionEvent, context?: Context) {
handle(event: PermissionEvent) {
const permission = event.properties
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event, context))
.then(() => this.process(event))
.catch(() => {})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
@@ -54,31 +48,28 @@ export class Handler {
this.queues.set(permission.sessionID, next)
}
private async process(event: PermissionEvent, context?: Context) {
private async process(event: PermissionEvent) {
const permission = event.properties
const registered = context ? undefined : await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
const target = context ?? (registered ? { sessionId: registered.id, cwd: registered.cwd } : undefined)
if (!target) return
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
if (!session) return
if (!this.input.connection.requestPermission) {
await this.reply(permission.id, "reject", target.cwd)
await this.reply(permission.id, "reject", session.cwd)
return
}
const toolCallId = permission.tool?.callID ?? permission.id
const result = await this.input.connection
.requestPermission({
sessionId: target.sessionId,
sessionId: permission.sessionID,
toolCall: await permissionToolCall({
toolCallId: target.toolCallPrefix ? `${target.toolCallPrefix}:${toolCallId}` : toolCallId,
toolCallId: permission.tool?.callID ?? permission.id,
toolName: permission.permission,
input: permission.metadata,
titlePrefix: target.titlePrefix,
}),
options: permissionOptions,
})
.catch(async () => {
await this.reply(permission.id, "reject", target.cwd)
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
@@ -86,15 +77,15 @@ export class Handler {
const reply = selectedReply(result)
if (reply !== "once" && reply !== "always") {
await this.reply(permission.id, "reject", target.cwd)
await this.reply(permission.id, "reject", session.cwd)
return
}
if (permission.permission === "edit") {
await this.writeProposedEdit(target.sessionId, permission.metadata).catch(() => {})
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
}
await this.reply(permission.id, reply, target.cwd)
await this.reply(permission.id, reply, session.cwd)
}
private async reply(requestID: string, reply: Reply, directory: string) {
@@ -128,7 +119,6 @@ async function permissionToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly titlePrefix?: string
}): Promise<ToolCallUpdate> {
const toolCall = pendingToolCall({
toolCallId: input.toolCallId,
@@ -141,18 +131,11 @@ async function permissionToolCall(input: {
const content = await permissionContent(input.toolName, input.input)
return {
...toolCall,
title: prefixedTitle(input.titlePrefix, toolCall.title),
locations: permissionLocations(input.toolName, input.input),
...(content.length ? { content } : {}),
}
}
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
if (!prefix) return title
if (!title) return prefix
return `${prefix}: ${title}`
}
function permissionTitle(toolName: string, input: ToolInput) {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
+13 -26
View File
@@ -28,6 +28,13 @@ export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
const RETRYABLE_MESSAGE = [
/\b(?:server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|too many requests|rate increased too quickly|provider[_\s-]?returned[_\s-]?error)\b|\brate[_\s-]?limit/i,
/\b(?:fetch failed|network error|upstream connect|connection (?:error|refused|lost)|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again)\b|^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed? out)\b/i,
/\b(?:resource[_\s-]?exhausted|please retry your request|you can retry your request|try your request again)\b/i,
/\b(?:429|500|502|503|504|524)\b/,
]
function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
@@ -122,32 +129,12 @@ export function retryable(error: Err, provider: string) {
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
}
// Check for rate limit patterns in plain text error messages
const msg = isRecord(error.data) ? error.data.message : undefined
if (typeof msg === "string") {
const lower = msg.toLowerCase()
if (
lower.includes("rate increased too quickly") ||
lower.includes("rate limit") ||
lower.includes("too many requests")
) {
return { message: msg }
}
}
const json = parseJSON(msg)
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
if (json.type === "error" && json.error?.type === "too_many_requests") {
return { message: "Too Many Requests" }
}
if (code.includes("exhausted") || code.includes("unavailable")) {
return { message: "Provider is overloaded" }
}
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return { message: "Rate Limited" }
}
const message = isRecord(error.data) ? error.data.message : undefined
if (typeof message !== "string") return undefined
const lower = message.toLowerCase()
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
if (RETRYABLE_MESSAGE.some((pattern) => pattern.test(message))) return { message }
return undefined
}
-72
View File
@@ -167,21 +167,6 @@ function toolUpdated(part: ToolPart): Event {
}
}
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
return {
id: `evt_${sessionID}_created`,
type: "session.created",
properties: {
sessionID,
info: {
id: sessionID,
parentID,
title,
},
},
} as Event
}
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
return {
info: {
@@ -334,63 +319,6 @@ async function createKnownSession(
}
describe("acp event routing", () => {
it("projects nested subagent messages onto the root session", async () => {
const harness = createHarness({
msg_grandchild: assistantMessage("ses_grandchild", "msg_grandchild", "part_grandchild", "text"),
})
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
await harness.subscription.handle(sessionCreated("ses_grandchild", "ses_child", "Research"))
await harness.subscription.handle(partUpdated("ses_grandchild", "msg_grandchild", "part_grandchild", "text"))
await harness.subscription.handle(textDelta("ses_grandchild", "msg_grandchild", "part_grandchild", "nested result"))
expect(harness.updates).toEqual([
{
sessionId: "ses_root",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_grandchild",
content: { type: "text", text: "nested result" },
_meta: {
"opencode/child-session": {
id: "ses_grandchild",
parentID: "ses_child",
depth: 2,
title: "Research",
},
},
},
},
])
})
it("namespaces subagent tool calls without colliding with root tools", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
await harness.subscription.handle(toolUpdated(runningTool("ses_root", "call_shared", "root")))
await harness.subscription.handle(toolUpdated(runningTool("ses_child", "call_shared", "child")))
const starts = toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")
expect(starts.map((item) => item.update.toolCallId)).toEqual(["call_shared", "ses_child:call_shared"])
expect(starts[1]).toMatchObject({
sessionId: "ses_root",
update: {
title: "Explore: printf hello",
_meta: {
"opencode/child-session": {
id: "ses_child",
parentID: "ses_root",
depth: 1,
title: "Explore",
},
},
},
})
})
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
@@ -122,21 +122,6 @@ function permissionAsked(
} as PermissionEvent
}
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
return {
id: `evt_${sessionID}_created`,
type: "session.created",
properties: {
sessionID,
info: {
id: sessionID,
parentID,
title,
},
},
} as Event
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
@@ -171,29 +156,6 @@ async function tempFile(name: string, content: string) {
}
describe("acp permissions", () => {
it("routes subagent permissions through the root session", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_root")
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
harness.subscription.handle(
permissionAsked("ses_child", "perm_child", {
tool: { messageID: "msg_child", callID: "call_child" },
}),
)
await pollUntil(() => harness.replies.length === 1, "subagent permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_root",
toolCall: {
toolCallId: "ses_child:call_child",
title: "Explore: printf hello",
},
})
expect(harness.replies).toEqual([{ requestID: "perm_child", reply: "once", directory: "/workspace" }])
})
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
+20 -2
View File
@@ -118,16 +118,21 @@ describe("session.retry.delay", () => {
})
describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
test("retries serialized too_many_requests messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
})
test("maps overloaded provider codes", () => {
test("retries serialized overloaded provider codes", () => {
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
})
test("retries serialized rate_limit messages", () => {
const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } })
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
})
test("does not retry unknown json messages", () => {
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
@@ -163,6 +168,19 @@ describe("session.retry.retryable", () => {
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test.each([
"Internal server error",
"Provider returned error",
"fetch failed",
"connection refused",
"EAI_AGAIN",
"response timed out",
"Please retry your request",
"upstream returned status 524",
])("retries matching API error text: %s", (message) => {
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
})
test("retries transport timeout errors", () => {
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
expect(SessionV1.APIError.isInstance(request)).toBe(true)