Skip to content
Merged
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
12 changes: 7 additions & 5 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
import { EffectLogger } from "@/effect"
import { isMedia } from "@/util/media"
import { classifyStreamFailure } from "./stream-failure-classifier"
import { LLMTrace } from "./llm-trace"
import { RunObservability } from "./run-observability"
export { isMedia } from "@/util/media"
Expand Down Expand Up @@ -1140,19 +1141,20 @@ export function fromError(
},
{ cause: e },
).toObject()
case (e as SystemError)?.code === "ECONNRESET":
case classifyStreamFailure(e) !== undefined: {
const transport = classifyStreamFailure(e)!
return new APIError(
{
message: "Connection reset by server",
message: (e as Error).message || "Connection interrupted",
isRetryable: true,
metadata: {
code: (e as SystemError).code ?? "",
syscall: (e as SystemError).syscall ?? "",
message: (e as SystemError).message ?? "",
code: transport.code,
message: (e as Error).message || "",
},
},
{ cause: e },
).toObject()
}
case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
if (ctx.aborted) {
return new AbortedError({ message: e.message }, { cause: e }).toObject()
Expand Down
13 changes: 5 additions & 8 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,24 +167,21 @@ function retryTimeoutPolicyFor(

function recoveryInterruptionMessage(recovery: NonNullable<RunObservability.Summary["incident"]>["recovery"] | undefined) {
switch (recovery?.reason) {
case "no_visible_output_or_tool_execution":
return "Connection lost. Retry failed — please resend your message."
case "visible_output_without_tool_execution":
return "The response was interrupted after output started. PawWork did not automatically retry to avoid duplicate text."
return "Connection lost during response. Please resend to continue."
case "partial_tool_input_without_execution":
return "The connection broke while PawWork was preparing a tool call. The tool did not run."
case "tool_call_materialized_without_execution":
return "A tool call was prepared before the interruption. Recovery needs confirmation before continuing."
return "Connection lost during a tool operation. Please resend — the tool did not complete."
case "tool_execution_started":
return "The connection was interrupted after tool execution started. PawWork did not automatically retry."
case "unsafe_side_effect_started":
return "The connection was interrupted after a side effect may have started. PawWork did not automatically retry."
case "side_effect_facts_incomplete":
return "The connection was interrupted, and PawWork could not prove whether external side effects were possible."
return "Connection lost. Please check whether the last operation completed before resending."
case "local_lifecycle_close":
return LOCAL_LIFECYCLE_CLOSE_INTERRUPTION_MESSAGE
case "user_cancel":
return "The run was cancelled by the user."
case "no_visible_output_or_tool_execution":
return "The provider connection was interrupted before PawWork produced output or ran tools."
default:
return undefined
}
Expand Down
85 changes: 85 additions & 0 deletions packages/opencode/src/session/stream-failure-classifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { classifyStreamFailure } from "./stream-failure-classifier"

describe("classifyStreamFailure", () => {
describe("transport disconnect — retryable", () => {
test("ECONNRESET SystemError", () => {
const error = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET", syscall: "read" })
const result = classifyStreamFailure(error)
expect(result).toEqual({
kind: "provider_transport_disconnect",
retryable: true,
code: "ECONNRESET",
})
})

test("UND_ERR_SOCKET — TypeError('terminated') with cause.code", () => {
const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" })
const error = new TypeError("terminated", { cause })
const result = classifyStreamFailure(error)
expect(result).toEqual({
kind: "provider_transport_disconnect",
retryable: true,
code: "UND_ERR_SOCKET",
})
})

test("ECONNREFUSED SystemError", () => {
const error = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED", syscall: "connect" })
const result = classifyStreamFailure(error)
expect(result).toEqual({
kind: "provider_transport_disconnect",
retryable: true,
code: "ECONNREFUSED",
})
})

test("ETIMEDOUT SystemError", () => {
const error = Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT", syscall: "connect" })
const result = classifyStreamFailure(error)
expect(result).toEqual({
kind: "provider_transport_disconnect",
retryable: true,
code: "ETIMEDOUT",
})
})

test("UND_ERR_SOCKET nested in cause chain", () => {
const innerCause = Object.assign(new Error("socket hang up"), { code: "UND_ERR_SOCKET" })
const midError = new Error("fetch failed", { cause: innerCause })
const outerError = new TypeError("terminated", { cause: midError })
const result = classifyStreamFailure(outerError)
expect(result).toEqual({
kind: "provider_transport_disconnect",
retryable: true,
code: "UND_ERR_SOCKET",
})
})
})

describe("non-transport errors — not classified", () => {
test("generic Error returns undefined", () => {
expect(classifyStreamFailure(new Error("something broke"))).toBeUndefined()
})

test("TypeError without transport cause returns undefined", () => {
expect(classifyStreamFailure(new TypeError("Cannot read properties of undefined"))).toBeUndefined()
})

test("AbortError returns undefined", () => {
const error = new DOMException("The operation was aborted", "AbortError")
expect(classifyStreamFailure(error)).toBeUndefined()
})

test("non-Error values return undefined", () => {
expect(classifyStreamFailure("string error")).toBeUndefined()
expect(classifyStreamFailure(null)).toBeUndefined()
expect(classifyStreamFailure(42)).toBeUndefined()
})

test("TypeError('terminated') without UND_ERR_SOCKET cause returns undefined", () => {
const error = new TypeError("terminated", { cause: new Error("unrelated") })
expect(classifyStreamFailure(error)).toBeUndefined()
})
})
})
34 changes: 34 additions & 0 deletions packages/opencode/src/session/stream-failure-classifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type TransportDisconnect = {
kind: "provider_transport_disconnect"
retryable: true
code: string
}

const TRANSPORT_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "UND_ERR_SOCKET"])

export function classifyStreamFailure(error: unknown): TransportDisconnect | undefined {
if (!(error instanceof Error)) return undefined

const topCode = (error as { code?: string }).code
if (typeof topCode === "string" && TRANSPORT_CODES.has(topCode)) {
return { kind: "provider_transport_disconnect", retryable: true, code: topCode }
}

const code = findTransportCodeInCause(error.cause)
if (code) {
return { kind: "provider_transport_disconnect", retryable: true, code }
}

return undefined
}

function findTransportCodeInCause(cause: unknown, depth = 0): string | undefined {
if (depth > 4 || !cause || typeof cause !== "object") return undefined
try {
const code = (cause as { code?: string }).code
if (typeof code === "string" && TRANSPORT_CODES.has(code)) return code
return findTransportCodeInCause((cause as { cause?: unknown }).cause, depth + 1)
} catch {
return undefined
}
}
Comment thread
Astro-Han marked this conversation as resolved.
35 changes: 35 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,41 @@ describe("session.message-v2.fromError", () => {

expect(result.name).toBe("MessageAbortedError")
})

test("classifies UND_ERR_SOCKET TypeError as retryable APIError", () => {
const socketCause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" })
const error = new TypeError("terminated", { cause: socketCause })

const result = MessageV2.fromError(error, { providerID })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect((result as MessageV2.APIError).data.message).toInclude("terminated")
})

test("classifies ECONNREFUSED as retryable APIError", () => {
const error = Object.assign(new Error("connect ECONNREFUSED"), {
code: "ECONNREFUSED",
syscall: "connect",
})

const result = MessageV2.fromError(error, { providerID })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
})

test("classifies ETIMEDOUT as retryable APIError", () => {
const error = Object.assign(new Error("connect ETIMEDOUT"), {
code: "ETIMEDOUT",
syscall: "connect",
})

const result = MessageV2.fromError(error, { providerID })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
})
})

