refactor(provider, message-v2): unify SSEStallError on MessageV2 schema (F9)#12
refactor(provider, message-v2): unify SSEStallError on MessageV2 schema (F9)#12tesdal wants to merge 1 commit intophase-ab-basefrom
Conversation
|
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
There was a problem hiding this comment.
Pull request overview
Unifies SSEStallError to a single schema-backed identity (MessageV2.SSEStallError) so throw/catch and isInstance checks are consistent across provider + session error serialization.
Changes:
- Remove the local
SSEStallErrorclass fromprovider.tsand throwMessageV2.SSEStallErrorfromwrapSSE(). - Add
extractStallMessage()to preserve the user-meaningful timeout text whenMessageV2.fromError()round-trips schema errors whose.messageis the tag. - Update tests to construct/assert against the schema error variant (
MessageV2.SSEStallError).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/opencode/src/provider/provider.ts | Switch wrapSSE() to throw the schema-backed SSE stall error (and remove the local error class). |
| packages/opencode/src/session/message-v2.ts | Add helper to extract stall timing text through cause chains; use it in fromError() SSE stall mapping. |
| packages/opencode/test/provider/chunk-timeout.test.ts | Update integration test to assert schema error identity and preserved .data.message. |
| packages/opencode/test/session/message-v2-sse-stall.test.ts | Update fixtures to use MessageV2.SSEStallError instances. |
| packages/opencode/test/session/retry.test.ts | Update retry round-trip test to use schema-backed stall error. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Lazy reference inside wrapSSE only — message-v2 also imports from @/provider, | ||
| // so this back-edge only resolves at call time. Do not use MessageV2 at module | ||
| // top-level (e.g., for a pre-allocated error constant) or it will TDZ-fault. | ||
| import { MessageV2 } from "@/session/message-v2" |
There was a problem hiding this comment.
The new import { MessageV2 } from "@/session/message-v2" introduces a circular dependency: message-v2.ts imports from @/provider (the barrel), and src/provider/index.ts re-exports ./provider, so loading message-v2 will load provider.ts which now loads message-v2 again. Also, the comment says the back-edge “only resolves at call time”, but the import is eager during module linking.
To avoid hard-to-debug initialization/TDZ issues, consider breaking the cycle (e.g., have message-v2.ts import ProviderError directly from @/provider/error instead of the barrel, or move SSEStallError to a small shared module that both provider.ts and message-v2.ts can import without crossing provider↔session).
There was a problem hiding this comment.
Good catch — you're right that is eager during module linking, the lazy-call-time framing was wrong. Fixed in 26dc319 by taking your first suggestion: message-v2.ts now imports * as ProviderError from "@/provider/error" directly instead of going through the @/provider barrel that re-exports provider.ts. provider/error.ts is leaf (only depends on ai, http, @/util/iife, and ./schema types), so no cycle.
Updated the comment in provider.ts to document the new invariant. Tests still 60/60, typecheck clean across all 13 packages.
96b2a0c to
26dc319
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| case hasSSEStallCause(e): | ||
| return new SSEStallError( | ||
| { message: e instanceof Error ? e.message : String(e) }, | ||
| { message: extractStallMessage(e) }, | ||
| { cause: e instanceof Error ? e : undefined }, | ||
| ).toObject() |
There was a problem hiding this comment.
extractStallMessage() is meant to preserve the real timeout text when fromError() receives an in-process MessageV2.SSEStallError (whose .message is the tag). The current tests cover the fallback path (data.message without the after Nms suffix) but don’t assert the main regression being fixed: when data.message does match the canonical ^SSE read timed out after \d+ms$ format, fromError() should still emit that exact string (not "SSEStallError"). Consider adding a test that passes new MessageV2.SSEStallError({ message: "SSE read timed out after 2ms" }) into fromError() and asserts result.data.message === "SSE read timed out after 2ms".
There was a problem hiding this comment.
Good point — the canonical happy path was implicit in the chunk-timeout integration test but not asserted directly at the fromError boundary. Added a focused test in 3c31b2b that constructs a top-level MessageV2.SSEStallError with the canonical wrapSSE message and asserts result.data.message preserves it verbatim. Fails loudly if extractStallMessage ever regresses to copying .message (which would emit the literal tag 'SSEStallError'). 10/10 pass in message-v2-sse-stall.test.ts.
Drop the duplicate `class SSEStallError extends Error` from provider.ts and have wrapSSE() throw `MessageV2.SSEStallError` (the schema-backed error) directly. Spike confirmed identity survives in-process rethrow paths (throw/catch, AbortController.abort, AbortSignal.any composition, ReadableStream.cancel, Promise.reject, end-to-end wrapSSE shape — 6/6). Kept hasSSEStallCause + SSE_STALL_MESSAGE_RE (hardened in F4) as the cross-realm safety net. The substring fallback defends rethrow paths that strip Error name/_tag (vendor SDK rewrapping, structured-clone boundaries) and is independent of which class is thrown. Added extractStallMessage() helper because the schema-error class sets `.message` to its tag (via `super(tag, options)`), so fromError can no longer copy `.message` directly when the input is already a schema instance — must read `.data.message`. Walks cause chain so nested wrappers still produce the original timing text. Updated test files to construct `MessageV2.SSEStallError` directly instead of the deleted runtime class. Net delta: -11 / +40 LOC. Eliminates one of two SSEStallError classes; remaining duplication is the substring fallback regex which intentionally defends a different threat model than the class identity. Addresses audit finding F9 (codex-5.3 diamond review, 2026-04-22).
26dc319 to
3c31b2b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Review-only PR — Copilot R3 clean (no new comments). Closing per per-finding workflow; merging local/audit-f9-sse-stall-unify into local/integration-v2 with --no-ff next. |
Audit finding F9 (codex-5.3 diamond review, 2026-04-22). Diamond: codex-5.3 APPROVED_WITH_COMMENTS, opus APPROVED_WITH_NITS. Copilot review: PR #12 (review-only, closed). 3 rounds: R1: 1 substantive (circular import) — fixed by importing ProviderError directly from @/provider/error. R2: 1 substantive (canonical-path coverage gap) — fixed by adding focused fromError round-trip test. R3: clean (no new comments). Net delta: +65 / -19 LOC (60 → 70 tests pass for F9 suite).
Summary
Audit finding F9 (codex-5.3 diamond review, 2026-04-22): provider.ts and message-v2.ts each defined their own
SSEStallErrorclass. Unify on the schema-backedMessageV2.SSEStallErrorso there is one identity.Changes
class SSEStallError extends Errorfromprovider.ts.wrapSSE()now throwsnew MessageV2.SSEStallError({ message })directly.extractStallMessage()inmessage-v2.ts. Required becausenamedSchemaErrorcallssuper(tag, options), so.messageon the schema instance is the tag ("SSEStallError"), not the timing text. The helper walks the cause chain and reads.data.message.hasSSEStallCause+SSE_STALL_MESSAGE_RE(hardened in F4) as the cross-realm safety net for vendor SDKs that stripname/_tagon rethrow.Spike evidence
In-process identity (throw/catch,
AbortController.abort,AbortSignal.any,ReadableStream.cancel,Promise.reject, end-to-end wrapSSE shape) survives via the schema's name-basedisInstancecheck — 6/6 spike tests green before deletion.Risk
Behavior change: thrown SSEStallError
.messageflips from "SSE read timed out after Nms" to "SSEStallError" (the tag). Audited consumers — onlyretry.ts:25reads it, and already uses.data.message.extractStallMessagecovers thefromErrorpath.Diamond review
Verification
bun typecheckclean (turbo, all 13 packages)bun test test/provider/chunk-timeout.test.ts test/session/message-v2-sse-stall.test.ts test/session/retry.test.ts→ 60/60 passNet delta: +46 / -18 LOC.