diff --git a/src/lib/chat/chatStream.svelte.test.ts b/src/lib/chat/chatStream.svelte.test.ts index f05282c8..e57d1a4d 100644 --- a/src/lib/chat/chatStream.svelte.test.ts +++ b/src/lib/chat/chatStream.svelte.test.ts @@ -59,9 +59,62 @@ describe('createChatStream', () => { await chat.send('hi'); expect(chat.messages[1].status).toBe('error'); expect(chat.messages[1].error).toMatch(/timed out/); + expect(chat.messages[1].errorCode).toBe('gateway_timeout'); expect(chat.status).toBe('error'); }); + it('captures code + message from a typed error frame', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + streamResponse([ + 'data: {"type":"start","lq_ai_message_id":"a1","chat_id":"c1"}\n\n', + 'data: {"type":"error","code":"upstream_error","message":"Provider rejected the request"}\n\n' + ]) + ) + ); + const chat = createChatStream('c1'); + await chat.send('hi'); + expect(chat.messages[1]).toMatchObject({ + status: 'error', + error: 'Provider rejected the request', + errorCode: 'upstream_error' + }); + expect(chat.status).toBe('error'); + }); + + it('surfaces code + message from a non-2xx api error envelope (not only 400)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + detail: { code: 'model_unavailable', message: 'Model smart is not available' } + }), + { status: 503, headers: { 'content-type': 'application/json' } } + ) + ) + ); + const chat = createChatStream('c1'); + await chat.send('hi'); + expect(chat.messages[1]).toMatchObject({ + status: 'error', + error: 'Model smart is not available', + errorCode: 'model_unavailable' + }); + }); + + it('keeps the generic message when the failure body carries no envelope', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('boom', { status: 500 }))); + const chat = createChatStream('c1'); + await chat.send('hi'); + expect(chat.messages[1].status).toBe('error'); + expect(chat.messages[1].error).toMatch(/could not reach the model/i); + expect(chat.messages[1].errorCode).toBeUndefined(); + }); + it('marks the assistant done (keeps partial text) when aborted', async () => { vi.stubGlobal( 'fetch', @@ -107,6 +160,9 @@ describe('createChatStream', () => { content: 'ok now', status: 'done' }); + // The previous failure's error + code are fully cleared by the retry. + expect(chat.messages[1].error).toBeUndefined(); + expect(chat.messages[1].errorCode).toBeUndefined(); expect(chat.status).toBe('idle'); }); diff --git a/src/lib/chat/chatStream.svelte.ts b/src/lib/chat/chatStream.svelte.ts index 1353f2e4..ffb20092 100644 --- a/src/lib/chat/chatStream.svelte.ts +++ b/src/lib/chat/chatStream.svelte.ts @@ -23,6 +23,9 @@ export interface ChatMessage { routed_inference_tier?: number | null; status?: 'streaming' | 'done' | 'error' | 'awaiting_confirmation' | 'awaiting_auth'; error?: string; + /** Machine-readable code accompanying `error` when the backend supplied one + * (SSE error-frame `code` / api error-envelope `detail.code`). */ + errorCode?: string; citations?: Citation[]; /** External-source provenance (case law consulted), lazy-fetched post-stream (PR6c). */ sources?: ToolSource[]; @@ -54,9 +57,10 @@ export function createChatStream(chatId: string, initial: ChatMessage[] = []) { let status = $state<'idle' | 'streaming' | 'error'>('idle'); let controller: AbortController | null = null; - function setError(idx: number, msg: string) { + function setError(idx: number, msg: string, code?: string) { messages[idx].status = 'error'; messages[idx].error = msg; + messages[idx].errorCode = code; status = 'error'; } @@ -95,7 +99,7 @@ export function createChatStream(chatId: string, initial: ChatMessage[] = []) { m.mcpAuth = { server: frame.server, authorize_url: frame.authorize_url }; m.status = 'awaiting_auth'; } else if (frame.type === 'error') { - setError(idx, frame.message); + setError(idx, frame.message, frame.code); } } @@ -274,16 +278,23 @@ export function createChatStream(chatId: string, initial: ChatMessage[] = []) { signal: controller.signal }); if (!res.ok || !res.body) { + // Surface the backend's own error envelope (FastAPI `detail` — a plain + // string or {code, message}) instead of a canned line when one is present. let msg = 'Could not reach the model. Please try again.'; - if (res.status === 400) { - try { - const env = (await res.json()) as { detail?: unknown }; - if (typeof env.detail === 'string' && env.detail) msg = env.detail; - } catch { - /* keep the generic message */ + let code: string | undefined; + try { + const env = (await res.json()) as { detail?: unknown }; + if (typeof env.detail === 'string' && env.detail) { + msg = env.detail; + } else if (env.detail && typeof env.detail === 'object') { + const d = env.detail as { code?: unknown; message?: unknown }; + if (typeof d.message === 'string' && d.message) msg = d.message; + if (typeof d.code === 'string' && d.code) code = d.code; } + } catch { + /* keep the generic message */ } - setError(idx, msg); + setError(idx, msg, code); return false; } accepted = true; // POST accepted — set_sticky (if any) reached the backend @@ -294,7 +305,14 @@ export function createChatStream(chatId: string, initial: ChatMessage[] = []) { messages[idx].status = 'done'; status = 'idle'; } else { - setError(idx, 'The connection was lost. Please try again.'); + // Keep the underlying failure visible instead of hiding it behind a canned line. + const detail = (e as Error).message; + setError( + idx, + detail + ? `The connection was lost (${detail}). Please try again.` + : 'The connection was lost. Please try again.' + ); } return accepted; // an abort AFTER acceptance still dispatched set_sticky } finally { @@ -346,6 +364,7 @@ export function createChatStream(chatId: string, initial: ChatMessage[] = []) { if (idx < 0 || messages[idx].role !== 'assistant') return; messages[idx].content = ''; messages[idx].error = undefined; + messages[idx].errorCode = undefined; messages[idx].routed_inference_tier = undefined; messages[idx].citations = undefined; messages[idx].sources = undefined; @@ -369,6 +388,7 @@ export function createChatStream(chatId: string, initial: ChatMessage[] = []) { if (!pendingId) return; m.confirmation = undefined; m.error = undefined; + m.errorCode = undefined; m.status = 'streaming'; status = 'streaming'; controller = new AbortController(); diff --git a/src/lib/components/Message.svelte b/src/lib/components/Message.svelte index 4b7cca20..05bf640b 100644 --- a/src/lib/components/Message.svelte +++ b/src/lib/components/Message.svelte @@ -97,9 +97,9 @@ {/if} - {#if message.status === 'error'} + {#if message.status === 'error' || (message.status === 'done' && message.error && message.content.trim() === '')}

- ⚠ {message.error} + ⚠ {message.errorCode ? `[${message.errorCode}] ` : ''}{message.error}