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
src/adapters/openai/gateway.ts76 lines · TypeScript
⋯ 32 lines hidden (lines 1–32)
1import OpenAI from 'openai';
2import { AsyncResult, type AsyncResultType, expose, ok, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { modelText } from '../../domain/model';
5import { type ProviderAdapter, type ProviderError, type ProviderRequest } from '../../ports';
6 
7/** Classify an SDK error by status code, instead of v1's substring sniffing. */
8const classify = (e: unknown): ProviderError => {
9 if (e instanceof OpenAI.APIError) {
10 if (e.status === 401 || e.status === 403) {
11 return { _tag: 'AuthFailed' };
12 }
13 if (e.status === 429) {
14 return { _tag: 'RateLimited' };
15 }
16 if (e.status === undefined) {
17 return { _tag: 'Network' };
18 }
19 return { _tag: 'Unexpected', detail: e.message };
20 }
21 return { _tag: 'Network' };
22};
23 
24const clientFor = (key: Redacted<ApiKey>): OpenAI => new OpenAI({ apiKey: expose(key) });
25 
26// GPT-5 / o-series are reasoning models; `minimal` effort keeps them fast and
27// streaming token-by-token (the "watch it paint" UX) rather than pausing to think.
28// Non-reasoning custom models (e.g. gpt-4o) reject the parameter, so only send it
29// where it's supported.
30const REASONING_FAMILY = /^(gpt-5|o[0-9])/i;
31const isReasoningModel = (id: string): boolean => REASONING_FAMILY.test(id);
32 
33async function* toContentStream(
34 stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
35): AsyncIterable<string> {
36 // A failure *after* the stream opens (429 once tokens flow, dropped socket, idle
37 // timeout) surfaces by throwing here. Classify it so the consumer sees a typed
38 // ProviderError instead of a raw SDK error / unhandled rejection.
39 try {
40 for await (const chunk of stream) {
41 const content = chunk.choices[0]?.delta?.content;
42 if (content) {
43 yield content;
44 }
45 }
46 } catch (e) {
47 throw classify(e);
⋯ 29 lines hidden (lines 48–76)
48 }
50 
51export const createOpenAiAdapter = (): ProviderAdapter => ({
52 verify: (key) =>
53 AsyncResult.tryCatch(async () => {
54 await clientFor(key).models.list();
55 }, classify),
56 
57 streamTheme: async (
58 request: ProviderRequest,
59 ): AsyncResultType<ProviderError, AsyncIterable<string>> => {
60 const id = modelText(request.model);
61 const opened = await AsyncResult.tryCatch(
62 () =>
63 clientFor(request.key).chat.completions.create({
64 model: id,
65 messages: [
66 { role: 'system', content: request.system },
67 { role: 'user', content: request.user },
68 ],
69 stream: true,
70 ...(isReasoningModel(id) ? { reasoning_effort: 'minimal' as const } : {}),
71 }),
72 classify,
73 );
74 return opened._tag === 'Err' ? opened : ok(toContentStream(opened.value));
75 },
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
src/ports/errors.ts118 lines · TypeScript
⋯ 42 lines hidden (lines 1–42)
1/**
2 * Error vocabularies for the side-effecting boundary, plus their translation into
3 * user-facing copy. Each port speaks a small, closed error union; the `render*`
4 * functions are the single place those become `UserMessage`s, so wording lives in
5 * one spot and adding a variant forces an explicit copy decision (exhaustive match).
6 */
7 
8import { matchTag, none, type OptionType, some } from '../fp';
9import { type WriteTarget } from '../domain/scope';
10 
11export type Severity = 'info' | 'warning' | 'error';
12 
13export interface UserMessage {
14 readonly title: string;
15 readonly detail: OptionType<string>;
16 readonly suggestion: OptionType<string>;
18 
19export const userMessage = (
20 title: string,
21 options: { readonly suggestion?: string; readonly detail?: string } = {},
22): UserMessage => ({
23 title,
24 detail: options.detail === undefined ? none : some(options.detail),
25 suggestion: options.suggestion === undefined ? none : some(options.suggestion),
26});
27 
28// ── Port error unions ─────────────────────────────────────────────────────────
29 
30export type StorageError = {
31 readonly _tag: 'StorageFailure';
32 readonly operation: 'read' | 'write' | 'clear';
33};
34 
35export type PromptError = { readonly _tag: 'PromptUnavailable' } | { readonly _tag: 'PromptEmpty' };
36 
37export type ProviderError =
38 | { readonly _tag: 'AuthFailed' }
39 | { readonly _tag: 'RateLimited' }
40 | { readonly _tag: 'Network' }
41 | { readonly _tag: 'Unexpected'; readonly detail: string };
42 
43const PROVIDER_ERROR_TAGS: ReadonlyArray<ProviderError['_tag']> = [
44 'AuthFailed',
45 'RateLimited',
46 'Network',
47 'Unexpected',
48];
49 
50/**
51 * A stream can only fail by *throwing*, so a mid-stream provider failure reaches the
52 * consumer as a thrown value. Adapters classify it to a `ProviderError` before
53 * re-throwing; this guard lets the application turn that thrown value back into a
54 * typed error instead of letting it escape as an unhandled rejection.
55 */
56export const isProviderError = (e: unknown): e is ProviderError =>
57 typeof e === 'object' &&
58 e !== null &&
59 '_tag' in e &&
60 (PROVIDER_ERROR_TAGS as ReadonlyArray<string>).includes((e as { readonly _tag: string })._tag);
⋯ 58 lines hidden (lines 61–118)
61 
62export type ConfigError =
63 | { readonly _tag: 'WriteFailed'; readonly target: WriteTarget }
64 | { readonly _tag: 'AllTargetsFailed' };
65 
66export type UiError = { readonly _tag: 'UiFailure' };
67 
68// ── Rendering ─────────────────────────────────────────────────────────────────
69 
70export const renderProviderError = (e: ProviderError): UserMessage =>
71 matchTag(e, {
72 AuthFailed: () =>
73 userMessage('🔑 The provider rejected the API key', {
74 suggestion: 'Clear the key and enter a valid one, then check your account access.',
75 }),
76 RateLimited: () =>
77 userMessage('🔑 Provider rate limit or quota reached', {
78 suggestion: 'Check your plan and usage on the provider dashboard, then try again.',
79 }),
80 Network: () =>
81 userMessage('🌐 Could not reach the model provider', {
82 suggestion: 'Check your internet connection and try again.',
83 }),
84 Unexpected: ({ detail }) =>
85 userMessage('❌ The model request failed', { detail, suggestion: 'Please try again.' }),
86 });
87 
88export const renderPromptError = (e: PromptError): UserMessage =>
89 matchTag(e, {
90 PromptUnavailable: () =>
91 userMessage('❌ Could not load the theme prompt', {
92 suggestion: 'Try reinstalling Vibe Themer.',
93 }),
94 PromptEmpty: () =>
95 userMessage('❌ The theme prompt is empty', {
96 suggestion: 'Try reinstalling Vibe Themer.',
97 }),
98 });
99 
100export const renderConfigError = (e: ConfigError): UserMessage =>
101 matchTag(e, {
102 WriteFailed: ({ target }) =>
103 userMessage(`❌ Could not write theme to ${target} settings`, {
104 suggestion: 'Check VS Code permissions and try restarting the editor.',
105 }),
106 AllTargetsFailed: () =>
107 userMessage('❌ Could not apply the theme to any settings scope', {
108 suggestion: 'Check VS Code permissions and try restarting the editor.',
109 }),
110 });
111 
112export const renderStorageError = (e: StorageError): UserMessage =>
113 userMessage(`❌ Secure storage ${e.operation} failed`, {
114 suggestion: 'Try restarting VS Code.',
115 });
116 
117export const renderUiError = (_e: UiError): UserMessage =>
118 userMessage('❌ A UI operation failed unexpectedly', { suggestion: 'Please try again.' });

The loop is now wrapped; a mid-stream throw becomes StreamFailed, not a rejection.

src/application/generateTheme.ts · 320 lines
src/application/generateTheme.ts320 lines · TypeScript
⋯ 157 lines hidden (lines 1–157)
1/**
2 * The Change Theme use case: provision a key, get a vibe, open the model stream,
3 * and apply each directive to the editor as it arrives.
4 *
5 * The orchestration lives here; every *decision* is delegated to a pure function
6 * (`parseLine`, `progressPercent`, `shouldShowMessage`, `coverage`) or a domain
7 * constructor, and every *effect* goes through a port. That split is what makes the
8 * whole flow testable with in-memory fakes — see `test/generateTheme.test.ts`.
9 *
10 * Benign exits (user dismisses the key prompt or the vibe picker) are *outcomes*,
11 * not errors. Genuine failures (prompt missing, OpenAI down, too many malformed
12 * lines) are errors.
13 */
14 
15import {
16 type AsyncResultType,
17 err,
18 matchTag,
19 type NonEmptyArray,
20 type NonEmptyString,
21 none,
22 ok,
23 Option,
24 type Redacted,
25 type ResultType,
26 some,
27} from '../fp';
28import { type ApiKey } from '../domain/apiKey';
29import { coverage } from '../domain/coverage';
30import { type StreamingDirective } from '../domain/directive';
31import { DEFAULT_MODEL, type Model } from '../domain/model';
32import { type WriteTarget, writePreference } from '../domain/scope';
33import { type ThemeSetting } from '../domain/theme';
34import { type Vibe } from '../domain/vibe';
35import { parseLine, renderParseError } from '../protocol/streamingParser';
36import {
37 type CancellationSignal,
38 type Capabilities,
39 isProviderError,
40 type KeepOrReset,
41 type Millis,
42 type ProgressReporter,
43 type PromptError,
44 type ProviderError,
45 renderConfigError,
46 renderProviderError,
47 renderPromptError,
48 renderUiError,
49 type UiError,
50 type UserMessage,
51 userMessage,
52} from '../ports';
53import { buildUserPrompt } from './context';
54import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
55import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
56import { curatedSuggestions } from './suggestions';
57 
58const MAX_RECOVERABLE_ERRORS = 5;
59const DEFAULT_EXPECTED_SETTINGS = 120;
60const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
61const PROGRESS_TITLE = 'Vibe Themer';
62 
63export type GenerationOutcome =
64 | { readonly _tag: 'NoKey' }
65 | { readonly _tag: 'NoVibe' }
66 | { readonly _tag: 'Completed'; readonly applied: number }
67 | { readonly _tag: 'CancelledKept'; readonly applied: number }
68 | { readonly _tag: 'CancelledReset' };
69 
70export type GenerationError =
71 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
72 | { readonly _tag: 'Ui'; readonly error: UiError }
73 | { readonly _tag: 'Prompt'; readonly error: PromptError }
74 | { readonly _tag: 'Provider'; readonly error: ProviderError }
75 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
76 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
77 
78type ApplyStatus = 'ok' | 'error';
79 
80type ConsumeOutcome =
81 | { readonly _tag: 'Completed'; readonly applied: number }
82 | { readonly _tag: 'Cancelled'; readonly applied: number }
83 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
84 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
85 
86// ── The streaming consume loop ─────────────────────────────────────────────────
87 
88const consumeStream = async (
89 caps: Capabilities,
90 stream: AsyncIterable<string>,
91 reporter: ProgressReporter,
92 signal: CancellationSignal,
93 preference: NonEmptyArray<WriteTarget>,
94): Promise<ConsumeOutcome> => {
95 let buffer = '';
96 let applied = 0;
97 let expected = DEFAULT_EXPECTED_SETTINGS;
98 let errors = 0;
99 let lastError = '';
100 let currentMessage = INITIAL_MESSAGE;
101 let lastMessageAt = neverShown;
102 
103 const reportProgress = (): void =>
104 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
105 
106 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
107 const result = await caps.config.applySetting(setting, preference);
108 if (result._tag === 'Ok') {
109 applied += 1;
110 reportProgress();
111 return 'ok';
112 }
113 lastError = renderConfigError(result.error).title;
114 return 'error';
115 };
116 
117 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
118 matchTag(directive, {
119 Count: async ({ total }): Promise<ApplyStatus> => {
120 expected = total;
121 currentMessage = `🎯 Planning ${total} theme settings...`;
122 reportProgress();
123 return 'ok';
124 },
125 Message: async ({ text }): Promise<ApplyStatus> => {
126 const now: Millis = caps.clock.now();
127 if (shouldShowMessage(lastMessageAt, now)) {
128 currentMessage = `${text}`;
129 lastMessageAt = markShown(now);
130 reporter.report({ message: currentMessage, percent: none });
131 }
132 return 'ok';
133 },
134 Selector: ({ selector, color }): Promise<ApplyStatus> =>
135 applySetting({ _tag: 'SelectorSetting', selector, color }),
136 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
137 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
138 });
139 
140 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
141 if (line.trim() === '') {
142 return null;
143 }
144 const parsed = parseLine(line);
145 if (parsed._tag === 'Ok') {
146 const status = await applyDirective(parsed.value);
147 if (status === 'error') {
148 errors += 1;
149 }
150 } else {
151 errors += 1;
152 lastError = renderParseError(parsed.error);
153 caps.logger.debug('directive parse failed', { line, error: lastError });
154 }
155 return errors >= MAX_RECOVERABLE_ERRORS ? { _tag: 'Aborted', applied, lastError } : null;
156 };
157 
158 try {
159 for await (const chunk of stream) {
160 if (signal.isCancelled()) {
161 break;
162 }
163 buffer += chunk;
164 const segments = buffer.split('\n');
165 buffer = segments.pop() ?? '';
166 for (const line of segments) {
167 if (signal.isCancelled()) {
168 break;
169 }
170 const aborted = await handleLine(line);
171 if (aborted !== null) {
172 return aborted;
173 }
174 }
175 }
176 } catch (e) {
177 // The stream threw after opening (provider error mid-generation). Adapters
178 // re-throw a classified ProviderError; anything else becomes Unexpected.
179 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
180 return { _tag: 'StreamFailed', applied, error };
181 }
⋯ 139 lines hidden (lines 182–320)
182 
183 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
184 if (!signal.isCancelled() && buffer.trim() !== '') {
185 const parsed = parseLine(buffer);
186 if (parsed._tag === 'Ok') {
187 await applyDirective(parsed.value);
188 }
189 }
190 
191 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
192};
193 
194// ── Finalization: keep/reset modals after the progress notification closes ─────
195 
196const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
197 result._tag === 'Ok' ? result.value : 'keep';
198 
199const finalize = (
200 caps: Capabilities,
201 consumed: ConsumeOutcome,
202): AsyncResultType<GenerationError, GenerationOutcome> =>
203 matchTag(consumed, {
204 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
205 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
206 
207 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
208 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
209 
210 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
211 const choice = await caps.ui.announceCompletion({
212 applied,
213 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
214 });
215 if (decideKeepOrReset(choice) === 'reset') {
216 await resetQuietly(caps);
217 }
218 return ok({ _tag: 'Completed', applied });
219 },
220 
221 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
222 const choice = await caps.ui.confirmCancellation(applied);
223 if (decideKeepOrReset(choice) === 'reset') {
224 await resetQuietly(caps);
225 return ok({ _tag: 'CancelledReset' });
226 }
227 return ok({ _tag: 'CancelledKept', applied });
228 },
229 });
230 
231const resetQuietly = async (caps: Capabilities): Promise<void> => {
232 const result = await caps.config.reset();
233 if (result._tag === 'Err') {
234 caps.logger.error('reset after generation failed', {
235 error: renderConfigError(result.error).title,
236 });
237 }
238};
239 
240// ── The use case ───────────────────────────────────────────────────────────────
241 
242const runGeneration = async (
243 caps: Capabilities,
244 model: Model,
245 key: Redacted<ApiKey>,
246 vibe: Vibe,
247 system: NonEmptyString,
248): AsyncResultType<GenerationError, GenerationOutcome> => {
249 const current = caps.config.readCurrentTheme();
250 const user = buildUserPrompt(current, vibe);
251 const preference = writePreference(caps.config.hasWorkspaceFolders());
252 
253 const streamResult = await caps.gateway.streamTheme({
254 provider: model.provider,
255 key,
256 model: model.id,
257 system,
258 user,
259 });
260 if (streamResult._tag === 'Err') {
261 return err({ _tag: 'Provider', error: streamResult.error });
262 }
263 
264 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
265 reporter.report({ message: INITIAL_MESSAGE, percent: none });
266 return consumeStream(caps, streamResult.value, reporter, signal, preference);
267 });
268 
269 return finalize(caps, consumed);
270};
271 
272export const generateTheme = async (
273 caps: Capabilities,
274): AsyncResultType<GenerationError, GenerationOutcome> => {
275 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
276 
277 const keyResult = await provisionApiKey(caps, model.provider);
278 if (keyResult._tag === 'Err') {
279 return keyResult.error._tag === 'Cancelled'
280 ? ok({ _tag: 'NoKey' })
281 : err({ _tag: 'Provision', error: keyResult.error });
282 }
283 
284 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
285 if (vibeResult._tag === 'Err') {
286 return err({ _tag: 'Ui', error: vibeResult.error });
287 }
288 if (vibeResult.value._tag === 'None') {
289 return ok({ _tag: 'NoVibe' });
290 }
291 
292 const promptResult = await caps.prompts.systemPrompt();
293 if (promptResult._tag === 'Err') {
294 return err({ _tag: 'Prompt', error: promptResult.error });
295 }
296 
297 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
298};
299 
300export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
301 matchTag(e, {
302 Provision: ({ error }) => renderProvisionError(error),
303 Ui: ({ error }) => some(renderUiError(error)),
304 Prompt: ({ error }) => some(renderPromptError(error)),
305 Provider: ({ error }) => some(renderProviderError(error)),
306 StreamInterrupted: ({ applied, error }) =>
307 some(
308 userMessage(renderProviderError(error).title, {
309 detail: `Generation stopped after applying ${applied} settings.`,
310 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
311 }),
312 ),
313 Aborted: ({ applied, lastError }) =>
314 some(
315 userMessage('⚠️ Theme generation was interrupted', {
316 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
317 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
318 }),
319 ),
320 });

