diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a3b88d5f1..0ea7837be 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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" @@ -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() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f87142010..43dcb2782 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -167,24 +167,21 @@ function retryTimeoutPolicyFor( function recoveryInterruptionMessage(recovery: NonNullable["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 } diff --git a/packages/opencode/src/session/stream-failure-classifier.test.ts b/packages/opencode/src/session/stream-failure-classifier.test.ts new file mode 100644 index 000000000..b7a6a7e7d --- /dev/null +++ b/packages/opencode/src/session/stream-failure-classifier.test.ts @@ -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() + }) + }) +}) diff --git a/packages/opencode/src/session/stream-failure-classifier.ts b/packages/opencode/src/session/stream-failure-classifier.ts new file mode 100644 index 000000000..dcfab658a --- /dev/null +++ b/packages/opencode/src/session/stream-failure-classifier.ts @@ -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 + } +} diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index b783b1396..de3d02cfd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -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", () => { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 126d688f2..8d81cc668 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -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) }, ), @@ -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", diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0d98e6caa..637d70ba3 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -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, )