One counter, two unrelated failures

MAX_RECOVERABLE_ERRORS is tolerance for malformed model output. But the consume loop incremented the same counter when a settings write failed. So an unwritable settings.json (all targets failing) aborted after 5 valid directives with "too many errors" — blaming the model for an editor/permissions problem and dropping the useful guidance.

Two named budgets: parse-quality vs settings-write.

src/application/generateTheme.ts · 347 lines
src/application/generateTheme.ts347 lines · TypeScript
⋯ 57 lines hidden (lines 1–57)
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 type ConfigError,
40 isProviderError,
41 type KeepOrReset,
42 type Millis,
43 type ProgressReporter,
44 type PromptError,
45 type ProviderError,
46 renderConfigError,
47 renderProviderError,
48 renderPromptError,
49 renderUiError,
50 type UiError,
51 type UserMessage,
52 userMessage,
53} from '../ports';
54import { buildUserPrompt } from './context';
55import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
56import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
57import { curatedSuggestions } from './suggestions';
58 
59// Tolerance for malformed *model output* (a parse-quality signal).
60const MAX_RECOVERABLE_ERRORS = 5;
61// Tolerance for *settings-write* failures (an environment/permissions signal,
62// kept separate so an IO problem isn't misreported as bad model output).
63const MAX_WRITE_ERRORS = 5;
64const DEFAULT_EXPECTED_SETTINGS = 120;
⋯ 283 lines hidden (lines 65–347)
65const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
66const PROGRESS_TITLE = 'Vibe Themer';
67 
68export type GenerationOutcome =
69 | { readonly _tag: 'NoKey' }
70 | { readonly _tag: 'NoVibe' }
71 | { readonly _tag: 'Completed'; readonly applied: number }
72 | { readonly _tag: 'CancelledKept'; readonly applied: number }
73 | { readonly _tag: 'CancelledReset' };
74 
75export type GenerationError =
76 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
77 | { readonly _tag: 'Ui'; readonly error: UiError }
78 | { readonly _tag: 'Prompt'; readonly error: PromptError }
79 | { readonly _tag: 'Provider'; readonly error: ProviderError }
80 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
81 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
82 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
83 
84type ApplyStatus = 'ok' | 'error';
85 
86type ConsumeOutcome =
87 | { readonly _tag: 'Completed'; readonly applied: number }
88 | { readonly _tag: 'Cancelled'; readonly applied: number }
89 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
90 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
91 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
92 
93// ── The streaming consume loop ─────────────────────────────────────────────────
94 
95const consumeStream = async (
96 caps: Capabilities,
97 stream: AsyncIterable<string>,
98 reporter: ProgressReporter,
99 signal: CancellationSignal,
100 preference: NonEmptyArray<WriteTarget>,
101): Promise<ConsumeOutcome> => {
102 let buffer = '';
103 let applied = 0;
104 let expected = DEFAULT_EXPECTED_SETTINGS;
105 let parseErrors = 0;
106 let writeErrors = 0;
107 let lastParseError = '';
108 let lastConfigError: ConfigError | undefined;
109 let currentMessage = INITIAL_MESSAGE;
110 let lastMessageAt = neverShown;
111 
112 const reportProgress = (): void =>
113 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
114 
115 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
116 const result = await caps.config.applySetting(setting, preference);
117 if (result._tag === 'Ok') {
118 applied += 1;
119 reportProgress();
120 return 'ok';
121 }
122 // A write failure is environmental, not a model-output problem — track it on
123 // its own budget and log it, rather than spending the malformed-line tolerance.
124 writeErrors += 1;
125 lastConfigError = result.error;
126 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
127 return 'error';
128 };
129 
130 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
131 matchTag(directive, {
132 Count: async ({ total }): Promise<ApplyStatus> => {
133 expected = total;
134 currentMessage = `🎯 Planning ${total} theme settings...`;
135 reportProgress();
136 return 'ok';
137 },
138 Message: async ({ text }): Promise<ApplyStatus> => {
139 const now: Millis = caps.clock.now();
140 if (shouldShowMessage(lastMessageAt, now)) {
141 currentMessage = `${text}`;
142 lastMessageAt = markShown(now);
143 reporter.report({ message: currentMessage, percent: none });
144 }
145 return 'ok';
146 },
147 Selector: ({ selector, color }): Promise<ApplyStatus> =>
148 applySetting({ _tag: 'SelectorSetting', selector, color }),
149 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
150 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
151 });
152 
153 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
154 if (line.trim() === '') {
155 return null;
156 }
157 const parsed = parseLine(line);
158 if (parsed._tag === 'Ok') {
159 // Write failures are counted inside applySetting, on their own budget.
160 await applyDirective(parsed.value);
161 } else {
162 parseErrors += 1;
163 lastParseError = renderParseError(parsed.error);
164 caps.logger.debug('directive parse failed', { line, error: lastParseError });
165 }
166 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
167 return { _tag: 'Aborted', applied, lastError: lastParseError };
168 }
169 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
170 return { _tag: 'WriteFailed', applied, error: lastConfigError };
171 }
172 return null;
173 };
174 
175 try {
176 for await (const chunk of stream) {
177 if (signal.isCancelled()) {
178 break;
179 }
180 buffer += chunk;
181 const segments = buffer.split('\n');
182 buffer = segments.pop() ?? '';
183 for (const line of segments) {
184 if (signal.isCancelled()) {
185 break;
186 }
187 const aborted = await handleLine(line);
188 if (aborted !== null) {
189 return aborted;
190 }
191 }
192 }
193 } catch (e) {
194 // The stream threw after opening (provider error mid-generation). Adapters
195 // re-throw a classified ProviderError; anything else becomes Unexpected.
196 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
197 return { _tag: 'StreamFailed', applied, error };
198 }
199 
200 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
201 if (!signal.isCancelled() && buffer.trim() !== '') {
202 const parsed = parseLine(buffer);
203 if (parsed._tag === 'Ok') {
204 await applyDirective(parsed.value);
205 }
206 }
207 
208 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
209};
210 
211// ── Finalization: keep/reset modals after the progress notification closes ─────
212 
213const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
214 result._tag === 'Ok' ? result.value : 'keep';
215 
216const finalize = (
217 caps: Capabilities,
218 consumed: ConsumeOutcome,
219): AsyncResultType<GenerationError, GenerationOutcome> =>
220 matchTag(consumed, {
221 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
222 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
223 
224 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
225 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
226 
227 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
228 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
229 
230 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
231 const choice = await caps.ui.announceCompletion({
232 applied,
233 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
234 });
235 if (decideKeepOrReset(choice) === 'reset') {
236 await resetQuietly(caps);
237 }
238 return ok({ _tag: 'Completed', applied });
239 },
240 
241 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
242 const choice = await caps.ui.confirmCancellation(applied);
243 if (decideKeepOrReset(choice) === 'reset') {
244 await resetQuietly(caps);
245 return ok({ _tag: 'CancelledReset' });
246 }
247 return ok({ _tag: 'CancelledKept', applied });
248 },
249 });
250 
251const resetQuietly = async (caps: Capabilities): Promise<void> => {
252 const result = await caps.config.reset();
253 if (result._tag === 'Err') {
254 caps.logger.error('reset after generation failed', {
255 error: renderConfigError(result.error).title,
256 });
257 }
258};
259 
260// ── The use case ───────────────────────────────────────────────────────────────
261 
262const runGeneration = async (
263 caps: Capabilities,
264 model: Model,
265 key: Redacted<ApiKey>,
266 vibe: Vibe,
267 system: NonEmptyString,
268): AsyncResultType<GenerationError, GenerationOutcome> => {
269 const current = caps.config.readCurrentTheme();
270 const user = buildUserPrompt(current, vibe);
271 const preference = writePreference(caps.config.hasWorkspaceFolders());
272 
273 const streamResult = await caps.gateway.streamTheme({
274 provider: model.provider,
275 key,
276 model: model.id,
277 system,
278 user,
279 });
280 if (streamResult._tag === 'Err') {
281 return err({ _tag: 'Provider', error: streamResult.error });
282 }
283 
284 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
285 reporter.report({ message: INITIAL_MESSAGE, percent: none });
286 return consumeStream(caps, streamResult.value, reporter, signal, preference);
287 });
288 
289 return finalize(caps, consumed);
290};
291 
292export const generateTheme = async (
293 caps: Capabilities,
294): AsyncResultType<GenerationError, GenerationOutcome> => {
295 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
296 
297 const keyResult = await provisionApiKey(caps, model.provider);
298 if (keyResult._tag === 'Err') {
299 return keyResult.error._tag === 'Cancelled'
300 ? ok({ _tag: 'NoKey' })
301 : err({ _tag: 'Provision', error: keyResult.error });
302 }
303 
304 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
305 if (vibeResult._tag === 'Err') {
306 return err({ _tag: 'Ui', error: vibeResult.error });
307 }
308 if (vibeResult.value._tag === 'None') {
309 return ok({ _tag: 'NoVibe' });
310 }
311 
312 const promptResult = await caps.prompts.systemPrompt();
313 if (promptResult._tag === 'Err') {
314 return err({ _tag: 'Prompt', error: promptResult.error });
315 }
316 
317 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
318};
319 
320export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
321 matchTag(e, {
322 Provision: ({ error }) => renderProvisionError(error),
323 Ui: ({ error }) => some(renderUiError(error)),
324 Prompt: ({ error }) => some(renderPromptError(error)),
325 Provider: ({ error }) => some(renderProviderError(error)),
326 StreamInterrupted: ({ applied, error }) =>
327 some(
328 userMessage(renderProviderError(error).title, {
329 detail: `Generation stopped after applying ${applied} settings.`,
330 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
331 }),
332 ),
333 WriteAborted: ({ applied, error }) =>
334 some(
335 userMessage(renderConfigError(error).title, {
336 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
337 suggestion: 'Check VS Code permissions and try restarting the editor.',
338 }),
339 ),
340 Aborted: ({ applied, lastError }) =>
341 some(
342 userMessage('⚠️ Theme generation was interrupted', {
343 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
344 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
345 }),
346 ),
347 });