StreamFailed -> StreamInterrupted, rendered with the applied count + Reset hint.

src/application/generateTheme.ts · 320 lines
src/application/generateTheme.ts320 lines · TypeScript
⋯ 204 lines hidden (lines 1–204)
1/**
2 * The Change Theme use case: provision a key, get a vibe, open the model stream,
3 * and apply each directive to the editor as it arrives.
4 *
5 * The orchestration lives here; every *decision* is delegated to a pure function
6 * (`parseLine`, `progressPercent`, `shouldShowMessage`, `coverage`) or a domain
7 * constructor, and every *effect* goes through a port. That split is what makes the
8 * whole flow testable with in-memory fakes — see `test/generateTheme.test.ts`.
9 *
10 * Benign exits (user dismisses the key prompt or the vibe picker) are *outcomes*,
11 * not errors. Genuine failures (prompt missing, OpenAI down, too many malformed
12 * lines) are errors.
13 */
14 
15import {
16 type AsyncResultType,
17 err,
18 matchTag,
19 type NonEmptyArray,
20 type NonEmptyString,
21 none,
22 ok,
23 Option,
24 type Redacted,
25 type ResultType,
26 some,
27} from '../fp';
28import { type ApiKey } from '../domain/apiKey';
29import { coverage } from '../domain/coverage';
30import { type StreamingDirective } from '../domain/directive';
31import { DEFAULT_MODEL, type Model } from '../domain/model';
32import { type WriteTarget, writePreference } from '../domain/scope';
33import { type ThemeSetting } from '../domain/theme';
34import { type Vibe } from '../domain/vibe';
35import { parseLine, renderParseError } from '../protocol/streamingParser';
36import {
37 type CancellationSignal,
38 type Capabilities,
39 isProviderError,
40 type KeepOrReset,
41 type Millis,
42 type ProgressReporter,
43 type PromptError,
44 type ProviderError,
45 renderConfigError,
46 renderProviderError,
47 renderPromptError,
48 renderUiError,
49 type UiError,
50 type UserMessage,
51 userMessage,
52} from '../ports';
53import { buildUserPrompt } from './context';
54import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
55import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
56import { curatedSuggestions } from './suggestions';
57 
58const MAX_RECOVERABLE_ERRORS = 5;
59const DEFAULT_EXPECTED_SETTINGS = 120;
60const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
61const PROGRESS_TITLE = 'Vibe Themer';
62 
63export type GenerationOutcome =
64 | { readonly _tag: 'NoKey' }
65 | { readonly _tag: 'NoVibe' }
66 | { readonly _tag: 'Completed'; readonly applied: number }
67 | { readonly _tag: 'CancelledKept'; readonly applied: number }
68 | { readonly _tag: 'CancelledReset' };
69 
70export type GenerationError =
71 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
72 | { readonly _tag: 'Ui'; readonly error: UiError }
73 | { readonly _tag: 'Prompt'; readonly error: PromptError }
74 | { readonly _tag: 'Provider'; readonly error: ProviderError }
75 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
76 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
77 
78type ApplyStatus = 'ok' | 'error';
79 
80type ConsumeOutcome =
81 | { readonly _tag: 'Completed'; readonly applied: number }
82 | { readonly _tag: 'Cancelled'; readonly applied: number }
83 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
84 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
85 
86// ── The streaming consume loop ─────────────────────────────────────────────────
87 
88const consumeStream = async (
89 caps: Capabilities,
90 stream: AsyncIterable<string>,
91 reporter: ProgressReporter,
92 signal: CancellationSignal,
93 preference: NonEmptyArray<WriteTarget>,
94): Promise<ConsumeOutcome> => {
95 let buffer = '';
96 let applied = 0;
97 let expected = DEFAULT_EXPECTED_SETTINGS;
98 let errors = 0;
99 let lastError = '';
100 let currentMessage = INITIAL_MESSAGE;
101 let lastMessageAt = neverShown;
102 
103 const reportProgress = (): void =>
104 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
105 
106 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
107 const result = await caps.config.applySetting(setting, preference);
108 if (result._tag === 'Ok') {
109 applied += 1;
110 reportProgress();
111 return 'ok';
112 }
113 lastError = renderConfigError(result.error).title;
114 return 'error';
115 };
116 
117 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
118 matchTag(directive, {
119 Count: async ({ total }): Promise<ApplyStatus> => {
120 expected = total;
121 currentMessage = `🎯 Planning ${total} theme settings...`;
122 reportProgress();
123 return 'ok';
124 },
125 Message: async ({ text }): Promise<ApplyStatus> => {
126 const now: Millis = caps.clock.now();
127 if (shouldShowMessage(lastMessageAt, now)) {
128 currentMessage = `${text}`;
129 lastMessageAt = markShown(now);
130 reporter.report({ message: currentMessage, percent: none });
131 }
132 return 'ok';
133 },
134 Selector: ({ selector, color }): Promise<ApplyStatus> =>
135 applySetting({ _tag: 'SelectorSetting', selector, color }),
136 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
137 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
138 });
139 
140 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
141 if (line.trim() === '') {
142 return null;
143 }
144 const parsed = parseLine(line);
145 if (parsed._tag === 'Ok') {
146 const status = await applyDirective(parsed.value);
147 if (status === 'error') {
148 errors += 1;
149 }
150 } else {
151 errors += 1;
152 lastError = renderParseError(parsed.error);
153 caps.logger.debug('directive parse failed', { line, error: lastError });
154 }
155 return errors >= MAX_RECOVERABLE_ERRORS ? { _tag: 'Aborted', applied, lastError } : null;
156 };
157 
158 try {
159 for await (const chunk of stream) {
160 if (signal.isCancelled()) {
161 break;
162 }
163 buffer += chunk;
164 const segments = buffer.split('\n');
165 buffer = segments.pop() ?? '';
166 for (const line of segments) {
167 if (signal.isCancelled()) {
168 break;
169 }
170 const aborted = await handleLine(line);
171 if (aborted !== null) {
172 return aborted;
173 }
174 }
175 }
176 } catch (e) {
177 // The stream threw after opening (provider error mid-generation). Adapters
178 // re-throw a classified ProviderError; anything else becomes Unexpected.
179 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
180 return { _tag: 'StreamFailed', applied, error };
181 }
182 
183 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
184 if (!signal.isCancelled() && buffer.trim() !== '') {
185 const parsed = parseLine(buffer);
186 if (parsed._tag === 'Ok') {
187 await applyDirective(parsed.value);
188 }
189 }
190 
191 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
192};
193 
194// ── Finalization: keep/reset modals after the progress notification closes ─────
195 
196const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
197 result._tag === 'Ok' ? result.value : 'keep';
198 
199const finalize = (
200 caps: Capabilities,
201 consumed: ConsumeOutcome,
202): AsyncResultType<GenerationError, GenerationOutcome> =>
203 matchTag(consumed, {
204 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
205 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
206 
207 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
208 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
209 
⋯ 94 lines hidden (lines 210–303)
210 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
211 const choice = await caps.ui.announceCompletion({
212 applied,
213 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
214 });
215 if (decideKeepOrReset(choice) === 'reset') {
216 await resetQuietly(caps);
217 }
218 return ok({ _tag: 'Completed', applied });
219 },
220 
221 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
222 const choice = await caps.ui.confirmCancellation(applied);
223 if (decideKeepOrReset(choice) === 'reset') {
224 await resetQuietly(caps);
225 return ok({ _tag: 'CancelledReset' });
226 }
227 return ok({ _tag: 'CancelledKept', applied });
228 },
229 });
230 
231const resetQuietly = async (caps: Capabilities): Promise<void> => {
232 const result = await caps.config.reset();
233 if (result._tag === 'Err') {
234 caps.logger.error('reset after generation failed', {
235 error: renderConfigError(result.error).title,
236 });
237 }
238};
239 
240// ── The use case ───────────────────────────────────────────────────────────────
241 
242const runGeneration = async (
243 caps: Capabilities,
244 model: Model,
245 key: Redacted<ApiKey>,
246 vibe: Vibe,
247 system: NonEmptyString,
248): AsyncResultType<GenerationError, GenerationOutcome> => {
249 const current = caps.config.readCurrentTheme();
250 const user = buildUserPrompt(current, vibe);
251 const preference = writePreference(caps.config.hasWorkspaceFolders());
252 
253 const streamResult = await caps.gateway.streamTheme({
254 provider: model.provider,
255 key,
256 model: model.id,
257 system,
258 user,
259 });
260 if (streamResult._tag === 'Err') {
261 return err({ _tag: 'Provider', error: streamResult.error });
262 }
263 
264 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
265 reporter.report({ message: INITIAL_MESSAGE, percent: none });
266 return consumeStream(caps, streamResult.value, reporter, signal, preference);
267 });
268 
269 return finalize(caps, consumed);
270};
271 
272export const generateTheme = async (
273 caps: Capabilities,
274): AsyncResultType<GenerationError, GenerationOutcome> => {
275 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
276 
277 const keyResult = await provisionApiKey(caps, model.provider);
278 if (keyResult._tag === 'Err') {
279 return keyResult.error._tag === 'Cancelled'
280 ? ok({ _tag: 'NoKey' })
281 : err({ _tag: 'Provision', error: keyResult.error });
282 }
283 
284 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
285 if (vibeResult._tag === 'Err') {
286 return err({ _tag: 'Ui', error: vibeResult.error });
287 }
288 if (vibeResult.value._tag === 'None') {
289 return ok({ _tag: 'NoVibe' });
290 }
291 
292 const promptResult = await caps.prompts.systemPrompt();
293 if (promptResult._tag === 'Err') {
294 return err({ _tag: 'Prompt', error: promptResult.error });
295 }
296 
297 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
298};
299 
300export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
301 matchTag(e, {
302 Provision: ({ error }) => renderProvisionError(error),
303 Ui: ({ error }) => some(renderUiError(error)),
304 Prompt: ({ error }) => some(renderPromptError(error)),
305 Provider: ({ error }) => some(renderProviderError(error)),
306 StreamInterrupted: ({ applied, error }) =>
307 some(
308 userMessage(renderProviderError(error).title, {
309 detail: `Generation stopped after applying ${applied} settings.`,
310 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
311 }),
312 ),
313 Aborted: ({ applied, lastError }) =>
⋯ 7 lines hidden (lines 314–320)
314 some(
315 userMessage('⚠️ Theme generation was interrupted', {
316 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
317 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
318 }),
319 ),
320 });

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.

