The gap: only the stream open was guarded
streamTheme wrapped the open of the stream in tryCatch and returned an AsyncIterable<string>. But a stream reports a later failure — a 429 once tokens flow, a dropped socket, an idle timeout — by throwing during iteration. The consume loop iterated with a bare for await, so that throw escaped every Result path as an unhandled rejection: progress notification gone, half-applied theme, no message. This PR closes the loop end to end.
Adapters: classify the throw at the edge
Each adapter's toContentStream now wraps iteration and re-throws a classified ProviderError. The core still imports no SDK — the SDK-specific classify stays in the adapter, and only a typed value crosses the seam.
try/catch around iteration -> throw classify(e). Anthropic adapter mirrors this.
src/adapters/openai/gateway.ts · 76 lines
⋯ 32 lines hidden (lines 1–32)
⋯ 29 lines hidden (lines 48–76)
The boundary guard, then the typed outcome
isProviderError turns the thrown value back into a typed error in the application, without the core knowing any SDK type. The consume loop wraps its for await; a throw becomes StreamFailed{applied, error}, which finalize maps to StreamInterrupted and renderGenerationError surfaces as the provider error plus how many settings were applied and a pointer to Reset. The partial theme is left in place (same as the Aborted path).
The guard: a stream can only signal failure by throwing, so meet it with a type guard.
src/ports/errors.ts · 118 lines
⋯ 42 lines hidden (lines 1–42)
⋯ 58 lines hidden (lines 61–118)
The loop is now wrapped; a mid-stream throw becomes StreamFailed, not a rejection.
src/application/generateTheme.ts · 320 lines
⋯ 157 lines hidden (lines 1–157)
⋯ 139 lines hidden (lines 182–320)
StreamFailed -> StreamInterrupted, rendered with the applied count + Reset hint.
src/application/generateTheme.ts · 320 lines
⋯ 204 lines hidden (lines 1–204)
⋯ 94 lines hidden (lines 210–303)
The discriminating test
The harness gains streamThrowAfter / streamThrowError — the fake throws mid-iteration exactly as the real adapter does after classify. The test asserts the failure surfaces as StreamInterrupted with the right applied count and the partial theme is kept. Before the fix, this rejected instead of returning.
Mid-stream 429: typed error + partial theme retained, no reset.