Count each on its own budget

applySetting counts write failures and remembers the ConfigError; handleLine counts only parse failures and checks both thresholds, returning a distinct outcome for each.

Write failures tracked in applySetting; handleLine aborts parse vs write distinctly.

src/application/generateTheme.ts · 347 lines
src/application/generateTheme.ts347 lines · TypeScript
⋯ 118 lines hidden (lines 1–118)
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 type ConfigError,
40 isProviderError,
41 type KeepOrReset,
42 type Millis,
43 type ProgressReporter,
44 type PromptError,
45 type ProviderError,
46 renderConfigError,
47 renderProviderError,
48 renderPromptError,
49 renderUiError,
50 type UiError,
51 type UserMessage,
52 userMessage,
53} from '../ports';
54import { buildUserPrompt } from './context';
55import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
56import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
57import { curatedSuggestions } from './suggestions';
58 
59// Tolerance for malformed *model output* (a parse-quality signal).
60const MAX_RECOVERABLE_ERRORS = 5;
61// Tolerance for *settings-write* failures (an environment/permissions signal,
62// kept separate so an IO problem isn't misreported as bad model output).
63const MAX_WRITE_ERRORS = 5;
64const DEFAULT_EXPECTED_SETTINGS = 120;
65const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
66const PROGRESS_TITLE = 'Vibe Themer';
67 
68export type GenerationOutcome =
69 | { readonly _tag: 'NoKey' }
70 | { readonly _tag: 'NoVibe' }
71 | { readonly _tag: 'Completed'; readonly applied: number }
72 | { readonly _tag: 'CancelledKept'; readonly applied: number }
73 | { readonly _tag: 'CancelledReset' };
74 
75export type GenerationError =
76 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
77 | { readonly _tag: 'Ui'; readonly error: UiError }
78 | { readonly _tag: 'Prompt'; readonly error: PromptError }
79 | { readonly _tag: 'Provider'; readonly error: ProviderError }
80 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
81 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
82 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
83 
84type ApplyStatus = 'ok' | 'error';
85 
86type ConsumeOutcome =
87 | { readonly _tag: 'Completed'; readonly applied: number }
88 | { readonly _tag: 'Cancelled'; readonly applied: number }
89 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
90 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
91 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
92 
93// ── The streaming consume loop ─────────────────────────────────────────────────
94 
95const consumeStream = async (
96 caps: Capabilities,
97 stream: AsyncIterable<string>,
98 reporter: ProgressReporter,
99 signal: CancellationSignal,
100 preference: NonEmptyArray<WriteTarget>,
101): Promise<ConsumeOutcome> => {
102 let buffer = '';
103 let applied = 0;
104 let expected = DEFAULT_EXPECTED_SETTINGS;
105 let parseErrors = 0;
106 let writeErrors = 0;
107 let lastParseError = '';
108 let lastConfigError: ConfigError | undefined;
109 let currentMessage = INITIAL_MESSAGE;
110 let lastMessageAt = neverShown;
111 
112 const reportProgress = (): void =>
113 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
114 
115 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
116 const result = await caps.config.applySetting(setting, preference);
117 if (result._tag === 'Ok') {
118 applied += 1;
119 reportProgress();
120 return 'ok';
121 }
122 // A write failure is environmental, not a model-output problem — track it on
123 // its own budget and log it, rather than spending the malformed-line tolerance.
124 writeErrors += 1;
125 lastConfigError = result.error;
126 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
127 return 'error';
⋯ 30 lines hidden (lines 128–157)
128 };
129 
130 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
131 matchTag(directive, {
132 Count: async ({ total }): Promise<ApplyStatus> => {
133 expected = total;
134 currentMessage = `🎯 Planning ${total} theme settings...`;
135 reportProgress();
136 return 'ok';
137 },
138 Message: async ({ text }): Promise<ApplyStatus> => {
139 const now: Millis = caps.clock.now();
140 if (shouldShowMessage(lastMessageAt, now)) {
141 currentMessage = `${text}`;
142 lastMessageAt = markShown(now);
143 reporter.report({ message: currentMessage, percent: none });
144 }
145 return 'ok';
146 },
147 Selector: ({ selector, color }): Promise<ApplyStatus> =>
148 applySetting({ _tag: 'SelectorSetting', selector, color }),
149 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
150 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
151 });
152 
153 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
154 if (line.trim() === '') {
155 return null;
156 }
157 const parsed = parseLine(line);
158 if (parsed._tag === 'Ok') {
159 // Write failures are counted inside applySetting, on their own budget.
160 await applyDirective(parsed.value);
161 } else {
162 parseErrors += 1;
163 lastParseError = renderParseError(parsed.error);
164 caps.logger.debug('directive parse failed', { line, error: lastParseError });
165 }
166 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
167 return { _tag: 'Aborted', applied, lastError: lastParseError };
168 }
169 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
170 return { _tag: 'WriteFailed', applied, error: lastConfigError };
171 }
172 return null;
⋯ 175 lines hidden (lines 173–347)
173 };
174 
175 try {
176 for await (const chunk of stream) {
177 if (signal.isCancelled()) {
178 break;
179 }
180 buffer += chunk;
181 const segments = buffer.split('\n');
182 buffer = segments.pop() ?? '';
183 for (const line of segments) {
184 if (signal.isCancelled()) {
185 break;
186 }
187 const aborted = await handleLine(line);
188 if (aborted !== null) {
189 return aborted;
190 }
191 }
192 }
193 } catch (e) {
194 // The stream threw after opening (provider error mid-generation). Adapters
195 // re-throw a classified ProviderError; anything else becomes Unexpected.
196 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
197 return { _tag: 'StreamFailed', applied, error };
198 }
199 
200 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
201 if (!signal.isCancelled() && buffer.trim() !== '') {
202 const parsed = parseLine(buffer);
203 if (parsed._tag === 'Ok') {
204 await applyDirective(parsed.value);
205 }
206 }
207 
208 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
209};
210 
211// ── Finalization: keep/reset modals after the progress notification closes ─────
212 
213const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
214 result._tag === 'Ok' ? result.value : 'keep';
215 
216const finalize = (
217 caps: Capabilities,
218 consumed: ConsumeOutcome,
219): AsyncResultType<GenerationError, GenerationOutcome> =>
220 matchTag(consumed, {
221 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
222 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
223 
224 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
225 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
226 
227 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
228 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
229 
230 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
231 const choice = await caps.ui.announceCompletion({
232 applied,
233 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
234 });
235 if (decideKeepOrReset(choice) === 'reset') {
236 await resetQuietly(caps);
237 }
238 return ok({ _tag: 'Completed', applied });
239 },
240 
241 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
242 const choice = await caps.ui.confirmCancellation(applied);
243 if (decideKeepOrReset(choice) === 'reset') {
244 await resetQuietly(caps);
245 return ok({ _tag: 'CancelledReset' });
246 }
247 return ok({ _tag: 'CancelledKept', applied });
248 },
249 });
250 
251const resetQuietly = async (caps: Capabilities): Promise<void> => {
252 const result = await caps.config.reset();
253 if (result._tag === 'Err') {
254 caps.logger.error('reset after generation failed', {
255 error: renderConfigError(result.error).title,
256 });
257 }
258};
259 
260// ── The use case ───────────────────────────────────────────────────────────────
261 
262const runGeneration = async (
263 caps: Capabilities,
264 model: Model,
265 key: Redacted<ApiKey>,
266 vibe: Vibe,
267 system: NonEmptyString,
268): AsyncResultType<GenerationError, GenerationOutcome> => {
269 const current = caps.config.readCurrentTheme();
270 const user = buildUserPrompt(current, vibe);
271 const preference = writePreference(caps.config.hasWorkspaceFolders());
272 
273 const streamResult = await caps.gateway.streamTheme({
274 provider: model.provider,
275 key,
276 model: model.id,
277 system,
278 user,
279 });
280 if (streamResult._tag === 'Err') {
281 return err({ _tag: 'Provider', error: streamResult.error });
282 }
283 
284 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
285 reporter.report({ message: INITIAL_MESSAGE, percent: none });
286 return consumeStream(caps, streamResult.value, reporter, signal, preference);
287 });
288 
289 return finalize(caps, consumed);
290};
291 
292export const generateTheme = async (
293 caps: Capabilities,
294): AsyncResultType<GenerationError, GenerationOutcome> => {
295 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
296 
297 const keyResult = await provisionApiKey(caps, model.provider);
298 if (keyResult._tag === 'Err') {
299 return keyResult.error._tag === 'Cancelled'
300 ? ok({ _tag: 'NoKey' })
301 : err({ _tag: 'Provision', error: keyResult.error });
302 }
303 
304 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
305 if (vibeResult._tag === 'Err') {
306 return err({ _tag: 'Ui', error: vibeResult.error });
307 }
308 if (vibeResult.value._tag === 'None') {
309 return ok({ _tag: 'NoVibe' });
310 }
311 
312 const promptResult = await caps.prompts.systemPrompt();
313 if (promptResult._tag === 'Err') {
314 return err({ _tag: 'Prompt', error: promptResult.error });
315 }
316 
317 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
318};
319 
320export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
321 matchTag(e, {
322 Provision: ({ error }) => renderProvisionError(error),
323 Ui: ({ error }) => some(renderUiError(error)),
324 Prompt: ({ error }) => some(renderPromptError(error)),
325 Provider: ({ error }) => some(renderProviderError(error)),
326 StreamInterrupted: ({ applied, error }) =>
327 some(
328 userMessage(renderProviderError(error).title, {
329 detail: `Generation stopped after applying ${applied} settings.`,
330 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
331 }),
332 ),
333 WriteAborted: ({ applied, error }) =>
334 some(
335 userMessage(renderConfigError(error).title, {
336 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
337 suggestion: 'Check VS Code permissions and try restarting the editor.',
338 }),
339 ),
340 Aborted: ({ applied, lastError }) =>
341 some(
342 userMessage('⚠️ Theme generation was interrupted', {
343 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
344 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
345 }),
346 ),
347 });

