With keys stored per provider, "The provider rejected the API key" couldn't tell a Claude user which key was wrong. renderProviderError takes an optional provider and names it; the provider is threaded through the Provision/Generation error variants that carry a ProviderError.
Optional provider -> the vendor name in the title; AuthFailed points at Clear API Keys.
src/ports/errors.ts · 123 lines src/ports/errors.ts 123 lines · TypeScript expand all
⋯ 70 lines hidden (lines 1–70) 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). 8 import { matchTag , none , type OptionType , some } from '../fp' ; 9 import { type Provider , providerInfo } from '../domain/provider' ; 10 import { type WriteTarget } from '../domain/scope' ; 12 export type Severity = 'info' | 'warning' | 'error' ; 14 export interface UserMessage { 15 readonly title : string ; 16 readonly detail : OptionType < string > ; 17 readonly suggestion : OptionType < string > ; 20 export const userMessage = ( 22 options : { readonly suggestion? : string ; readonly detail? : string } = { } , 25 detail : options.detail === undefined ? none : some ( options . detail ) , 26 suggestion : options.suggestion === undefined ? none : some ( options . suggestion ) , 29 // ── Port error unions ───────────────────────────────────────────────────────── 31 export type StorageError = { 32 readonly _tag : 'StorageFailure' ; 33 readonly operation : 'read' | 'write' | 'clear' ; 36 export type PromptError = { readonly _tag : 'PromptUnavailable' } | { readonly _tag : 'PromptEmpty' } ; 38 export type ProviderError = 39 | { readonly _tag : 'AuthFailed' } 40 | { readonly _tag : 'RateLimited' } 41 | { readonly _tag : 'Network' } 42 | { readonly _tag : 'Unexpected' ; readonly detail : string } ; 44 const PROVIDER_ERROR_TAGS : ReadonlyArray < ProviderError [ '_tag' ] > = [ 52 * A stream can only fail by *throwing*, so a mid-stream provider failure reaches the 53 * consumer as a thrown value. Adapters classify it to a `ProviderError` before 54 * re-throwing; this guard lets the application turn that thrown value back into a 55 * typed error instead of letting it escape as an unhandled rejection. 57 export const isProviderError = ( e : unknown ) : e is ProviderError => 58 typeof e === 'object' && 61 ( PROVIDER_ERROR_TAGS as ReadonlyArray < string > ) . includes ( ( e as { readonly _tag : string } ) . _tag ) ; 63 export type ConfigError = 64 | { readonly _tag : 'WriteFailed' ; readonly target : WriteTarget } 65 | { readonly _tag : 'AllTargetsFailed' } ; 67 export type UiError = { readonly _tag : 'UiFailure' } ; 69 // ── Rendering ───────────────────────────────────────────────────────────────── 71 // `provider`, when known, names which vendor failed — keys are stored per provider, 72 // so "Anthropic rejected the API key" is actionable where "the provider" is not. 73 export const renderProviderError = ( e : ProviderError , provider? : Provider ) : UserMessage => { 74 const name = provider !== undefined ? providerInfo ( provider ) .displayName : undefined ; 77 userMessage ( ` 🔑 ${ name ?? 'The provider' } rejected the API key ` , { 78 suggestion : 'Run "Clear API Keys", then enter a valid key and check your account access.' , 81 userMessage ( ` 🔑 ${ name ?? 'Provider' } rate limit or quota reached ` , { 82 suggestion : 'Check your plan and usage on the provider dashboard, then try again.' , 85 userMessage ( ` 🌐 Could not reach ${ name ?? 'the model provider' } ` , { 86 suggestion : 'Check your internet connection and try again.' , 88 Unexpected : ( { detail } ) => 89 userMessage ( '❌ The model request failed' , { detail , suggestion : 'Please try again.' } ) , ⋯ 32 lines hidden (lines 92–123) 93 export const renderPromptError = ( e : PromptError ) : UserMessage => 95 PromptUnavailable : ( ) => 96 userMessage ( '❌ Could not load the theme prompt' , { 97 suggestion : 'Try reinstalling Vibe Themer.' , 100 userMessage ( '❌ The theme prompt is empty' , { 101 suggestion : 'Try reinstalling Vibe Themer.' , 105 export const renderConfigError = ( e : ConfigError ) : UserMessage => 107 WriteFailed : ( { target } ) => 108 userMessage ( ` ❌ Could not write theme to ${ target } settings ` , { 109 suggestion : 'Check VS Code permissions and try restarting the editor.' , 111 AllTargetsFailed : ( ) => 112 userMessage ( '❌ Could not apply the theme to any settings scope' , { 113 suggestion : 'Check VS Code permissions and try restarting the editor.' , 117 export const renderStorageError = ( e : StorageError ) : UserMessage => 118 userMessage ( ` ❌ Secure storage ${ e . operation } failed ` , { 119 suggestion : 'Try restarting VS Code.' , 122 export const renderUiError = ( _e : UiError ) : UserMessage => 123 userMessage ( '❌ A UI operation failed unexpectedly' , { suggestion : 'Please try again.' } ) ;
The provider is passed through at render time.
src/application/generateTheme.ts · 358 lines src/application/generateTheme.ts 358 lines · TypeScript expand all
⋯ 335 lines hidden (lines 1–335) 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. 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`. 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 28 import { type ApiKey } from '../domain/apiKey' ; 29 import { coverage } from '../domain/coverage' ; 30 import { type StreamingDirective } from '../domain/directive' ; 31 import { DEFAULT_MODEL , type Model , modelText } from '../domain/model' ; 32 import { type Provider , providerInfo } from '../domain/provider' ; 33 import { type WriteTarget , writePreference } from '../domain/scope' ; 34 import { type ThemeSetting } from '../domain/theme' ; 35 import { type Vibe } from '../domain/vibe' ; 36 import { parseLine , renderParseError } from '../protocol/streamingParser' ; 38 type CancellationSignal , 44 type ProgressReporter , 55 import { buildUserPrompt } from './context' ; 56 import { markShown , neverShown , progressPercent , shouldShowMessage } from './progress' ; 57 import { type ProvisionError , provisionApiKey , renderProvisionError } from './provisionApiKey' ; 58 import { curatedSuggestions } from './suggestions' ; 60 // Tolerance for malformed *model output* (a parse-quality signal). 61 const MAX_RECOVERABLE_ERRORS = 5 ; 62 // Tolerance for *settings-write* failures (an environment/permissions signal, 63 // kept separate so an IO problem isn't misreported as bad model output). 64 const MAX_WRITE_ERRORS = 5 ; 65 const DEFAULT_EXPECTED_SETTINGS = 120 ; 66 const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...' ; 67 const PROGRESS_TITLE = 'Vibe Themer' ; 69 export type GenerationOutcome = 70 | { readonly _tag : 'NoKey' } 71 | { readonly _tag : 'NoVibe' } 72 | { readonly _tag : 'Completed' ; readonly applied : number } 73 | { readonly _tag : 'CancelledKept' ; readonly applied : number } 74 | { readonly _tag : 'CancelledReset' } ; 76 export type GenerationError = 77 | { readonly _tag : 'Provision' ; readonly error : ProvisionError } 78 | { readonly _tag : 'Ui' ; readonly error : UiError } 79 | { readonly _tag : 'Prompt' ; readonly error : PromptError } 80 | { readonly _tag : 'Provider' ; readonly error : ProviderError ; readonly provider : Provider } 82 readonly _tag : 'StreamInterrupted' ; 83 readonly applied : number ; 84 readonly error : ProviderError ; 85 readonly provider : Provider ; 87 | { readonly _tag : 'WriteAborted' ; readonly applied : number ; readonly error : ConfigError } 88 | { readonly _tag : 'Aborted' ; readonly applied : number ; readonly lastError : string } ; 90 type ApplyStatus = 'ok' | 'error' ; 93 | { readonly _tag : 'Completed' ; readonly applied : number } 94 | { readonly _tag : 'Cancelled' ; readonly applied : number } 95 | { readonly _tag : 'StreamFailed' ; readonly applied : number ; readonly error : ProviderError } 96 | { readonly _tag : 'WriteFailed' ; readonly applied : number ; readonly error : ConfigError } 97 | { readonly _tag : 'Aborted' ; readonly applied : number ; readonly lastError : string } ; 99 // ── The streaming consume loop ───────────────────────────────────────────────── 101 const consumeStream = async ( 103 stream : AsyncIterable < string > , 104 reporter : ProgressReporter , 105 signal : CancellationSignal , 106 preference : NonEmptyArray < WriteTarget > , 107 ) : Promise < ConsumeOutcome > => { 110 let expected = DEFAULT_EXPECTED_SETTINGS ; 113 let lastParseError = '' ; 114 let lastConfigError : ConfigError | undefined ; 115 let currentMessage = INITIAL_MESSAGE ; 116 let lastMessageAt = neverShown ; 118 const reportProgress = ( ) : void => 119 reporter . report ( { message : currentMessage , percent : some ( progressPercent ( applied , expected ) ) } ) ; 121 const applySetting = async ( setting : ThemeSetting ) : Promise < ApplyStatus > => { 122 const result = await caps . config . applySetting ( setting , preference ) ; 123 if ( result . _tag === 'Ok' ) { 128 // A write failure is environmental, not a model-output problem — track it on 129 // its own budget and log it, rather than spending the malformed-line tolerance. 131 lastConfigError = result . error ; 132 caps . logger . debug ( 'apply setting failed' , { error : renderConfigError ( result . error ) . title } ) ; 136 const applyDirective = ( directive : StreamingDirective ) : Promise < ApplyStatus > => 137 matchTag ( directive , { 138 Count : async ( { total } ) : Promise < ApplyStatus > => { 140 currentMessage = ` 🎯 Planning ${ total } theme settings... ` ; 144 Message : async ( { text } ) : Promise < ApplyStatus > => { 145 const now : Millis = caps . clock . now ( ) ; 146 if ( shouldShowMessage ( lastMessageAt , now ) ) { 147 currentMessage = ` ✨ ${ text } ` ; 148 lastMessageAt = markShown ( now ) ; 149 reporter . report ( { message : currentMessage , percent : none } ) ; 153 Selector : ( { selector , color } ) : Promise < ApplyStatus > => 154 applySetting ( { _tag : 'SelectorSetting' , selector , color } ) , 155 Token : ( { scope , color , fontStyle } ) : Promise < ApplyStatus > => 156 applySetting ( { _tag : 'TokenSetting' , scope , color , fontStyle } ) , 159 const handleLine = async ( line : string ) : Promise < ConsumeOutcome | null > => { 160 if ( line . trim ( ) === '' ) { 163 const parsed = parseLine ( line ) ; 164 if ( parsed . _tag === 'Ok' ) { 165 // Write failures are counted inside applySetting, on their own budget. 166 await applyDirective ( parsed . value ) ; 169 lastParseError = renderParseError ( parsed . error ) ; 170 caps . logger . debug ( 'directive parse failed' , { line , error : lastParseError } ) ; 172 if ( parseErrors >= MAX_RECOVERABLE_ERRORS ) { 173 return { _tag : 'Aborted' , applied , lastError : lastParseError } ; 175 if ( writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined ) { 176 return { _tag : 'WriteFailed' , applied , error : lastConfigError } ; 182 for await ( const chunk of stream ) { 183 if ( signal . isCancelled ( ) ) { 187 const segments = buffer . split ( '\n' ) ; 188 buffer = segments . pop ( ) ?? '' ; 189 for ( const line of segments ) { 190 if ( signal . isCancelled ( ) ) { 193 const aborted = await handleLine ( line ) ; 194 if ( aborted !== null ) { 200 // The stream threw after opening (provider error mid-generation). Adapters 201 // re-throw a classified ProviderError; anything else becomes Unexpected. 202 const error : ProviderError = isProviderError ( e ) ? e : { _tag : 'Unexpected' , detail : String ( e ) } ; 203 return { _tag : 'StreamFailed' , applied , error } ; 206 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors. 207 if ( ! signal . isCancelled ( ) && buffer . trim ( ) !== '' ) { 208 const parsed = parseLine ( buffer ) ; 209 if ( parsed . _tag === 'Ok' ) { 210 await applyDirective ( parsed . value ) ; 214 return signal . isCancelled ( ) ? { _tag : 'Cancelled' , applied } : { _tag : 'Completed' , applied } ; 217 // ── Finalization: keep/reset modals after the progress notification closes ───── 219 const decideKeepOrReset = ( result : ResultType < UiError , KeepOrReset > ) : KeepOrReset => 220 result . _tag === 'Ok' ? result . value : 'keep' ; 224 consumed : ConsumeOutcome , 226 ) : AsyncResultType < GenerationError , GenerationOutcome > => 228 Aborted : ( { applied , lastError } ) : AsyncResultType < GenerationError , GenerationOutcome > => 229 Promise . resolve ( err ( { _tag : 'Aborted' , applied , lastError } ) ) , 231 StreamFailed : ( { applied , error } ) : AsyncResultType < GenerationError , GenerationOutcome > => 232 Promise . resolve ( err ( { _tag : 'StreamInterrupted' , applied , error , provider } ) ) , 234 WriteFailed : ( { applied , error } ) : AsyncResultType < GenerationError , GenerationOutcome > => 235 Promise . resolve ( err ( { _tag : 'WriteAborted' , applied , error } ) ) , 237 Completed : async ( { applied } ) : AsyncResultType < GenerationError , GenerationOutcome > => { 238 const choice = await caps . ui . announceCompletion ( { 240 coverage : coverage ( applied , DEFAULT_EXPECTED_SETTINGS ) , 242 if ( decideKeepOrReset ( choice ) === 'reset' ) { 243 await resetQuietly ( caps ) ; 245 return ok ( { _tag : 'Completed' , applied } ) ; 248 Cancelled : async ( { applied } ) : AsyncResultType < GenerationError , GenerationOutcome > => { 249 const choice = await caps . ui . confirmCancellation ( applied ) ; 250 if ( decideKeepOrReset ( choice ) === 'reset' ) { 251 await resetQuietly ( caps ) ; 252 return ok ( { _tag : 'CancelledReset' } ) ; 254 return ok ( { _tag : 'CancelledKept' , applied } ) ; 258 const resetQuietly = async ( caps : Capabilities ) : Promise < void > => { 259 const result = await caps . config . reset ( ) ; 260 if ( result . _tag === 'Err' ) { 261 caps . logger . error ( 'reset after generation failed' , { 262 error : renderConfigError ( result . error ) . title , 267 // ── The use case ─────────────────────────────────────────────────────────────── 269 const runGeneration = async ( 272 key : Redacted < ApiKey > , 274 system : NonEmptyString , 275 ) : AsyncResultType < GenerationError , GenerationOutcome > => { 276 const current = caps . config . readCurrentTheme ( ) ; 277 const user = buildUserPrompt ( current , vibe ) ; 278 const preference = writePreference ( caps . config . applyTo ( ) , caps . config . hasWorkspaceFolders ( ) ) ; 280 const streamResult = await caps . gateway . streamTheme ( { 281 provider : model.provider , 287 if ( streamResult . _tag === 'Err' ) { 288 return err ( { _tag : 'Provider' , error : streamResult.error , provider : model.provider } ) ; 291 const consumed = await caps . ui . runWithProgress ( PROGRESS_TITLE , ( reporter , signal ) => { 292 reporter . report ( { message : INITIAL_MESSAGE , percent : none } ) ; 293 return consumeStream ( caps , streamResult . value , reporter , signal , preference ) ; 296 return finalize ( caps , consumed , model . provider ) ; 299 export const generateTheme = async ( 301 ) : AsyncResultType < GenerationError , GenerationOutcome > => { 302 const model = Option . getOrElse ( ( ) => DEFAULT_MODEL ) ( caps . preferences . selectedModel ( ) ) ; 303 const modelLabel = ` ${ providerInfo ( model . provider ) . displayName } · ${ modelText ( model . id ) } ` ; 305 // Ask for the vibe first: the user commits to an action (and sees the active 306 // model) before being asked for a credential. Provisioning the key — which does a 307 // verify round-trip — only happens once there's a vibe to act on. 308 const vibeResult = await caps . ui . pickVibe ( curatedSuggestions , modelLabel ) ; 309 if ( vibeResult . _tag === 'Err' ) { 310 return err ( { _tag : 'Ui' , error : vibeResult.error } ) ; 312 if ( vibeResult . value . _tag === 'None' ) { 313 return ok ( { _tag : 'NoVibe' } ) ; 316 const keyResult = await provisionApiKey ( caps , model . provider ) ; 317 if ( keyResult . _tag === 'Err' ) { 318 return keyResult . error . _tag === 'Cancelled' 319 ? ok ( { _tag : 'NoKey' } ) 320 : err ( { _tag : 'Provision' , error : keyResult.error } ) ; 323 const promptResult = await caps . prompts . systemPrompt ( ) ; 324 if ( promptResult . _tag === 'Err' ) { 325 return err ( { _tag : 'Prompt' , error : promptResult.error } ) ; 328 return runGeneration ( caps , model , keyResult . value , vibeResult . value . value , promptResult . value ) ; 331 export const renderGenerationError = ( e : GenerationError ) : Option . Option < UserMessage > => 333 Provision : ( { error } ) => renderProvisionError ( error ) , 334 Ui : ( { error } ) => some ( renderUiError ( error ) ) , 335 Prompt : ( { error } ) => some ( renderPromptError ( error ) ) , 336 Provider : ( { error , provider } ) => some ( renderProviderError ( error , provider ) ) , 337 StreamInterrupted : ( { applied , error , provider } ) => 339 userMessage ( renderProviderError ( error , provider ) . title , { 340 detail : ` Generation stopped after applying ${ applied } settings. ` , 341 suggestion : 'Try again, or run "Reset Theme Customizations" to start fresh.' , 344 WriteAborted : ( { applied , error } ) => ⋯ 14 lines hidden (lines 345–358) 346 userMessage ( renderConfigError ( error ) . title , { 347 detail : ` Stopped after applying ${ applied } settings — the editor kept refusing writes. ` , 348 suggestion : 'Check VS Code permissions and try restarting the editor.' , 351 Aborted : ( { applied , lastError } ) => 353 userMessage ( '⚠️ Theme generation was interrupted' , { 354 detail : ` ${ applied } settings were applied before too many errors (last: ${ lastError } ). ` , 355 suggestion : 'Try again, or run "Reset Theme Customizations" to start fresh.' ,
Vibe-before-key ordering, and the rendered error naming Anthropic.
test/generateTheme.test.ts · 238 lines test/generateTheme.test.ts 238 lines · TypeScript expand all
⋯ 97 lines hidden (lines 1–97) 1 import { describe , it } from 'node:test' ; 2 import assert from 'node:assert/strict' ; 3 import { generateTheme , renderGenerationError } from '../src/application/generateTheme' ; 4 import { makeModel } from '../src/domain/model' ; 5 import { harness } from './support/harness' ; 7 const VALID_KEY = ` sk- ${ 'x' . repeat ( 40 ) } ` ; 11 'MESSAGE:Warming up the editor 🔥' , 12 'SELECTOR:editor.background=#1a1a1a' , 13 'TOKEN:comment=#6a9955,italic' , 14 'SELECTOR:activityBar.background=REMOVE' , 18 describe ( '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 ) ; 23 assert . equal ( result . _tag , 'Ok' ) ; 24 if ( result . _tag === 'Ok' ) { 25 assert . deepEqual ( result . value , { _tag : 'Completed' , applied : 3 } ) ; 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' } } , 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 ) ; 39 assert . equal ( h . colors . has ( 'foo.bar' ) , false ) ; 40 if ( result . _tag === 'Ok' ) { 41 assert . equal ( result . value . _tag , 'Completed' ) ; 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 : { } } , 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 ) ; 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' ) ) ; 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 ) ; 64 assert . equal ( h . captured . keySet , true ) ; 65 if ( result . _tag === 'Ok' ) { 66 assert . equal ( result . value . _tag , 'Completed' ) ; 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' ) ; 74 selectedModel : makeModel ( 'anthropic' , 'claude-sonnet-4-6' ) , 75 promptKey : anthropicKey , 76 vibe : 'calm ocean depths' , 79 const result = await generateTheme ( h . caps ) ; 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' ) ; 92 describe ( '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' } } ) ; 98 it ( 'asks for the vibe before the API key' , async ( ) => { 99 // No key and no vibe: dismissing the vibe picker yields NoVibe, which proves the 100 // vibe step runs before key provisioning (it would otherwise be NoKey). 101 const h = harness ( { } ) ; 102 assert . deepEqual ( await generateTheme ( h . caps ) , { _tag : 'Ok' , value : { _tag : 'NoVibe' } } ) ; 103 assert . equal ( h . captured . keySet , false ) ; 106 it ( 'returns NoKey when no key is stored and the prompt is dismissed' , async ( ) => { 107 const h = harness ( { vibe : 'cozy autumn' } ) ; 108 assert . deepEqual ( await generateTheme ( h . caps ) , { _tag : 'Ok' , value : { _tag : 'NoKey' } } ) ; 112 describe ( 'generateTheme — failures' , ( ) => { 113 it ( 'surfaces a provider stream error' , async ( ) => { 114 const h = harness ( { storedKey : VALID_KEY , vibe : 'cozy' , streamError : { _tag : 'RateLimited' } } ) ; 115 assert . deepEqual ( await generateTheme ( h . caps ) , { 117 error : { _tag : 'Provider' , error : { _tag : 'RateLimited' } , provider : 'openai' } , 121 it ( 'names the provider in the rendered error (multi-provider)' , async ( ) => { 123 selectedModel : makeModel ( 'anthropic' , 'claude-sonnet-4-6' ) , 124 storedKey : ` sk-ant- ${ 'x' . repeat ( 40 ) } ` , 126 streamError : { _tag : 'AuthFailed' } , 128 const result = await generateTheme ( h . caps ) ; 129 assert . equal ( result . _tag , 'Err' ) ; 130 if ( result . _tag === 'Err' ) { 131 const msg = renderGenerationError ( result . error ) ; 132 assert . equal ( msg . _tag , 'Some' ) ; 133 if ( msg . _tag === 'Some' ) { 134 assert . ok ( msg . value . title . includes ( 'Anthropic' ) , msg . value . title ) ; ⋯ 101 lines hidden (lines 138–238) 139 it ( 'surfaces a mid-stream provider error and keeps the partial theme' , async ( ) => { 140 // The stream opens, applies some settings, then throws (e.g. a 429 once tokens 141 // flow). Before the fix this escaped as an unhandled rejection; now it is a 142 // typed StreamInterrupted error and the partial theme is left for the user. 144 storedKey : VALID_KEY , 146 streamText : HAPPY_STREAM , 149 streamThrowError : { _tag : 'RateLimited' } , 151 const result = await generateTheme ( h . caps ) ; 153 assert . equal ( result . _tag , 'Err' ) ; 154 if ( result . _tag === 'Err' && result . error . _tag === 'StreamInterrupted' ) { 155 assert . equal ( result . error . error . _tag , 'RateLimited' ) ; 156 assert . equal ( result . error . applied , 3 ) ; 158 assert . fail ( ` expected StreamInterrupted, got ${ JSON . stringify ( result ) } ` ) ; 160 assert . equal ( h . colors . get ( 'editor.background' ) , '#1a1a1a' ) ; 161 assert . equal ( h . captured . resets , 0 ) ; 164 it ( 'aborts after too many malformed lines' , async ( ) => { 165 const garbage = Array . from ( { length : 6 } , ( _unused , i ) => ` GARBAGE: ${ i } ` ) . join ( '\n' ) ; 166 const h = harness ( { storedKey : VALID_KEY , vibe : 'cozy' , streamText : garbage } ) ; 167 const result = await generateTheme ( h . caps ) ; 168 assert . equal ( result . _tag , 'Err' ) ; 169 if ( result . _tag === 'Err' ) { 170 assert . equal ( result . error . _tag , 'Aborted' ) ; 174 it ( 'reports a write failure distinctly from malformed output' , async ( ) => { 175 // Valid directives whose *writes* all fail must abort as a config/IO error, 176 // not as "too many malformed lines" — they never spend the parse budget. 177 const lines = [ 'COUNT:6' ] ; 178 for ( let i = 0 ; i < 6 ; i += 1 ) { 179 lines . push ( ` SELECTOR:editor.k ${ i } =#111111 ` ) ; 182 storedKey : VALID_KEY , 184 streamText : ` ${ lines . join ( '\n' ) } \n ` , 188 const result = await generateTheme ( h . caps ) ; 190 assert . equal ( result . _tag , 'Err' ) ; 191 if ( result . _tag === 'Err' && result . error . _tag === 'WriteAborted' ) { 192 assert . equal ( result . error . applied , 0 ) ; 194 assert . fail ( ` expected WriteAborted, got ${ JSON . stringify ( result ) } ` ) ; 199 describe ( 'generateTheme — cancellation and reset' , ( ) => { 200 it ( 'on cancel + reset, clears the theme' , async ( ) => { 202 storedKey : VALID_KEY , 204 streamText : HAPPY_STREAM , 205 cancelAfterReports : 2 , 206 cancellationChoice : 'reset' , 208 const result = await generateTheme ( h . caps ) ; 209 if ( result . _tag === 'Ok' ) { 210 assert . equal ( result . value . _tag , 'CancelledReset' ) ; 212 assert . ok ( h . captured . resets >= 1 ) ; 215 it ( 'on success + reset choice, clears the theme' , async ( ) => { 217 storedKey : VALID_KEY , 219 streamText : HAPPY_STREAM , 220 completionChoice : 'reset' , 222 const result = await generateTheme ( h . caps ) ; 223 if ( result . _tag === 'Ok' ) { 224 assert . equal ( result . value . _tag , 'Completed' ) ; 226 assert . equal ( h . captured . resets , 1 ) ; 230 describe ( 'generateTheme — secret safety' , ( ) => { 231 it ( 'never lets the raw API key reach logs or notifications' , async ( ) => { 232 const h = harness ( { storedKey : VALID_KEY , vibe : 'cozy autumn' , streamText : HAPPY_STREAM } ) ; 233 await generateTheme ( h . caps ) ; 234 const serialized = JSON . stringify ( h . captured ) ; 235 assert . equal ( serialized . includes ( 'sk-' ) , false ) ; 236 assert . equal ( serialized . includes ( 'x' . repeat ( 40 ) ) , false ) ;