describe("session.message-v2.ToolStateError.reason", () => {
Expand Down
10 changes: 7 additions & 3 deletions packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1166,8 +1166,12 @@ it.live("session.processor effect tests reset reasoning state across retries", (

expect(value).toBe("continue")
expect(yield* llm.calls).toBe(2)
expect(reasoning.some((part) => part.text === "two")).toBe(true)
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
// Bun harness cannot produce Node undici's UND_ERR_SOCKET shape; TCP reset
// covers the processor retry/replay chain, classifier unit tests cover the rest.
const reasoningTexts = reasoning.map((part) => part.text)
expect(reasoningTexts).toContain("two")
expect(reasoningTexts).not.toContain("one")
expect(reasoningTexts).not.toContain("onetwo")
}),
{ git: true, config: (url) => providerCfg(url) },
),
Expand Down Expand Up @@ -2103,7 +2107,7 @@ it.live("retryable stream error after visible output does not replay the assista
expect(textParts.map((part) => part.text)).not.toContain("replayed")
expect(stored?.info.role).toBe("assistant")
if (stored?.info.role === "assistant") {
expect(stored.info.error?.data.message).toContain("interrupted after output started")
expect(stored.info.error?.data.message).toContain("Connection lost during response")
expect(stored.info.error?.data.message).not.toContain("stream terminated")
expect(stored.info.diagnostics?.run_observability?.incident?.recovery).toMatchObject({
recommendation: "offer_continue",
Expand Down
3 changes: 1 addition & 2 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,8 @@ describe("session.message-v2.fromError", () => {

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
expect((result as MessageV2.APIError).data.message).toInclude("socket connection")
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
},
15_000,
)
Expand Down