Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/lib/chat/chatStream.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});

Expand Down
40 changes: 30 additions & 10 deletions src/lib/chat/chatStream.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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';
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
8 changes: 5 additions & 3 deletions src/lib/components/Message.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@
</span>
{/if}

{#if message.status === 'error'}
{#if message.status === 'error' || (message.status === 'done' && message.error && message.content.trim() === '')}
<p class="text-mlq-error">
⚠ {message.error}
⚠ {message.errorCode ? `[${message.errorCode}] ` : ''}{message.error}
<button
type="button"
onclick={() => onretry?.()}
Expand Down Expand Up @@ -174,7 +174,9 @@
citations={message.citations as Citation[]}
onactivate={onactivatecitation}
/>
{:else if message.status === 'done' && message.content.trim() === '' && !(message.sources && message.sources.length > 0)}
<!-- Canned empty-response hint: only for a genuinely completed stream with no
error and no content — an errored turn renders its real error above. -->
{:else if message.status === 'done' && message.content.trim() === '' && !message.error && !(message.sources && message.sources.length > 0)}
<p class="text-mlq-muted">
The model returned an empty response. This can happen with smaller local models on
requests that need tools. Try again, or switch to a more capable model.
Expand Down
36 changes: 36 additions & 0 deletions src/lib/components/Message.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,42 @@ describe('Message', () => {
expect(retried).toBe(true);
});

it('renders the backend error code alongside its message when present', () => {
const { getByText } = render(Message, {
props: {
message: {
key: 'a1',
id: 'a1',
role: 'assistant',
content: '',
status: 'error',
error: 'Provider rejected the request',
errorCode: 'upstream_error'
}
}
});
expect(getByText(/\[upstream_error\] Provider rejected the request/)).toBeInTheDocument();
});

it('shows the real error, not the canned empty-response hint, when an errored turn ends empty', () => {
const { getByText, queryByText, getByRole } = render(Message, {
props: {
message: {
key: 'a1',
id: 'a1',
role: 'assistant',
content: '',
status: 'done',
error: 'timed out waiting for the model',
errorCode: 'gateway_timeout'
}
}
});
expect(queryByText(/empty response/i)).toBeNull();
expect(getByText(/\[gateway_timeout\] timed out waiting for the model/)).toBeInTheDocument();
expect(getByRole('button', { name: /retry/i })).toBeInTheDocument();
});

it('shows an empty-response fallback with Retry when a done turn has no content', () => {
let retried = false;
const { getByRole, getByText } = render(Message, {
Expand Down