diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index a1456181c..bc0253963 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -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. @@ -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) { @@ -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.'), diff --git a/src/test/pages/AskStreamFallback.test.tsx b/src/test/pages/AskStreamFallback.test.tsx new file mode 100644 index 000000000..5cb974a00 --- /dev/null +++ b/src/test/pages/AskStreamFallback.test.tsx @@ -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()), + 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(); + 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'); + }); +});