WriteFailed -> WriteAborted, rendered as a settings/permissions problem.

src/application/generateTheme.ts · 347 lines
src/application/generateTheme.ts347 lines · TypeScript
⋯ 226 lines hidden (lines 1–226)
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 type ConfigError,
40 isProviderError,
41 type KeepOrReset,
42 type Millis,
43 type ProgressReporter,
44 type PromptError,
45 type ProviderError,
46 renderConfigError,
47 renderProviderError,
48 renderPromptError,
49 renderUiError,
50 type UiError,
51 type UserMessage,
52 userMessage,
53} from '../ports';
54import { buildUserPrompt } from './context';
55import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
56import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
57import { curatedSuggestions } from './suggestions';
58 
59// Tolerance for malformed *model output* (a parse-quality signal).
60const MAX_RECOVERABLE_ERRORS = 5;
61// Tolerance for *settings-write* failures (an environment/permissions signal,
62// kept separate so an IO problem isn't misreported as bad model output).
63const MAX_WRITE_ERRORS = 5;
64const DEFAULT_EXPECTED_SETTINGS = 120;
65const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
66const PROGRESS_TITLE = 'Vibe Themer';
67 
68export type GenerationOutcome =
69 | { readonly _tag: 'NoKey' }
70 | { readonly _tag: 'NoVibe' }
71 | { readonly _tag: 'Completed'; readonly applied: number }
72 | { readonly _tag: 'CancelledKept'; readonly applied: number }
73 | { readonly _tag: 'CancelledReset' };
74 
75export type GenerationError =
76 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
77 | { readonly _tag: 'Ui'; readonly error: UiError }
78 | { readonly _tag: 'Prompt'; readonly error: PromptError }
79 | { readonly _tag: 'Provider'; readonly error: ProviderError }
80 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
81 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
82 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
83 
84type ApplyStatus = 'ok' | 'error';
85 
86type ConsumeOutcome =
87 | { readonly _tag: 'Completed'; readonly applied: number }
88 | { readonly _tag: 'Cancelled'; readonly applied: number }
89 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
90 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
91 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
92 
93// ── The streaming consume loop ─────────────────────────────────────────────────
94 
95const consumeStream = async (
96 caps: Capabilities,
97 stream: AsyncIterable<string>,
98 reporter: ProgressReporter,
99 signal: CancellationSignal,
100 preference: NonEmptyArray<WriteTarget>,
101): Promise<ConsumeOutcome> => {
102 let buffer = '';
103 let applied = 0;
104 let expected = DEFAULT_EXPECTED_SETTINGS;
105 let parseErrors = 0;
106 let writeErrors = 0;
107 let lastParseError = '';
108 let lastConfigError: ConfigError | undefined;
109 let currentMessage = INITIAL_MESSAGE;
110 let lastMessageAt = neverShown;
111 
112 const reportProgress = (): void =>
113 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
114 
115 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
116 const result = await caps.config.applySetting(setting, preference);
117 if (result._tag === 'Ok') {
118 applied += 1;
119 reportProgress();
120 return 'ok';
121 }
122 // A write failure is environmental, not a model-output problem — track it on
123 // its own budget and log it, rather than spending the malformed-line tolerance.
124 writeErrors += 1;
125 lastConfigError = result.error;
126 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
127 return 'error';
128 };
129 
130 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
131 matchTag(directive, {
132 Count: async ({ total }): Promise<ApplyStatus> => {
133 expected = total;
134 currentMessage = `🎯 Planning ${total} theme settings...`;
135 reportProgress();
136 return 'ok';
137 },
138 Message: async ({ text }): Promise<ApplyStatus> => {
139 const now: Millis = caps.clock.now();
140 if (shouldShowMessage(lastMessageAt, now)) {
141 currentMessage = `${text}`;
142 lastMessageAt = markShown(now);
143 reporter.report({ message: currentMessage, percent: none });
144 }
145 return 'ok';
146 },
147 Selector: ({ selector, color }): Promise<ApplyStatus> =>
148 applySetting({ _tag: 'SelectorSetting', selector, color }),
149 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
150 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
151 });
152 
153 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
154 if (line.trim() === '') {
155 return null;
156 }
157 const parsed = parseLine(line);
158 if (parsed._tag === 'Ok') {
159 // Write failures are counted inside applySetting, on their own budget.
160 await applyDirective(parsed.value);
161 } else {
162 parseErrors += 1;
163 lastParseError = renderParseError(parsed.error);
164 caps.logger.debug('directive parse failed', { line, error: lastParseError });
165 }
166 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
167 return { _tag: 'Aborted', applied, lastError: lastParseError };
168 }
169 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
170 return { _tag: 'WriteFailed', applied, error: lastConfigError };
171 }
172 return null;
173 };
174 
175 try {
176 for await (const chunk of stream) {
177 if (signal.isCancelled()) {
178 break;
179 }
180 buffer += chunk;
181 const segments = buffer.split('\n');
182 buffer = segments.pop() ?? '';
183 for (const line of segments) {
184 if (signal.isCancelled()) {
185 break;
186 }
187 const aborted = await handleLine(line);
188 if (aborted !== null) {
189 return aborted;
190 }
191 }
192 }
193 } catch (e) {
194 // The stream threw after opening (provider error mid-generation). Adapters
195 // re-throw a classified ProviderError; anything else becomes Unexpected.
196 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
197 return { _tag: 'StreamFailed', applied, error };
198 }
199 
200 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
201 if (!signal.isCancelled() && buffer.trim() !== '') {
202 const parsed = parseLine(buffer);
203 if (parsed._tag === 'Ok') {
204 await applyDirective(parsed.value);
205 }
206 }
207 
208 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
209};
210 
211// ── Finalization: keep/reset modals after the progress notification closes ─────
212 
213const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
214 result._tag === 'Ok' ? result.value : 'keep';
215 
216const finalize = (
217 caps: Capabilities,
218 consumed: ConsumeOutcome,
219): AsyncResultType<GenerationError, GenerationOutcome> =>
220 matchTag(consumed, {
221 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
222 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
223 
224 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
225 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
226 
227 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
228 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
⋯ 104 lines hidden (lines 229–332)
229 
230 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
231 const choice = await caps.ui.announceCompletion({
232 applied,
233 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
234 });
235 if (decideKeepOrReset(choice) === 'reset') {
236 await resetQuietly(caps);
237 }
238 return ok({ _tag: 'Completed', applied });
239 },
240 
241 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
242 const choice = await caps.ui.confirmCancellation(applied);
243 if (decideKeepOrReset(choice) === 'reset') {
244 await resetQuietly(caps);
245 return ok({ _tag: 'CancelledReset' });
246 }
247 return ok({ _tag: 'CancelledKept', applied });
248 },
249 });
250 
251const resetQuietly = async (caps: Capabilities): Promise<void> => {
252 const result = await caps.config.reset();
253 if (result._tag === 'Err') {
254 caps.logger.error('reset after generation failed', {
255 error: renderConfigError(result.error).title,
256 });
257 }
258};
259 
260// ── The use case ───────────────────────────────────────────────────────────────
261 
262const runGeneration = async (
263 caps: Capabilities,
264 model: Model,
265 key: Redacted<ApiKey>,
266 vibe: Vibe,
267 system: NonEmptyString,
268): AsyncResultType<GenerationError, GenerationOutcome> => {
269 const current = caps.config.readCurrentTheme();
270 const user = buildUserPrompt(current, vibe);
271 const preference = writePreference(caps.config.hasWorkspaceFolders());
272 
273 const streamResult = await caps.gateway.streamTheme({
274 provider: model.provider,
275 key,
276 model: model.id,
277 system,
278 user,
279 });
280 if (streamResult._tag === 'Err') {
281 return err({ _tag: 'Provider', error: streamResult.error });
282 }
283 
284 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
285 reporter.report({ message: INITIAL_MESSAGE, percent: none });
286 return consumeStream(caps, streamResult.value, reporter, signal, preference);
287 });
288 
289 return finalize(caps, consumed);
290};
291 
292export const generateTheme = async (
293 caps: Capabilities,
294): AsyncResultType<GenerationError, GenerationOutcome> => {
295 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
296 
297 const keyResult = await provisionApiKey(caps, model.provider);
298 if (keyResult._tag === 'Err') {
299 return keyResult.error._tag === 'Cancelled'
300 ? ok({ _tag: 'NoKey' })
301 : err({ _tag: 'Provision', error: keyResult.error });
302 }
303 
304 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
305 if (vibeResult._tag === 'Err') {
306 return err({ _tag: 'Ui', error: vibeResult.error });
307 }
308 if (vibeResult.value._tag === 'None') {
309 return ok({ _tag: 'NoVibe' });
310 }
311 
312 const promptResult = await caps.prompts.systemPrompt();
313 if (promptResult._tag === 'Err') {
314 return err({ _tag: 'Prompt', error: promptResult.error });
315 }
316 
317 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
318};
319 
320export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
321 matchTag(e, {
322 Provision: ({ error }) => renderProvisionError(error),
323 Ui: ({ error }) => some(renderUiError(error)),
324 Prompt: ({ error }) => some(renderPromptError(error)),
325 Provider: ({ error }) => some(renderProviderError(error)),
326 StreamInterrupted: ({ applied, error }) =>
327 some(
328 userMessage(renderProviderError(error).title, {
329 detail: `Generation stopped after applying ${applied} settings.`,
330 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
331 }),
332 ),
333 WriteAborted: ({ applied, error }) =>
334 some(
335 userMessage(renderConfigError(error).title, {
336 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
337 suggestion: 'Check VS Code permissions and try restarting the editor.',
338 }),
339 ),
⋯ 8 lines hidden (lines 340–347)
340 Aborted: ({ applied, lastError }) =>
341 some(
342 userMessage('⚠️ Theme generation was interrupted', {
343 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
344 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
345 }),
346 ),
347 });

