Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/pages/AskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,6 @@ export default function AskPage() {
const askStartTime = Date.now();

if (!workspace.context && isAskStreamingSupported(config, { ...options, mode })) {
let receivedStreamText = false;
let partialResponse = '';
const streamingMessage = {
// The stored reply reuses this id, so the bubble the user is reading never remounts.
Expand All @@ -258,7 +257,6 @@ export default function AskPage() {
result = await streamAskChat(fullMessage, history, mode, config, options, {
onDelta: (text) => {
if (!isCurrentRequest()) return;
receivedStreamText = true;
partialResponse += text;
pendingText += text;
if (!flushTimer) {
Expand All @@ -282,7 +280,9 @@ export default function AskPage() {
// A newer request or Stop replaced this one; Stop keeps the partial itself.
return true;
}
if (receivedStreamText) {
// Whitespace alone is not an answer: a model that spent its cap on
// reasoning may send a newline first. Ask the structured endpoint then.
if (partialResponse.trim()) {
toast({
title: t('ask.streamInterrupted', 'Response was interrupted'),
description: t('ask.streamInterruptedDesc', 'You can ask again to get a complete answer.'),
Expand Down
65 changes: 65 additions & 0 deletions src/test/pages/AskStreamFallback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';

Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: vi.fn(), writable: true });

const mocks = vi.hoisted(() => ({
streamed: '',
askChat: vi.fn(),
}));

vi.mock('@/lib/supabase/AuthProvider', () => ({
useAuth: () => ({ user: { id: 'user-a' } }),
}));

vi.mock('@/lib/ai/client', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/ai/client')>()),
askChat: mocks.askChat,
}));

vi.mock('@/components/features/subscription', () => ({
LimitReachedModal: (): null => null,
ModelRestrictedModal: (): null => null,
PricingModal: (): null => null,
}));

// The stream sends `mocks.streamed`, then fails the way ai-stream does when
// a reply is cut off at its cap before any real text.
vi.mock('@/lib/ai/streaming', () => ({
isAskStreamingSupported: () => true,
streamAskChat: vi.fn(async (_message: string, _history: unknown, _mode: string, _config: unknown, _options: unknown, handlers: { onDelta: (text: string) => void }) => {
if (mocks.streamed) handlers.onDelta(mocks.streamed);
throw new Error('AI streaming failed. Please try again.');
}),
}));

import AskPage from '@/pages/AskPage';
import { useSessionStore } from '@/lib/stores/session-store';
import { enableOneOffMode } from '@/test/helpers/debate';

async function ask(streamed: string) {
mocks.streamed = streamed;
mocks.askChat.mockReset();
mocks.askChat.mockResolvedValue({ response: 'Structured answer', followUpQuestions: [], uncertainties: [] });
enableOneOffMode();
useSessionStore.getState().clearAskMessages();
render(<MemoryRouter initialEntries={['/app/ask']}><AskPage /></MemoryRouter>);
fireEvent.change(screen.getByPlaceholderText('Ask your debate coach...'), { target: { value: 'How do I weigh cost?' } });
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
await waitFor(() => expect(useSessionStore.getState().askLoading).toBe(false));
}

describe('a failed Ask stream', () => {
it('asks the structured endpoint when the stream sent only whitespace', async () => {
await ask('\n\n');
expect(mocks.askChat).toHaveBeenCalledTimes(1);
expect(useSessionStore.getState().askMessages.at(-1)?.content).toBe('Structured answer');
});

it('keeps a partial answer instead of asking again', async () => {
await ask('Weigh access first');
expect(mocks.askChat).not.toHaveBeenCalled();
expect(useSessionStore.getState().askMessages.at(-1)?.content).toBe('Weigh access first');
});
});
Loading