diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 0e8b7e88a40a..1878571288d6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1076,9 +1076,11 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(stream(sessionID)) }) -// Legacy substring fallback for rare paths where the tagged class gets stripped -// during cross-realm rethrow. Primary signal is `name`/`_tag` set by SSEStallError. -const SSE_STALL_MESSAGE_RE = /SSE (read|chunk) time(d out|out)/ +// Message-based exact-match fallback for the wrapSSE() emission format. +// The primary signals are `name === "SSEStallError"` and `_tag === "SSEStallError"`; +// this regex only matches when that structured error identity is stripped during +// cross-realm rethrow. +const SSE_STALL_MESSAGE_RE = /^SSE read timed out after \d+ms$/ function hasSSEStallCause(e: unknown, depth = 0): boolean { if (depth > 8) return false diff --git a/packages/opencode/test/session/message-v2-sse-stall.test.ts b/packages/opencode/test/session/message-v2-sse-stall.test.ts index 96afc11b840e..ad35dbf1ae60 100644 --- a/packages/opencode/test/session/message-v2-sse-stall.test.ts +++ b/packages/opencode/test/session/message-v2-sse-stall.test.ts @@ -29,14 +29,14 @@ describe("session.message-v2.fromError — SSEStallError", () => { }) test("detects SSE stall by timeout message without SSEStallError name", () => { - const error = new Error("SSE chunk timeout after 120000ms") + const error = new Error("SSE read timed out after 120000ms") const result = MessageV2.fromError(error, { providerID }) expect(result.name).toBe("SSEStallError") expect(MessageV2.SSEStallError.isInstance(result)).toBe(true) if (!MessageV2.SSEStallError.isInstance(result)) throw new Error("Expected SSEStallError") - expect(result.data.message).toBe("SSE chunk timeout after 120000ms") + expect(result.data.message).toBe("SSE read timed out after 120000ms") }) test("detects SSE stall through two-deep cause chain", () => { @@ -65,4 +65,45 @@ describe("session.message-v2.fromError — SSEStallError", () => { expect(result.name).toBe("SSEStallError") expect(MessageV2.APIError.isInstance(result)).toBe(false) }) + + test("hasSSEStallCause: tag-based detection still works", () => { + const tagged = Object.assign(new Error("anything"), { _tag: "SSEStallError" }) + const result = MessageV2.fromError(tagged, { providerID }) + expect(MessageV2.SSEStallError.isInstance(result)).toBe(true) + }) + + test("hasSSEStallCause: exact wrapSSE-format message is detected through cause chain", () => { + const taggedless = new Error("SSE read timed out after 120000ms") + const outer = new Error("outer") + outer.cause = taggedless + const result = MessageV2.fromError(outer, { providerID }) + expect(MessageV2.SSEStallError.isInstance(result)).toBe(true) + }) + + test("hasSSEStallCause: message-regex fallback rejects speculative 'chunk timeout' variants", () => { + const speculative = new Error("SSE chunk timeout") + const result = MessageV2.fromError(speculative, { providerID }) + expect(MessageV2.SSEStallError.isInstance(result)).toBe(false) + }) + + test("hasSSEStallCause: narrowed regex rejects shapes the old loose regex accepted", () => { + // The previous regex /SSE (read|chunk) time(d out|out)/ matched all of these; + // the narrowed /^SSE read timed out after \d+ms$/ rejects them all. + // Any future wrapSSE format change must update the regex in lockstep. + // Note: In JS regexes, `$` without the `m` flag matches only end-of-input + // and does NOT match before a trailing "\n", so newline-suffixed messages + // are rejected. + const cases = [ + "SSE read timed out", // missing "after Nms" suffix + "SSE chunk timed out after 120000ms", // wrong verb ("chunk" never emitted) + "SSE read timeout after 120000ms", // "timeout" not "timed out" + "prefix: SSE read timed out after 120000ms", // non-anchored prefix + "SSE read timed out after 120000ms ", // trailing whitespace + "SSE read timed out after 120000ms\n", // trailing newline ($ does not match before \n) + ] + for (const message of cases) { + const result = MessageV2.fromError(new Error(message), { providerID }) + expect(MessageV2.SSEStallError.isInstance(result)).toBe(false) + } + }) })