test/generateTheme.test.ts · 185 lines
test/generateTheme.test.ts185 lines · TypeScript
⋯ 109 lines hidden (lines 1–109)
1import { describe, it } from 'node:test';
2import assert from 'node:assert/strict';
3import { generateTheme } from '../src/application/generateTheme';
4import { makeModel } from '../src/domain/model';
5import { harness } from './support/harness';
6 
7const VALID_KEY = `sk-${'x'.repeat(40)}`;
8 
9const HAPPY_STREAM = [
10 'COUNT:3',
11 'MESSAGE:Warming up the editor 🔥',
12 'SELECTOR:editor.background=#1a1a1a',
13 'TOKEN:comment=#6a9955,italic',
14 'SELECTOR:activityBar.background=REMOVE',
15 '',
16].join('\n');
17 
18describe('generateTheme — happy path', () => {
19 it('applies each setting live and completes', async () => {
20 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn evening', streamText: HAPPY_STREAM });
21 const result = await generateTheme(h.caps);
22 
23 assert.equal(result._tag, 'Ok');
24 if (result._tag === 'Ok') {
25 assert.deepEqual(result.value, { _tag: 'Completed', applied: 3 });
26 }
27 assert.equal(h.colors.get('editor.background'), '#1a1a1a');
28 assert.equal(h.colors.has('activityBar.background'), false);
29 assert.deepEqual(h.tokenRules, [
30 { scope: 'comment', settings: { foreground: '#6a9955', fontStyle: 'italic' } },
31 ]);
32 });
33 
34 it('REMOVE deletes a previously applied selector (iteration)', async () => {
35 const text = ['COUNT:2', 'SELECTOR:foo.bar=#111111', 'SELECTOR:foo.bar=REMOVE', ''].join('\n');
36 const h = harness({ storedKey: VALID_KEY, vibe: 'make it warmer', streamText: text });
37 const result = await generateTheme(h.caps);
38 
39 assert.equal(h.colors.has('foo.bar'), false);
40 if (result._tag === 'Ok') {
41 assert.equal(result.value._tag, 'Completed');
42 }
43 });
44 
45 it('injects existing customizations into the streamed prompt for iteration', async () => {
46 const currentTheme = {
47 global: { colors: { 'editor.background': '#1e1e1e' }, tokens: {} },
48 workspace: { colors: {}, tokens: {} },
49 };
50 const text = ['COUNT:1', 'SELECTOR:editor.background=#2a1f1a', ''].join('\n');
51 const h = harness({ storedKey: VALID_KEY, vibe: 'make it warmer', streamText: text, currentTheme });
52 await generateTheme(h.caps);
53 
54 assert.ok(h.captured.streamUserPrompt.includes('CURRENT THEME CONTEXT:'));
55 assert.ok(h.captured.streamUserPrompt.includes('editor.background: #1e1e1e'));
56 assert.ok(h.captured.streamUserPrompt.endsWith('make it warmer'));
57 });
58 
59 it('prompts for, verifies, and stores a new key, then proceeds', async () => {
60 const text = ['COUNT:1', 'SELECTOR:editor.background=#000000', ''].join('\n');
61 const h = harness({ promptKey: VALID_KEY, vibe: 'minimal dark', streamText: text });
62 const result = await generateTheme(h.caps);
63 
64 assert.equal(h.captured.keySet, true);
65 if (result._tag === 'Ok') {
66 assert.equal(result.value._tag, 'Completed');
67 }
68 });
69 
70 it('routes to Anthropic when a Claude model is selected, storing an sk-ant- key', async () => {
71 const anthropicKey = `sk-ant-${'x'.repeat(40)}`;
72 const text = ['COUNT:1', 'SELECTOR:editor.background=#101010', ''].join('\n');
73 const h = harness({
74 selectedModel: makeModel('anthropic', 'claude-sonnet-4-6'),
75 promptKey: anthropicKey,
76 vibe: 'calm ocean depths',
77 streamText: text,
78 });
79 const result = await generateTheme(h.caps);
80 
81 assert.deepEqual(h.captured.keySetProviders, ['anthropic']);
82 assert.equal(h.colors.get('editor.background'), '#101010');
83 if (result._tag === 'Ok') {
84 assert.equal(result.value._tag, 'Completed');
85 }
86 });
87});
88 
89describe('generateTheme — benign exits', () => {
90 it('returns NoVibe when the picker is dismissed', async () => {
91 const h = harness({ storedKey: VALID_KEY });
92 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoVibe' } });
93 });
94 
95 it('returns NoKey when no key is stored and the prompt is dismissed', async () => {
96 const h = harness({ vibe: 'cozy autumn' });
97 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoKey' } });
98 });
99});
100 
101describe('generateTheme — failures', () => {
102 it('surfaces a provider stream error', async () => {
103 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamError: { _tag: 'RateLimited' } });
104 assert.deepEqual(await generateTheme(h.caps), {
105 _tag: 'Err',
106 error: { _tag: 'Provider', error: { _tag: 'RateLimited' } },
107 });
108 });
109 
110 it('surfaces a mid-stream provider error and keeps the partial theme', async () => {
111 // The stream opens, applies some settings, then throws (e.g. a 429 once tokens
112 // flow). Before the fix this escaped as an unhandled rejection; now it is a
113 // typed StreamInterrupted error and the partial theme is left for the user.
114 const h = harness({
115 storedKey: VALID_KEY,
116 vibe: 'cozy',
117 streamText: HAPPY_STREAM,
118 chunkSize: 1000,
119 streamThrowAfter: 1,
120 streamThrowError: { _tag: 'RateLimited' },
121 });
122 const result = await generateTheme(h.caps);
123 
124 assert.equal(result._tag, 'Err');
125 if (result._tag === 'Err' && result.error._tag === 'StreamInterrupted') {
126 assert.equal(result.error.error._tag, 'RateLimited');
127 assert.equal(result.error.applied, 3);
128 } else {
129 assert.fail(`expected StreamInterrupted, got ${JSON.stringify(result)}`);
130 }
131 assert.equal(h.colors.get('editor.background'), '#1a1a1a');
132 assert.equal(h.captured.resets, 0);
133 });
134 
⋯ 51 lines hidden (lines 135–185)
135 it('aborts after too many malformed lines', async () => {
136 const garbage = Array.from({ length: 6 }, (_unused, i) => `GARBAGE:${i}`).join('\n');
137 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamText: garbage });
138 const result = await generateTheme(h.caps);
139 assert.equal(result._tag, 'Err');
140 if (result._tag === 'Err') {
141 assert.equal(result.error._tag, 'Aborted');
142 }
143 });
144});
145 
146describe('generateTheme — cancellation and reset', () => {
147 it('on cancel + reset, clears the theme', async () => {
148 const h = harness({
149 storedKey: VALID_KEY,
150 vibe: 'cozy',
151 streamText: HAPPY_STREAM,
152 cancelAfterReports: 2,
153 cancellationChoice: 'reset',
154 });
155 const result = await generateTheme(h.caps);
156 if (result._tag === 'Ok') {
157 assert.equal(result.value._tag, 'CancelledReset');
158 }
159 assert.ok(h.captured.resets >= 1);
160 });
161 
162 it('on success + reset choice, clears the theme', async () => {
163 const h = harness({
164 storedKey: VALID_KEY,
165 vibe: 'cozy',
166 streamText: HAPPY_STREAM,
167 completionChoice: 'reset',
168 });
169 const result = await generateTheme(h.caps);
170 if (result._tag === 'Ok') {
171 assert.equal(result.value._tag, 'Completed');
172 }
173 assert.equal(h.captured.resets, 1);
174 });
175});
176 
177describe('generateTheme — secret safety', () => {
178 it('never lets the raw API key reach logs or notifications', async () => {
179 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn', streamText: HAPPY_STREAM });
180 await generateTheme(h.caps);
181 const serialized = JSON.stringify(h.captured);
182 assert.equal(serialized.includes('sk-'), false);
183 assert.equal(serialized.includes('x'.repeat(40)), false);
184 });
185});