The discriminating test

With failApply, six valid directives whose writes all fail must surface WriteAborted (a config/IO error), not Aborted (malformed output). Before the split, this produced the wrong, output-blaming message.

Valid directives + failing writes -> WriteAborted, not Aborted.

test/generateTheme.test.ts · 212 lines
test/generateTheme.test.ts212 lines · TypeScript
⋯ 147 lines hidden (lines 1–147)
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 // The gateway is actually asked to stream the Anthropic model, not just the key.
83 assert.equal(h.captured.streamProvider, 'anthropic');
84 assert.equal(h.captured.streamModel, 'claude-sonnet-4-6');
85 assert.equal(h.colors.get('editor.background'), '#101010');
86 if (result._tag === 'Ok') {
87 assert.equal(result.value._tag, 'Completed');
88 }
89 });
90});
91 
92describe('generateTheme — benign exits', () => {
93 it('returns NoVibe when the picker is dismissed', async () => {
94 const h = harness({ storedKey: VALID_KEY });
95 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoVibe' } });
96 });
97 
98 it('returns NoKey when no key is stored and the prompt is dismissed', async () => {
99 const h = harness({ vibe: 'cozy autumn' });
100 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoKey' } });
101 });
102});
103 
104describe('generateTheme — failures', () => {
105 it('surfaces a provider stream error', async () => {
106 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamError: { _tag: 'RateLimited' } });
107 assert.deepEqual(await generateTheme(h.caps), {
108 _tag: 'Err',
109 error: { _tag: 'Provider', error: { _tag: 'RateLimited' } },
110 });
111 });
112 
113 it('surfaces a mid-stream provider error and keeps the partial theme', async () => {
114 // The stream opens, applies some settings, then throws (e.g. a 429 once tokens
115 // flow). Before the fix this escaped as an unhandled rejection; now it is a
116 // typed StreamInterrupted error and the partial theme is left for the user.
117 const h = harness({
118 storedKey: VALID_KEY,
119 vibe: 'cozy',
120 streamText: HAPPY_STREAM,
121 chunkSize: 1000,
122 streamThrowAfter: 1,
123 streamThrowError: { _tag: 'RateLimited' },
124 });
125 const result = await generateTheme(h.caps);
126 
127 assert.equal(result._tag, 'Err');
128 if (result._tag === 'Err' && result.error._tag === 'StreamInterrupted') {
129 assert.equal(result.error.error._tag, 'RateLimited');
130 assert.equal(result.error.applied, 3);
131 } else {
132 assert.fail(`expected StreamInterrupted, got ${JSON.stringify(result)}`);
133 }
134 assert.equal(h.colors.get('editor.background'), '#1a1a1a');
135 assert.equal(h.captured.resets, 0);
136 });
137 
138 it('aborts after too many malformed lines', async () => {
139 const garbage = Array.from({ length: 6 }, (_unused, i) => `GARBAGE:${i}`).join('\n');
140 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamText: garbage });
141 const result = await generateTheme(h.caps);
142 assert.equal(result._tag, 'Err');
143 if (result._tag === 'Err') {
144 assert.equal(result.error._tag, 'Aborted');
145 }
146 });
147 
148 it('reports a write failure distinctly from malformed output', async () => {
149 // Valid directives whose *writes* all fail must abort as a config/IO error,
150 // not as "too many malformed lines" — they never spend the parse budget.
151 const lines = ['COUNT:6'];
152 for (let i = 0; i < 6; i += 1) {
153 lines.push(`SELECTOR:editor.k${i}=#111111`);
154 }
155 const h = harness({
156 storedKey: VALID_KEY,
157 vibe: 'cozy',
158 streamText: `${lines.join('\n')}\n`,
159 chunkSize: 1000,
160 failApply: true,
161 });
162 const result = await generateTheme(h.caps);
163 
164 assert.equal(result._tag, 'Err');
165 if (result._tag === 'Err' && result.error._tag === 'WriteAborted') {
166 assert.equal(result.error.applied, 0);
167 } else {
168 assert.fail(`expected WriteAborted, got ${JSON.stringify(result)}`);
169 }
170 });
⋯ 42 lines hidden (lines 171–212)
171});
172 
173describe('generateTheme — cancellation and reset', () => {
174 it('on cancel + reset, clears the theme', async () => {
175 const h = harness({
176 storedKey: VALID_KEY,
177 vibe: 'cozy',
178 streamText: HAPPY_STREAM,
179 cancelAfterReports: 2,
180 cancellationChoice: 'reset',
181 });
182 const result = await generateTheme(h.caps);
183 if (result._tag === 'Ok') {
184 assert.equal(result.value._tag, 'CancelledReset');
185 }
186 assert.ok(h.captured.resets >= 1);
187 });
188 
189 it('on success + reset choice, clears the theme', async () => {
190 const h = harness({
191 storedKey: VALID_KEY,
192 vibe: 'cozy',
193 streamText: HAPPY_STREAM,
194 completionChoice: 'reset',
195 });
196 const result = await generateTheme(h.caps);
197 if (result._tag === 'Ok') {
198 assert.equal(result.value._tag, 'Completed');
199 }
200 assert.equal(h.captured.resets, 1);
201 });
202});
203 
204describe('generateTheme — secret safety', () => {
205 it('never lets the raw API key reach logs or notifications', async () => {
206 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn', streamText: HAPPY_STREAM });
207 await generateTheme(h.caps);
208 const serialized = JSON.stringify(h.captured);
209 assert.equal(serialized.includes('sk-'), false);
210 assert.equal(serialized.includes('x'.repeat(40)), false);
211 });
212});