diff --git a/.github/workflows/onboarding-e2e.yml b/.github/workflows/onboarding-e2e.yml index b904fe22..67bda5ab 100644 --- a/.github/workflows/onboarding-e2e.yml +++ b/.github/workflows/onboarding-e2e.yml @@ -17,7 +17,6 @@ # pip install -e "packages/prompt-composer[dev]" # pip install -e "services/convsim-core[dev]" # python -m pytest e2e/onboarding/test_p1_happy_path.py \ -# e2e/onboarding/test_p2_instant_play.py \ # e2e/onboarding/test_p7_regression_loop.py -v name: Onboarding e2e (fast trio) @@ -64,11 +63,11 @@ jobs: - name: Install convsim-core run: pip install -e "services/convsim-core[dev]" - - name: Run onboarding fast trio (P1 / P2 / P7) + # P2 (instant play) was removed with the no-model demo path (issue #473). + - name: Run onboarding fast pair (P1 / P7) run: | - echo "Local: python -m pytest e2e/onboarding/test_p1_happy_path.py e2e/onboarding/test_p2_instant_play.py e2e/onboarding/test_p7_regression_loop.py -v" + echo "Local: python -m pytest e2e/onboarding/test_p1_happy_path.py e2e/onboarding/test_p7_regression_loop.py -v" python -m pytest \ e2e/onboarding/test_p1_happy_path.py \ - e2e/onboarding/test_p2_instant_play.py \ e2e/onboarding/test_p7_regression_loop.py \ -v --tb=short diff --git a/apps/web/src/__tests__/Conversation.test.tsx b/apps/web/src/__tests__/Conversation.test.tsx index e3599620..2d84ec55 100644 --- a/apps/web/src/__tests__/Conversation.test.tsx +++ b/apps/web/src/__tests__/Conversation.test.tsx @@ -1408,109 +1408,26 @@ describe('Conversation screen', () => { }) }) - describe('runtime hint labeling + model-ready toast (issue #383)', () => { + describe('no runtime badges or model-ready toast (issue #473)', () => { afterEach(() => { localStorage.clear() }) - it('labels a scripted session with the runtime badge', async () => { - localStorage.setItem('convsim.active_runtime_hint', 'scripted') - mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) - renderConversation() - await waitFor(() => - expect(screen.getByTestId('runtime-label')).toHaveTextContent(/scripted practice run/i), - ) - }) - - it('completing a background install shows the toast and clears the scripted hint so the next real-AI session is not mislabeled', async () => { + it('renders no runtime badge and no toast even with stale legacy keys present', async () => { + // Old app versions wrote these; they must be inert now. localStorage.setItem('convsim.active_runtime_hint', 'scripted') localStorage.setItem('convsim.tutorial.install_id', '42') mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) - mockApi.getSetupInstallStatus.mockResolvedValue({ - ok: true, - data: { id: 42, status: 'complete', registry_id: 'qwen3-4b-q4', stages: [], error_message: null, created_at: '', updated_at: '' }, - } as never) - - renderConversation() - - await waitFor(() => expect(screen.getByTestId('model-ready-toast')).toBeInTheDocument()) - - // The current scripted session keeps its badge (captured at mount)… - expect(screen.getByTestId('runtime-label')).toHaveTextContent(/scripted practice run/i) - // …but the hint is cleared so a subsequent genuine-AI conversation isn't - // labeled "Scripted practice run" (the real model is now the active runtime). - expect(localStorage.getItem('convsim.active_runtime_hint')).toBeNull() - expect(localStorage.getItem('convsim.tutorial.install_id')).toBeNull() - }) - it('labels a fake session with the "Demo mode" badge', async () => { - localStorage.setItem('convsim.active_runtime_hint', 'fake') - mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) renderConversation() await waitFor(() => - expect(screen.getByTestId('runtime-label')).toHaveTextContent(/demo mode/i), + expect(screen.getByRole('heading', { name: /^conversation$/i })).toBeInTheDocument(), ) - }) - - it('toast "Switch now" clears the runtime hint and navigates to the library', async () => { - localStorage.setItem('convsim.active_runtime_hint', 'scripted') - localStorage.setItem('convsim.tutorial.install_id', '42') - mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) - mockApi.getSetupInstallStatus.mockResolvedValue({ - ok: true, - data: { id: 42, status: 'complete', registry_id: 'qwen3-4b-q4', stages: [], error_message: null, created_at: '', updated_at: '' }, - } as never) - - renderConversation() - - await waitFor(() => expect(screen.getByTestId('model-ready-toast')).toBeInTheDocument()) - fireEvent.click(screen.getByRole('button', { name: /switch now/i })) - await waitFor(() => expect(screen.getByText('Library page')).toBeInTheDocument()) - expect(localStorage.getItem('convsim.active_runtime_hint')).toBeNull() - expect(localStorage.getItem('convsim.tutorial.install_id')).toBeNull() - }) - - it('toast "After this conversation" dismisses the toast without leaving the session', async () => { - localStorage.setItem('convsim.active_runtime_hint', 'scripted') - localStorage.setItem('convsim.tutorial.install_id', '42') - mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) - mockApi.getSetupInstallStatus.mockResolvedValue({ - ok: true, - data: { id: 42, status: 'complete', registry_id: 'qwen3-4b-q4', stages: [], error_message: null, created_at: '', updated_at: '' }, - } as never) - - renderConversation() - - await waitFor(() => expect(screen.getByTestId('model-ready-toast')).toBeInTheDocument()) - fireEvent.click(screen.getByRole('button', { name: /after this conversation/i })) - - await waitFor(() => - expect(screen.queryByTestId('model-ready-toast')).not.toBeInTheDocument(), - ) - // Still in the conversation — the runtime is never swapped mid-scene. - expect(screen.queryByText('Library page')).not.toBeInTheDocument() - }) - - it('a failed background install clears the install-id key and shows no toast', async () => { - localStorage.setItem('convsim.active_runtime_hint', 'scripted') - localStorage.setItem('convsim.tutorial.install_id', '42') - mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) - mockApi.getSetupInstallStatus.mockResolvedValue({ - ok: true, - data: { id: 42, status: 'failed', registry_id: 'qwen3-4b-q4', stages: [], error_message: 'disk full', created_at: '', updated_at: '' }, - } as never) - - renderConversation() - - // The install-id key is cleared so we stop polling a dead job… - await waitFor(() => - expect(localStorage.getItem('convsim.tutorial.install_id')).toBeNull(), - ) - // …no error modal interrupts the scene, and the scripted badge stays put - // because the real model never became active. + expect(screen.queryByTestId('runtime-label')).not.toBeInTheDocument() expect(screen.queryByTestId('model-ready-toast')).not.toBeInTheDocument() - expect(localStorage.getItem('convsim.active_runtime_hint')).toBe('scripted') + // No background-install polling is wired to the legacy key any more. + expect(mockApi.getSetupInstallStatus).not.toHaveBeenCalled() }) }) -}) +}) \ No newline at end of file diff --git a/apps/web/src/__tests__/Debrief.test.tsx b/apps/web/src/__tests__/Debrief.test.tsx index 9aa9ae6f..fc9af7b4 100644 --- a/apps/web/src/__tests__/Debrief.test.tsx +++ b/apps/web/src/__tests__/Debrief.test.tsx @@ -443,43 +443,23 @@ describe('Debrief screen', () => { }) }) - describe('model-ready upgrade CTA (issue #383)', () => { - it('does not render "Try it with the real AI" without the modelReadyAfterTutorial route state', async () => { + describe('no tutorial upgrade CTA (issue #473)', () => { + it('never renders "Try it with the real AI" — every conversation is already the real AI', async () => { mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief() + renderDebrief({ modelReadyAfterTutorial: true, isScripted: true }) await waitFor(() => expect(screen.getByTestId('replay-btn')).toBeInTheDocument(), ) + // Legacy route state (written by old app versions) is inert. expect(screen.queryByTestId('try-real-ai-btn')).not.toBeInTheDocument() }) - - it('renders "Try it with the real AI" when the model became ready during the tutorial', async () => { - mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ modelReadyAfterTutorial: true }) - await waitFor(() => - expect(screen.getByTestId('try-real-ai-btn')).toBeInTheDocument(), - ) - expect(screen.getByTestId('try-real-ai-btn')).toHaveTextContent(/try it with the real ai/i) - }) - - it('navigates to the library when "Try it with the real AI" is clicked', async () => { - mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ modelReadyAfterTutorial: true }) - await waitFor(() => - expect(screen.getByTestId('try-real-ai-btn')).toBeInTheDocument(), - ) - fireEvent.click(screen.getByTestId('try-real-ai-btn')) - await waitFor(() => - expect(screen.getByText('Library page')).toBeInTheDocument(), - ) - }) }) describe('voice invite card (issue #385)', () => { it('shows the voice invite card after a real AI conversation when invite is pending', async () => { mockReadVoiceInviteState.mockReturnValue('pending') mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: false }) + renderDebrief() await waitFor(() => expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(), ) @@ -492,7 +472,7 @@ describe('Debrief screen', () => { // conversation does not re-show it. mockReadVoiceInviteState.mockReturnValue('pending') mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: false }) + renderDebrief() await waitFor(() => expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(), ) @@ -501,20 +481,10 @@ describe('Debrief screen', () => { expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument() }) - it('does not show voice invite card when the session was scripted', async () => { - mockReadVoiceInviteState.mockReturnValue('pending') - mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: true }) - await waitFor(() => - expect(screen.getByTestId('summary-section')).toBeInTheDocument(), - ) - expect(screen.queryByTestId('voice-invite-card')).not.toBeInTheDocument() - }) - it('does not show voice invite card when already dismissed', async () => { mockReadVoiceInviteState.mockReturnValue('dismissed') mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: false }) + renderDebrief() await waitFor(() => expect(screen.getByTestId('summary-section')).toBeInTheDocument(), ) @@ -524,7 +494,7 @@ describe('Debrief screen', () => { it('does not show voice invite card when already in setup state', async () => { mockReadVoiceInviteState.mockReturnValue('setup') mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: false }) + renderDebrief() await waitFor(() => expect(screen.getByTestId('summary-section')).toBeInTheDocument(), ) @@ -534,7 +504,7 @@ describe('Debrief screen', () => { it('"Maybe later" hides the card and persists dismissed state', async () => { mockReadVoiceInviteState.mockReturnValue('pending') mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: false }) + renderDebrief() await waitFor(() => expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(), ) @@ -546,7 +516,7 @@ describe('Debrief screen', () => { it('"Set up voice" hides the card, persists setup state, and navigates to settings', async () => { mockReadVoiceInviteState.mockReturnValue('pending') mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief({ isScripted: false }) + renderDebrief() await waitFor(() => expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(), ) @@ -557,17 +527,6 @@ describe('Debrief screen', () => { ) }) - it('does not show voice invite card when isScripted is absent from route state', async () => { - // No route state at all (e.g. navigated directly) — default is to treat as non-scripted - mockReadVoiceInviteState.mockReturnValue('pending') - mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) - renderDebrief() - await waitFor(() => - expect(screen.getByTestId('summary-section')).toBeInTheDocument(), - ) - // No route state = isScripted defaults false, so invite appears when pending - expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument() - }) }) describe('replay same setup button', () => { diff --git a/apps/web/src/__tests__/FirstRunWizard.test.tsx b/apps/web/src/__tests__/FirstRunWizard.test.tsx index fabf3170..8a10cc95 100644 --- a/apps/web/src/__tests__/FirstRunWizard.test.tsx +++ b/apps/web/src/__tests__/FirstRunWizard.test.tsx @@ -22,6 +22,7 @@ vi.mock('../api/client', () => ({ startSetupInstall: vi.fn(), getSetupInstallStatus: vi.fn(), cancelSetupInstall: vi.fn(), + listScenarios: vi.fn(), createSession: vi.fn().mockResolvedValue({ ok: true, data: { session_id: 'sess-tutorial-1', scenario_id: 'first_words_tutorial', state: 'NotStarted', created_at: '', setup: { scenario_id: 'first_words_tutorial', difficulty: 'standard', player_role_name: 'New Player', language: 'en', input_mode: 'text-only', tts_enabled: false, show_state_meters: true, save_transcript: true, seed: null } } }), }, })) @@ -159,6 +160,40 @@ beforeEach(() => { } }) mockApi.benchmarkModel.mockResolvedValue({ ok: true, data: DEFAULT_BENCHMARK }) mockApi.recordOnboardingOutcome.mockResolvedValue({ ok: true, data: undefined }) + mockApi.listScenarios.mockResolvedValue({ ok: true, data: [ + { + scenario_id: 'salary_negotiation', + title: 'The Salary Conversation', + summary: 'Ask your manager for the raise you have earned.', + content_rating: 'general', + pack_id: 'core', + pack_name: 'Core', + player_role: { label: 'Employee', brief: 'You want a raise.' }, + difficulty: { default: 'standard', options: { standard: {} } }, + supported_languages: ['en'], + duration: { max_turns: 12, soft_time_limit_minutes: 10 }, + state_meters_permitted: true, + voice_supported: true, + safety_summary: '', + estimated_length_label: '10 min', + }, + { + scenario_id: 'first_words_tutorial', + title: 'First Words', + summary: 'Scripted tutorial (internal).', + content_rating: 'general', + pack_id: 'core', + pack_name: 'Core', + player_role: { label: 'New Player', brief: '' }, + difficulty: { default: 'standard', options: { standard: {} } }, + supported_languages: ['en'], + duration: { max_turns: 3, soft_time_limit_minutes: 3 }, + state_meters_permitted: true, + voice_supported: false, + safety_summary: '', + estimated_length_label: '3 min', + }, + ] }) mockApi.getSetupStatus.mockResolvedValue({ ok: true, data: { kind: 'ready' } }) mockApi.createSession.mockResolvedValue({ ok: true, data: { session_id: 'sess-tutorial-1', scenario_id: 'first_words_tutorial', state: 'NotStarted' as const, created_at: '', setup: { scenario_id: 'first_words_tutorial', difficulty: 'standard' as const, player_role_name: 'New Player', language: 'en', input_mode: 'text-only' as const, tts_enabled: false, show_state_meters: true, save_transcript: true, seed: null } } }) }) @@ -176,9 +211,14 @@ describe('FirstRunWizard — welcome step', () => { expect(screen.getByRole('button', { name: /set me up/i })).toBeInTheDocument() }) - it('shows a Try it right now card button', () => { + it('offers no model-free "Try it right now" path (issue #473)', () => { + renderWizard() + expect(screen.queryByRole('button', { name: /try it right now/i })).not.toBeInTheDocument() + }) + + it('states the not-a-chatbot promise on the welcome screen', () => { renderWizard() - expect(screen.getByRole('button', { name: /try it right now/i })).toBeInTheDocument() + expect(screen.getByText(/not a chatbot\. not a mirror\./i)).toBeInTheDocument() }) it('pre-fetches the model registry on mount', async () => { @@ -216,19 +256,6 @@ describe('FirstRunWizard — welcome step', () => { expect(mockApi.startSetupInstall).toHaveBeenCalledWith('qwen3-4b-instruct-q4_k_m') }) - it('Try it right now starts the scripted tutorial (navigates directly to the conversation)', async () => { - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /try it right now/i })) - await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument()) - }) - - it('marks setup complete when Try it right now is clicked', async () => { - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /try it right now/i })) - await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument()) - expect(localStorage.getItem(SETUP_KEYS.firstRunComplete)).toBe('true') - }) - it('states everything stays on this machine', () => { renderWizard() expect(screen.getByText(/everything stays on this machine/i)).toBeInTheDocument() @@ -267,19 +294,6 @@ describe('FirstRunWizard — welcome step', () => { await screen.findByRole('heading', { name: /use a gguf file/i }) }) - it('Try it right now card does not use the words warning, without, or only', () => { - renderWizard() - const btn = screen.getByRole('button', { name: /try it right now/i }) - expect(btn.textContent).not.toMatch(/warning/i) - expect(btn.textContent).not.toMatch(/\bwithout\b/i) - expect(btn.textContent).not.toMatch(/\bonly\b/i) - }) - - it('states responses are scripted, not AI-generated', () => { - renderWizard() - expect(screen.getByText(/scripted, not ai-generated/i)).toBeInTheDocument() - }) - it('shows a Read setup docs link', () => { renderWizard() const link = screen.getByRole('link', { name: /read setup docs/i }) @@ -339,9 +353,9 @@ describe('FirstRunWizard — preflight step', () => { expect(screen.getByTestId('remediation-action-disk-space').textContent).toBe('Choose another location') }) - it('shows the text-only escape hatch on every remediation card', async () => { + it('offers no model-free escape hatch on remediation cards (issue #473)', async () => { await goToPreflight() - expect(screen.getByTestId('remediation-text-only-disk-space')).toBeInTheDocument() + expect(screen.queryByTestId('remediation-text-only-disk-space')).not.toBeInTheDocument() }) it('does not show a "Retry system check" button (retrying is automatic)', async () => { @@ -349,12 +363,6 @@ describe('FirstRunWizard — preflight step', () => { expect(screen.queryByRole('button', { name: /retry system check/i })).not.toBeInTheDocument() }) - it('proceeds to choose step when "Try text-only instead" is clicked', async () => { - await goToPreflight() - fireEvent.click(screen.getByTestId('remediation-text-only-disk-space')) - await screen.findByRole('heading', { name: /choose how to get started/i }) - }) - it('auto-fixable checks (engine, model, packs) do not route to preflight', async () => { // llama-cpp-binary is auto-fixable and must never appear as a wall mockApi.preflight.mockResolvedValue({ @@ -606,7 +614,7 @@ describe('FirstRunWizard — successful install', () => { } }) await vi.advanceTimersByTimeAsync(2000) - await waitFor(() => expect(screen.getByTestId('home-page')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByTestId('library-page')).toBeInTheDocument()) } finally { vi.useRealTimers() } @@ -632,7 +640,7 @@ describe('FirstRunWizard — successful install', () => { updated_at: '2026-01-01T00:00:00Z', } }) await vi.advanceTimersByTimeAsync(2000) - await waitFor(() => expect(screen.getByTestId('home-page')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByTestId('library-page')).toBeInTheDocument()) expect(localStorage.getItem(SETUP_KEYS.firstRunComplete)).toBe('true') } finally { @@ -1075,191 +1083,60 @@ describe('FirstRunWizard — existing Ollama path', () => { }) }) -// ── Demo / text-only path ───────────────────────────────────────────────────── - -describe('FirstRunWizard — "Try it right now" tutorial path', () => { - it('calls useModel with the scripted runtime when Try it right now is clicked', async () => { - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /try it right now/i })) - await waitFor(() => - expect(mockApi.useModel).toHaveBeenCalledWith({ runtime_id: 'scripted', model_id: null }), - ) - }) - - it('navigates directly to the conversation and marks setup complete', async () => { - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /try it right now/i })) - await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument()) - expect(localStorage.getItem(SETUP_KEYS.firstRunComplete)).toBe('true') - }) - - it('labels the session as scripted via localStorage', async () => { - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /try it right now/i })) - await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument()) - expect(localStorage.getItem(SETUP_KEYS.activeRuntimeHint)).toBe('scripted') - }) - - it('still navigates to the tutorial even when useModel fails', async () => { - mockApi.useModel.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'runtime unavailable' } }) - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /try it right now/i })) - await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument()) - }) -}) - -// ── Tutorial CTA on installing step ────────────────────────────────────────── +// ── While-you-wait enrichment during install (issue #473) ───────────────────── -describe('FirstRunWizard — tutorial CTA during install', () => { +describe('FirstRunWizard — while-you-wait content during install', () => { async function goToInstalling() { renderWizard() fireEvent.click(screen.getByRole('button', { name: /set me up/i })) await screen.findByRole('heading', { name: /setting up your ai/i }) } - it('shows the play tutorial region while downloading', async () => { - await goToInstalling() - expect( - screen.getByRole('region', { name: /start tutorial while downloading/i }), - ).toBeInTheDocument() - }) - - it('shows the ▶ Start now button as the primary CTA', async () => { + it('offers no playable tutorial or demo while downloading', async () => { await goToInstalling() - expect( - screen.getByRole('button', { name: /start now/i }), - ).toBeInTheDocument() - }) - - it('still shows the download progress bar alongside the tutorial CTA', async () => { - await goToInstalling() - expect(screen.getByRole('progressbar', { name: /overall install progress/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /start now/i })).not.toBeInTheDocument() + expect(screen.queryByText(/responses are scripted/i)).not.toBeInTheDocument() }) - it('still shows the Cancel and go home button alongside the tutorial CTA', async () => { + it('shows the not-a-chatbot promise while downloading', async () => { await goToInstalling() - expect(screen.getByRole('button', { name: /cancel and go home/i })).toBeInTheDocument() + expect(await screen.findByText(/someone with their own agenda/i)).toBeInTheDocument() }) - it('starts the scripted tutorial directly (no interstitial) when Start now is clicked', async () => { - mockApi.useModel.mockResolvedValue({ ok: true, data: { - runtime_id: 'scripted', - model_id: null, - runtime_name: 'Scripted tutorial', - status: 'ready', - message: null, - } }) + it('teaches the three-beat loop (investigate, appeal, succeed)', async () => { await goToInstalling() - fireEvent.click(screen.getByRole('button', { name: /start now/i })) - await waitFor(() => - expect(mockApi.useModel).toHaveBeenCalledWith({ runtime_id: 'scripted', model_id: null }), - ) - await screen.findByTestId('conversation-page') + expect(await screen.findByText(/investigate their universe/i)).toBeInTheDocument() + expect(screen.getByText(/appeal and compel within it/i)).toBeInTheDocument() + expect(screen.getByText(/succeed on their terms/i)).toBeInTheDocument() }) - it('marks tutorial complete in localStorage when starting the tutorial', async () => { + it('previews real scenarios from the registry as first missions', async () => { await goToInstalling() - fireEvent.click(screen.getByRole('button', { name: /start now/i })) - await waitFor(() => - expect(localStorage.getItem(SETUP_KEYS.tutorialComplete)).toBe('true'), - ) + expect(await screen.findByText(/your first missions/i)).toBeInTheDocument() + expect(screen.getByText('The Salary Conversation')).toBeInTheDocument() }) - it('marks first-run complete in localStorage when starting the tutorial', async () => { + it('never previews the internal scripted tutorial scenario', async () => { await goToInstalling() - fireEvent.click(screen.getByRole('button', { name: /start now/i })) - await waitFor(() => - expect(localStorage.getItem(SETUP_KEYS.firstRunComplete)).toBe('true'), - ) + await screen.findByText(/your first missions/i) + expect(screen.queryByText('First Words')).not.toBeInTheDocument() }) - it('records the background install id so the model-ready toast can fire mid-tutorial', async () => { + it('still shows the download progress bar alongside the enrichment content', async () => { await goToInstalling() - fireEvent.click(screen.getByRole('button', { name: /start now/i })) - await waitFor(() => - expect(localStorage.getItem(SETUP_KEYS.tutorialInstallId)).toBe(String(RUNNING_JOB.id)), - ) - expect(localStorage.getItem(SETUP_KEYS.activeRuntimeHint)).toBe('scripted') + expect(screen.getByRole('progressbar', { name: /overall install progress/i })).toBeInTheDocument() }) - it('proceeds even when useModel fails for the scripted runtime', async () => { - mockApi.useModel.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'scripted unavailable' } }) + it('still shows the Cancel and go home button alongside the enrichment content', async () => { await goToInstalling() - fireEvent.click(screen.getByRole('button', { name: /start now/i })) - await waitFor(() => - expect(localStorage.getItem(SETUP_KEYS.tutorialComplete)).toBe('true'), - ) + expect(screen.getByRole('button', { name: /cancel and go home/i })).toBeInTheDocument() }) - it('navigates directly to the tutorial conversation (a real, mounted route)', async () => { + it('hides the missions section gracefully when the scenario list fails to load', async () => { + mockApi.listScenarios.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'unavailable' } }) await goToInstalling() - fireEvent.click(screen.getByRole('button', { name: /start now/i })) - await screen.findByTestId('conversation-page') - }) -}) - -// ── Tutorial completion affects post-install navigation ─────────────────────── - -describe('FirstRunWizard — post-install navigation with tutorial completed', () => { - async function goToInstalling() { - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /set me up/i })) - await screen.findByRole('heading', { name: /setting up your ai/i }) - } - - it('navigates to /library (not home) when install completes and tutorial was completed', async () => { - localStorage.setItem(SETUP_KEYS.tutorialComplete, 'true') - vi.useFakeTimers({ shouldAdvanceTime: true }) - try { - await goToInstalling() - mockApi.getSetupInstallStatus.mockResolvedValue({ ok: true, data: { - id: 1, - status: 'complete' as const, - registry_id: 'qwen3-4b-instruct-q4_k_m', - stages: [ - { id: 'engine', label: 'Getting the AI engine', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'model', label: 'Downloading Qwen3 4B Instruct Q4_K_M', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'verify', label: 'Verifying (SHA-256)', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'warmup', label: 'First launch of the model', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'packs', label: 'Preparing scenarios', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - ], - error_message: null, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - } }) - await vi.advanceTimersByTimeAsync(2000) - - await waitFor(() => expect(screen.getByTestId('library-page')).toBeInTheDocument()) - } finally { - vi.useRealTimers() - } - }) - - it('navigates to / (home) when install completes and tutorial was NOT completed', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - try { - await goToInstalling() - mockApi.getSetupInstallStatus.mockResolvedValue({ ok: true, data: { - id: 1, - status: 'complete' as const, - registry_id: 'qwen3-4b-instruct-q4_k_m', - stages: [ - { id: 'engine', label: 'Getting the AI engine', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'model', label: 'Downloading Qwen3 4B Instruct Q4_K_M', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'verify', label: 'Verifying (SHA-256)', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'warmup', label: 'First launch of the model', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - { id: 'packs', label: 'Preparing scenarios', state: 'complete', bytes_downloaded: null, bytes_total: null, error: null }, - ], - error_message: null, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - } }) - await vi.advanceTimersByTimeAsync(2000) - - await waitFor(() => expect(screen.getByTestId('home-page')).toBeInTheDocument()) - } finally { - vi.useRealTimers() - } + await screen.findByText(/someone with their own agenda/i) + expect(screen.queryByText(/your first missions/i)).not.toBeInTheDocument() }) }) @@ -1275,16 +1152,6 @@ describe('FirstRunWizard — load error state', () => { ) }) - it('mentions the text-only demo as a fallback when the runtime is unavailable', async () => { - mockApi.getModels.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'runtime unreachable' } }) - renderWizard() - fireEvent.click(screen.getByRole('button', { name: /set me up/i })) - await waitFor(() => - expect(screen.getByRole('alert')).toBeInTheDocument(), - ) - expect(screen.getByText(/text-only demo works without one/i)).toBeInTheDocument() - }) - it('back button from load error returns to the welcome step', async () => { mockApi.getModels.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'network error' } }) renderWizard() diff --git a/apps/web/src/__tests__/Home.test.tsx b/apps/web/src/__tests__/Home.test.tsx index fad196ba..f03af364 100644 --- a/apps/web/src/__tests__/Home.test.tsx +++ b/apps/web/src/__tests__/Home.test.tsx @@ -161,12 +161,11 @@ describe('Home — no-model state', () => { expect(await screen.findByRole('link', { name: /connect ollama/i })).toBeInTheDocument() }) - it('offers text-only demo option linking to the library', async () => { + it('offers no text-only demo option (issue #473)', async () => { stubFetches(makeHealth(), makePacks(0)) renderHome() - const link = await screen.findByRole('link', { name: /text-only demo/i }) - expect(link).toBeInTheDocument() - expect(link).toHaveAttribute('href', '/library') + await screen.findByRole('heading', { name: /no model configured/i }) + expect(screen.queryByRole('link', { name: /text-only demo/i })).not.toBeInTheDocument() }) it('hides no-model section when LLM is ready', async () => { @@ -412,12 +411,6 @@ describe('Home — no-model recovery cards', () => { expect(screen.getByText(/use an existing ollama installation/i)).toBeInTheDocument() }) - it('shows text-only demo as a styled recovery card', async () => { - stubFetches(makeHealth(), makePacks(0)) - renderHome() - await screen.findByRole('heading', { name: /no model configured/i }) - expect(screen.getByText(/explore the interface now/i)).toBeInTheDocument() - }) }) describe('Home — missing-pack section', () => { diff --git a/apps/web/src/__tests__/ModelManager.test.tsx b/apps/web/src/__tests__/ModelManager.test.tsx index fe819b61..732cd60b 100644 --- a/apps/web/src/__tests__/ModelManager.test.tsx +++ b/apps/web/src/__tests__/ModelManager.test.tsx @@ -21,6 +21,7 @@ vi.mock('../api/client', () => ({ startSetupInstall: vi.fn(), getSetupInstallStatus: vi.fn(), cancelSetupInstall: vi.fn(), + listScenarios: vi.fn().mockResolvedValue({ ok: true, data: [] }), }, })) @@ -144,6 +145,7 @@ beforeEach(() => { mockApi.benchmarkModel.mockResolvedValue({ ok: true, data: DEFAULT_BENCHMARK }) mockApi.recordOnboardingOutcome.mockResolvedValue({ ok: true, data: undefined }) mockApi.getSetupStatus.mockResolvedValue({ ok: true, data: { kind: 'ready' } }) + mockApi.listScenarios.mockResolvedValue({ ok: true, data: [] }) }) // ── Loading state ──────────────────────────────────────────────────────────── @@ -185,11 +187,10 @@ describe('ModelManager — choose step', () => { ).toBeInTheDocument() }) - it('shows the Try text-only demo option', async () => { + it('offers no text-only demo option (issue #473)', async () => { renderModelManager() - expect( - await screen.findByRole('button', { name: /try text-only demo/i }), - ).toBeInTheDocument() + await screen.findByRole('heading', { name: /set up your model/i }) + expect(screen.queryByRole('button', { name: /try text-only demo/i })).not.toBeInTheDocument() }) it('does not start any download on page load', async () => { @@ -351,7 +352,7 @@ describe('ModelManager — download progress', () => { } }) - it('navigates home when the download reaches ready', async () => { + it('navigates to the library when the download reaches ready', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) try { await goToInstalling() @@ -373,7 +374,7 @@ describe('ModelManager — download progress', () => { await vi.advanceTimersByTimeAsync(2000) - await waitFor(() => expect(screen.getByTestId('home-page')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByTestId('library-page')).toBeInTheDocument()) } finally { vi.useRealTimers() } @@ -661,79 +662,6 @@ describe('ModelManager — GGUF branch', () => { }) }) -// ── Text-only demo branch ───────────────────────────────────────────────────── - -describe('ModelManager — text-only demo branch', () => { - async function goToDemo() { - renderModelManager() - await screen.findByRole('button', { name: /try text-only demo/i }) - fireEvent.click(screen.getByRole('button', { name: /try text-only demo/i })) - await screen.findByRole('heading', { name: /text-only demo/i }) - } - - it('shows the text-only demo heading', async () => { - await goToDemo() - expect(screen.getByRole('heading', { name: /text-only demo/i })).toBeInTheDocument() - }) - - it('shows a disclaimer that this is not production quality', async () => { - await goToDemo() - expect(screen.getByText(/this is a demo, not production quality/i)).toBeInTheDocument() - }) - - it('mentions scripted responses in the disclaimer', async () => { - await goToDemo() - expect(screen.getByText(/scripted responses/i)).toBeInTheDocument() - }) - - it('shows the I understand confirm button', async () => { - await goToDemo() - expect(screen.getByRole('button', { name: /i understand/i })).toBeInTheDocument() - }) - - it('shows a cancel button on the demo warning', async () => { - await goToDemo() - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() - }) - - it('cancel returns to choose step', async () => { - await goToDemo() - fireEvent.click(screen.getByRole('button', { name: /cancel/i })) - await screen.findByRole('heading', { name: /set up your model/i }) - }) - - it('calls useModel with the fake runtime when confirmed', async () => { - mockApi.useModel.mockResolvedValue({ ok: true, data: { - runtime_id: 'fake', - model_id: null, - runtime_name: 'Fake (deterministic)', - status: 'ready', - message: null, - } }) - await goToDemo() - fireEvent.click(screen.getByRole('button', { name: /i understand/i })) - await waitFor(() => - expect(mockApi.useModel).toHaveBeenCalledWith({ - runtime_id: 'fake', - model_id: null, - }), - ) - }) - - it('navigates to the library after confirming demo mode', async () => { - await goToDemo() - fireEvent.click(screen.getByRole('button', { name: /i understand/i })) - await waitFor(() => expect(screen.getByTestId('library-page')).toBeInTheDocument()) - }) - - it('still navigates to library even when useModel fails for demo', async () => { - mockApi.useModel.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'runtime unavailable' } }) - await goToDemo() - fireEvent.click(screen.getByRole('button', { name: /i understand/i })) - await waitFor(() => expect(screen.getByTestId('library-page')).toBeInTheDocument()) - }) -}) - // ── Benchmark step ──────────────────────────────────────────────────────────── describe('ModelManager — benchmark step', () => { diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index cbf55800..0795d5b6 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -137,12 +137,6 @@ export const de: LocaleMessages = { 'Verwenden Sie eine vorhandene Ollama-Installation. Kein zusätzlicher Download erforderlich.', action: 'Ollama verbinden →', }, - demo: { - title: 'Textnur-Demo ausprobieren', - description: - 'Erkunden Sie die Benutzeroberfläche jetzt mit skriptierten NPC-Antworten – kein Modell erforderlich. Die Antwortqualität ist im Vergleich zu einem echten KI-Modell begrenzt.', - action: 'Textnur-Demo ausprobieren →', - }, }, missingPack: { title: 'Keine Szenarienpakete installiert', @@ -315,32 +309,21 @@ export const de: LocaleMessages = { clearing: 'Wird gelöscht…', }, }, - conversation: { - runtimeLabel: { - scripted: 'Skriptbasiertes Übungsgespräch', - fake: 'Demo-Modus', - }, - modelReady: { - toast: 'Ihr KI-Modell ist bereit', - switchNow: 'Jetzt wechseln', - afterConversation: 'Nach diesem Gespräch', - }, - }, setup: { welcome: { headline: 'Üben Sie Gespräche, die zählen.', subheadline: 'Privat. Auf Ihrem Gerät. Für Sie.', + promise: { + heading: 'Kein Chatbot. Kein Spiegel.', + body: + 'Alle, mit denen Sie hier sprechen, stehen mitten im Leben: mit Anliegen, Stimmungen und Zielen, die nichts mit Ihnen zu tun haben. Nichts, was Sie sagen, wird bloß zurückgespiegelt. Sie kommen weiter, indem Sie herausfinden, was Ihrem Gegenüber wichtig ist — und sich darauf einlassen.', + }, setMeUp: { title: 'Einrichten', description: 'Lädt das KI-Modell herunter ({{size}} GB, {{license}}). Danach offline nutzbar.', descriptionLoading: 'Lädt das empfohlene KI-Modell herunter. Danach offline nutzbar.', badge: 'Empfohlen', }, - tryNow: { - title: 'Sofort ausprobieren', - description: 'Spielen Sie sofort ein Szenario — kein Download nötig. Jederzeit upgraden.', - disclaimer: 'Antworten sind geskriptet, nicht KI-generiert.', - }, privacy: { summary: '🔒 Alles bleibt auf Ihrem Gerät.', toggle: 'Details', @@ -363,8 +346,30 @@ export const de: LocaleMessages = { oneIssue: 'Ein Problem muss gelöst werden, bevor die Einrichtung fortgesetzt werden kann.', manyIssues: '{{count}} Probleme müssen gelöst werden, bevor die Einrichtung fortgesetzt werden kann.', }, + installing: { + whileHeading: 'Während des Downloads…', + promiseHeading: 'Gleich treffen Sie jemanden mit eigener Agenda.', + promiseBody: + 'Die Person, mit der Sie sprechen werden, ist kein Chatbot, der auf Eingaben wartet. Sie steht mitten im Leben — mit Anliegen, Stimmungen und Zielen, die nichts mit Ihnen zu tun haben — und sie wird Ihnen den Erfolg nicht schenken. So gelingt es trotzdem:', + loopHeading: 'So spielen Sie', + loop: { + investigate: { + title: 'Erkunden Sie ihre Welt.', + body: 'Was will Ihr Gegenüber? Wovor hat es Angst? Fragen Sie. Hören Sie zu. Die Antworten stecken im Gespräch.', + }, + appeal: { + title: 'Überzeugen Sie in ihren Begriffen.', + body: 'Argumentieren Sie mit dem, was Ihrem Gegenüber wichtig ist — nicht mit dem, was Ihnen wichtig wäre.', + }, + succeed: { + title: 'Gewinnen Sie zu ihren Bedingungen.', + body: 'Wenn es klappt, dann weil Sie es sich in der Welt Ihres Gegenübers verdient haben. Genau darum geht es.', + }, + }, + missionsHeading: 'Ihre ersten Missionen', + missionRole: 'Ihre Rolle: {{role}}', + }, remediation: { - textOnly: 'Stattdessen nur Text verwenden', details: 'Details', detailsOpen: 'Details ▾', detailsClosed: 'Details ▸', @@ -441,7 +446,6 @@ export const de: LocaleMessages = { exporting: 'Exportiere…', exportMarkdown: 'Transkript exportieren (Markdown)', tryAnother: 'Anderes Szenario versuchen', - tryWithRealAi: 'Mit der echten KI ausprobieren', privacyNotice: 'Exportierte Dateien werden in Ihrem lokalen Download-Ordner gespeichert und verlassen Ihr Gerät nicht.', }, diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 4181e860..4c7b7223 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -129,12 +129,6 @@ export const en = { description: 'Use an existing Ollama installation. No additional download required.', action: 'Connect Ollama →', }, - demo: { - title: 'Try text-only demo', - description: - 'Explore the interface now using scripted NPC responses — no model needed. Response quality is limited compared to a real AI model.', - action: 'Try text-only demo →', - }, }, missingPack: { title: 'No scenario packs installed', @@ -306,32 +300,21 @@ export const en = { clearing: 'Clearing…', }, }, - conversation: { - runtimeLabel: { - scripted: 'Scripted practice run', - fake: 'Demo mode', - }, - modelReady: { - toast: 'Your AI model is ready', - switchNow: 'Switch now', - afterConversation: 'After this conversation', - }, - }, setup: { welcome: { headline: 'Practice conversations that matter.', subheadline: 'Private. On your machine. Yours.', + promise: { + heading: 'Not a chatbot. Not a mirror.', + body: + "Everyone you'll talk to here arrives mid-life: stakes, moods, and goals that have nothing to do with you. Nothing you say is reflected back. You get somewhere by figuring out what they care about — and working within it.", + }, setMeUp: { title: 'Set me up', description: 'Downloads the AI model ({{size}} GB, {{license}}). Works offline afterwards.', descriptionLoading: 'Downloads the recommended AI model. Works offline afterwards.', badge: 'Recommended', }, - tryNow: { - title: 'Try it right now', - description: 'Play a scripted scenario instantly, no download. Upgrade any time.', - disclaimer: 'Responses are scripted, not AI-generated.', - }, privacy: { summary: '🔒 Everything stays on this machine.', toggle: 'Details', @@ -354,8 +337,30 @@ export const en = { oneIssue: 'One thing needs your attention before setup can continue.', manyIssues: '{{count}} things need your attention before setup can continue.', }, + installing: { + whileHeading: 'While that downloads…', + promiseHeading: "You're about to meet someone with their own agenda.", + promiseBody: + "The person you'll be talking to isn't a chatbot waiting for input. They show up mid-life — with stakes, moods, and goals that have nothing to do with you — and they will not hand you the win. Here's how you get it:", + loopHeading: 'How to play', + loop: { + investigate: { + title: 'Investigate their universe.', + body: 'What do they want? What are they afraid of? Ask. Listen. The answers are in there.', + }, + appeal: { + title: 'Appeal and compel within it.', + body: "Make your case in their terms, not yours. What moves them is what they care about — not what you'd care about.", + }, + succeed: { + title: 'Succeed on their terms.', + body: 'When it goes your way, it will be because you earned it inside their world. That win is the whole point.', + }, + }, + missionsHeading: 'Your first missions', + missionRole: 'You play: {{role}}', + }, remediation: { - textOnly: 'Try text-only instead', details: 'Details', detailsOpen: 'Details ▾', detailsClosed: 'Details ▸', @@ -431,7 +436,6 @@ export const en = { exporting: 'Exporting…', exportMarkdown: 'Export transcript (Markdown)', tryAnother: 'Try another scenario', - tryWithRealAi: 'Try it with the real AI', privacyNotice: 'Exported files are saved to your local download folder and never leave your device.', }, diff --git a/apps/web/src/privacyPrefs.ts b/apps/web/src/privacyPrefs.ts index 80694b81..004bb144 100644 --- a/apps/web/src/privacyPrefs.ts +++ b/apps/web/src/privacyPrefs.ts @@ -27,13 +27,10 @@ export const PRIVACY_KEYS = { export const SETUP_KEYS = { firstRunComplete: 'convsim.setup.complete', - tutorialComplete: 'convsim.tutorial.complete', - // Written by handleStartTutorial when a background install is running so - // Conversation.tsx can show the model-ready toast when the download finishes. - tutorialInstallId: 'convsim.tutorial.install_id', - // Written by handleStartTutorial / handleConfirmDemo so Conversation.tsx can - // label the session ("Scripted practice run" / "Demo mode"). - activeRuntimeHint: 'convsim.active_runtime_hint', + // Legacy keys ('convsim.tutorial.complete', 'convsim.tutorial.install_id', + // 'convsim.active_runtime_hint') were written by the removed no-model + // demo/tutorial path (issue #473). Stale values are harmless: nothing reads + // them any more. } as const export function readPrivacyPref(key: string, defaultValue: boolean): boolean { diff --git a/apps/web/src/screens/Conversation.tsx b/apps/web/src/screens/Conversation.tsx index 8d883b9d..8535097d 100644 --- a/apps/web/src/screens/Conversation.tsx +++ b/apps/web/src/screens/Conversation.tsx @@ -7,10 +7,8 @@ import VoiceInput, { type SttReviewMeta } from '../components/VoiceInput' import DebugDrawer, { type DebugTurnEntry } from '../components/DebugDrawer' import PerformanceWarningBanner from '../components/PerformanceWarning' import { useLatencyMetrics } from '../hooks/useLatencyMetrics' -import { isDevModeEnabled, SETUP_KEYS } from '../privacyPrefs' +import { isDevModeEnabled } from '../privacyPrefs' import { getVoiceTimingPrefs } from '../components/VoiceSettingsPanel' -import { useSetupInstall } from '../setup/useSetupInstall' -import { useTranslation } from '../i18n' import type { ApiError } from '../api/errors' import type { ApiResult } from '../api/client' import { ApiErrorView } from '../components/ApiErrorView' @@ -77,7 +75,6 @@ export default function Conversation() { const { sessionId } = useParams<{ sessionId: string }>() const navigate = useNavigate() const { state } = useLocation() - const { t } = useTranslation() const routeState = state as { language?: string show_state_meters?: boolean @@ -95,33 +92,6 @@ export default function Conversation() { // Voice timing preferences (issue #308) — read once at mount from localStorage. const voiceTimingPrefs = getVoiceTimingPrefs() - // Read the active runtime hint set by the setup flow so we can label scripted - // and fake sessions in-session. Read once at mount — the hint is stable for - // the lifetime of this component since the setup flow only writes it before - // navigating here. - const [runtimeHint] = useState(() => { - try { return localStorage.getItem(SETUP_KEYS.activeRuntimeHint) } catch { return null } - }) - - // If a background install job was started before this tutorial conversation, - // poll its status and show the model-ready toast when it completes. - const [backgroundInstallId] = useState(() => { - try { - const raw = localStorage.getItem(SETUP_KEYS.tutorialInstallId) - if (!raw) return null - const id = Number(raw) - return Number.isFinite(id) && id > 0 ? id : null - } catch { return null } - }) - - // 'hidden' = toast not yet shown; 'shown' = toast visible; 'deferred' = user - // picked "After this conversation" so the debrief should show the upgrade CTA. - const [modelReadyState, setModelReadyState] = useState<'hidden' | 'shown' | 'deferred'>('hidden') - - const backgroundInstallJob = useSetupInstall( - modelReadyState === 'hidden' ? backgroundInstallId : null, - ) - const [phase, setPhase] = useState('starting') const [sessionState, setSessionState] = useState('NotStarted') const [endingType, setEndingType] = useState(null) @@ -175,26 +145,6 @@ export default function Conversation() { } }, []) - // Show the model-ready toast when the background install finishes. On failure, - // silently clear the key — remediation is deferred to after the conversation - // per issue #383 (never interrupt a session with an error modal). - useEffect(() => { - if (!backgroundInstallJob) return - const { status } = backgroundInstallJob - if (status === 'complete') { - try { localStorage.removeItem(SETUP_KEYS.tutorialInstallId) } catch { /* ignore */ } - // The real model is now the active runtime server-side, so any conversation - // started from here on (Switch now, or "Try it with the real AI" after the - // debrief) is genuine AI — drop the scripted/fake hint so it isn't mislabeled. - // The current session's badge is unaffected: runtimeHint was captured in a - // useState initializer at mount and does not re-read localStorage. - try { localStorage.removeItem(SETUP_KEYS.activeRuntimeHint) } catch { /* ignore */ } - setModelReadyState('shown') - } else if (status === 'failed' || status === 'cancelled') { - try { localStorage.removeItem(SETUP_KEYS.tutorialInstallId) } catch { /* ignore */ } - } - }, [backgroundInstallJob]) - function _playNextTtsChunk() { const url = ttsQueueRef.current.shift() if (!url) { @@ -683,16 +633,6 @@ export default function Conversation() { setBanners((prev) => prev.filter((b) => b.id !== id)) } - function handleModelReadySwitchNow() { - try { localStorage.removeItem(SETUP_KEYS.tutorialInstallId) } catch { /* ignore */ } - try { localStorage.removeItem(SETUP_KEYS.activeRuntimeHint) } catch { /* ignore */ } - navigate('/library') - } - - function handleModelReadyDefer() { - setModelReadyState('deferred') - } - const isIdle = phase === 'active' const isBusy = phase === 'submitting' || phase === 'ending' const isEnded = phase === 'ended' @@ -716,42 +656,6 @@ export default function Conversation() {

Conversation

- {runtimeHint === 'scripted' && ( - - {t('conversation.runtimeLabel.scripted')} - - )} - {runtimeHint === 'fake' && ( - - {t('conversation.runtimeLabel.fake')} - - )}

Session: {sessionId}  |  State:{' '} @@ -782,62 +686,6 @@ export default function Conversation() { )}

- {/* Model-ready toast — shown when a background install completes mid-session. - Never shown mid-turn; user must explicitly switch or defer. */} - {modelReadyState === 'shown' && ( -
- {t('conversation.modelReady.toast')} ✨ -
- - -
-
- )} - {/* NPC panel + scene card */}
- )}
-
-

- {t('home.noModel.demo.title')} -

-

- {t('home.noModel.demo.description')} -

- - {t('home.noModel.demo.action')} - -
)} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index d3092d13..00669c03 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -122,10 +122,6 @@ export default function Settings() { } } - function handleHealthTextOnly() { - navigate('/library') - } - function handleSaveTranscriptsChange(v: boolean) { setSaveTranscripts(v) writePrivacyPref(PRIVACY_KEYS.saveTranscripts, v) @@ -1042,7 +1038,6 @@ export default function Settings() { key={check.id} check={check} onAction={handleHealthFixAction} - onTextOnly={handleHealthTextOnly} coreVersion={coreVersion} /> ))} diff --git a/apps/web/src/setup/RemediationCard.tsx b/apps/web/src/setup/RemediationCard.tsx index 0af37593..65e9fce7 100644 --- a/apps/web/src/setup/RemediationCard.tsx +++ b/apps/web/src/setup/RemediationCard.tsx @@ -3,8 +3,9 @@ * RemediationCard — shown for any preflight check with severity === 'needs-human'. * * Renders the check's name, plain-language message, a primary fix action (from - * the check's fix_action), a universal "Try text-only instead" escape hatch, and - * a collapsible Details section with a copy-ready bug-report block. + * the check's fix_action), and a collapsible Details section with a copy-ready + * bug-report block. There is deliberately no model-free escape hatch: a blocked + * machine gets an honest fix path, never a facsimile conversation (issue #473). * * Vocabulary contract: this component never renders the words "binary", "llama", * "sidecar", or "preflight" — those are filtered at the backend before the check @@ -20,8 +21,6 @@ export interface RemediationCardProps { check: PreflightCheck /** Called when the primary fix action is triggered. */ onAction: (action: PreflightFixAction) => void - /** Called when the user chooses "Try text-only instead". */ - onTextOnly: () => void /** Version string shown in the copy block (e.g. from the runtime-handshake check). */ coreVersion?: string } @@ -66,16 +65,6 @@ const primaryBtnStyle: React.CSSProperties = { cursor: 'pointer', } -const escapeBtnStyle: React.CSSProperties = { - padding: '0.4rem 0.9rem', - borderRadius: '6px', - border: '1px solid rgba(255,255,255,0.15)', - background: 'rgba(255,255,255,0.06)', - color: '#a1a1aa', - fontSize: '0.875rem', - cursor: 'pointer', -} - const detailsToggleStyle: React.CSSProperties = { marginTop: '0.75rem', background: 'none', @@ -128,7 +117,7 @@ function buildCopyBlock(check: PreflightCheck, coreVersion?: string): string { return lines.join('\n') } -export function RemediationCard({ check, onAction, onTextOnly, coreVersion }: RemediationCardProps) { +export function RemediationCard({ check, onAction, coreVersion }: RemediationCardProps) { const { t } = useTranslation() const [detailsOpen, setDetailsOpen] = useState(false) const [copied, setCopied] = useState(false) @@ -163,13 +152,6 @@ export function RemediationCard({ check, onAction, onTextOnly, coreVersion }: Re {check.fix_action.label} )} - - - {/* Try it right now card */} - {/* Privacy disclosure */} diff --git a/apps/web/src/setup/useSetupFlow.ts b/apps/web/src/setup/useSetupFlow.ts index 040c0831..e5b19043 100644 --- a/apps/web/src/setup/useSetupFlow.ts +++ b/apps/web/src/setup/useSetupFlow.ts @@ -25,7 +25,6 @@ export type SetupFlowStep = | 'benchmark' | 'ollama-select' | 'gguf-path' - | 'demo-warning' | 'load-error' /** @@ -77,8 +76,6 @@ export interface UseSetupFlowReturn { handleStartInstall: (registryId: string) => Promise handleSelectOllama: (m: DetectedOllamaModel) => Promise handleUseGguf: () => Promise - handleConfirmDemo: (markComplete?: boolean) => Promise - handleStartTutorial: () => Promise handleCancelInstall: () => Promise handleFinishBenchmark: () => void reloadModels: () => Promise @@ -97,19 +94,6 @@ async function markFirstRunComplete(): Promise { try { await api.recordOnboardingOutcome('completed-with-model') } catch { /* best-effort */ } } -async function markDemoComplete(): Promise { - try { localStorage.setItem(SETUP_KEYS.firstRunComplete, 'true') } catch { /* ignore */ } - try { await api.recordOnboardingOutcome('demo') } catch { /* best-effort */ } -} - -function markTutorialComplete(): void { - try { localStorage.setItem(SETUP_KEYS.tutorialComplete, 'true') } catch { /* ignore */ } -} - -function isTutorialComplete(): boolean { - try { return localStorage.getItem(SETUP_KEYS.tutorialComplete) === 'true' } catch { return false } -} - export function useSetupFlow( initialStep: SetupFlowStep, initialInstallId?: number, @@ -239,12 +223,10 @@ export function useSetupFlow( if (step !== 'installing' || setupInstallJob == null) return const { status } = setupInstallJob if (status === 'complete') { - // A real model is now active — clear scripted/fake session labels so future - // conversations show no runtime hint banner. - try { localStorage.removeItem(SETUP_KEYS.activeRuntimeHint) } catch { /* ignore */ } - try { localStorage.removeItem(SETUP_KEYS.tutorialInstallId) } catch { /* ignore */ } + // The real model is live. Land the player in the library — one click from + // their first real conversation (issue #473: no tutorial gate in between). void markFirstRunComplete().then(() => { - navigate(isTutorialComplete() ? '/library' : '/') + navigate('/library') }) } else if (status === 'failed' || status === 'cancelled') { setActionError({ @@ -321,9 +303,6 @@ export function useSetupFlow( setActionError(null) const r = await api.useModel({ runtime_id: 'ollama', model_id: m.id }) if (!r.ok) { setActionError(r.error); setActionLoading(false); return } - // Real model is now active — clear scripted/fake session hint. - try { localStorage.removeItem(SETUP_KEYS.activeRuntimeHint) } catch { /* ignore */ } - try { localStorage.removeItem(SETUP_KEYS.tutorialInstallId) } catch { /* ignore */ } benchmarkStartedRef.current = false setStep('benchmark') setActionLoading(false) @@ -338,9 +317,6 @@ export function useSetupFlow( setActionError(null) const reg = await api.registerGguf({ path: trimmed }) if (!reg.ok) { setActionError(reg.error); setActionLoading(false); return } - // Real model is now active — clear scripted/fake session hint. - try { localStorage.removeItem(SETUP_KEYS.activeRuntimeHint) } catch { /* ignore */ } - try { localStorage.removeItem(SETUP_KEYS.tutorialInstallId) } catch { /* ignore */ } // AWAIT the engine start (it returns once the model is loaded) so the // benchmark step never races a half-started engine. "Already running" // (409) is success for our purposes; any other failure is surfaced here @@ -356,75 +332,6 @@ export function useSetupFlow( setActionLoading(false) } - async function handleConfirmDemo(markComplete = false) { - setActionLoading(true) - try { await api.useModel({ runtime_id: 'fake', model_id: null }) } catch { /* best-effort */ } - finally { setActionLoading(false) } - // Write runtime hint so Conversation.tsx can label fake sessions "Demo mode". - try { localStorage.setItem(SETUP_KEYS.activeRuntimeHint, 'fake') } catch { /* ignore */ } - if (markComplete) await markDemoComplete() - navigate('/library') - } - - async function handleStartTutorial() { - // Snapshot installId before the async useModel call; the value in the closure - // is stable for the lifetime of this invocation since setInstallId is not - // called here. - const activeInstallId = installId - setActionLoading(true) - try { - try { await api.useModel({ runtime_id: 'scripted', model_id: null }) } catch { /* best-effort */ } - markTutorialComplete() - // Label all scripted sessions so the user knows they are not talking to the AI. - try { localStorage.setItem(SETUP_KEYS.activeRuntimeHint, 'scripted') } catch { /* ignore */ } - if (activeInstallId != null) { - // A background download is running. Tell Conversation.tsx so it can show - // the model-ready toast when the pipeline finishes. - try { localStorage.setItem(SETUP_KEYS.tutorialInstallId, String(activeInstallId)) } catch { /* ignore */ } - // Record a 'completed-with-model' outcome optimistically — the user already - // committed to the full install path. - await markFirstRunComplete() - } else { - // "Try it right now" path: no install in progress. - await markDemoComplete() - } - // Create the tutorial session directly so the user goes straight to the - // conversation without having to click through the scenario setup form. - // The scripted runtime needs no model, so this always succeeds on first open. - // runtime_id pins the session to the authored script: the useModel call above - // only moves the *global* selection, which the background install flips back - // to llama.cpp the moment it finishes. - const sessionResult = await api.createSession({ - scenario_id: 'first_words_tutorial', - difficulty: 'standard', - player_role_name: 'New Player', - language: 'en', - input_mode: 'text-only', - tts_enabled: false, - show_state_meters: true, - save_transcript: true, - seed: null, - runtime_id: 'scripted', - }) - if (sessionResult.ok) { - navigate(`/conversation/${sessionResult.data.session_id}`, { - state: { - language: 'en', - show_state_meters: true, - scenario_id: 'first_words_tutorial', - input_mode: 'text-only', - tts_enabled: false, - }, - }) - } else { - // Fall back to the setup form if session creation unexpectedly fails. - navigate('/setup/first_words_tutorial') - } - } finally { - setActionLoading(false) - } - } - async function handleCancelInstall() { if (installId != null) { try { await api.cancelSetupInstall(installId) } catch { /* best-effort */ } @@ -463,8 +370,6 @@ export function useSetupFlow( handleStartInstall, handleSelectOllama, handleUseGguf, - handleConfirmDemo, - handleStartTutorial, handleCancelInstall, handleFinishBenchmark, reloadModels, diff --git a/e2e/onboarding/test_p1_happy_path.py b/e2e/onboarding/test_p1_happy_path.py index 92744630..29548916 100644 --- a/e2e/onboarding/test_p1_happy_path.py +++ b/e2e/onboarding/test_p1_happy_path.py @@ -173,9 +173,22 @@ def test_status_ready_after_completed_install_and_outcome( ) def test_session_reachable_after_setup(self, fresh_profile, fixture_server): - """After setup the tutorial scenario is reachable (proxy for real-runtime access).""" + """After setup the tutorial scenario is reachable (proxy for real-runtime access). + + The completed install pipeline persists the real-model selection; an + unpinned session then passes the issue-#473 backstop (which refuses + unpinned sessions on a model-free selection) and follows the active + runtime. + """ client, app = fresh_profile _seed_fixture_model(app, fixture_server) + from convsim_core.services.model_manager_service import set_active_config + + set_active_config( + app.state.db.connection(), + runtime_id="llama_cpp", + model_id="/tmp/fixture-model.gguf", + ) resp = client.post( "/api/sessions", diff --git a/e2e/onboarding/test_p2_instant_play.py b/e2e/onboarding/test_p2_instant_play.py deleted file mode 100644 index b4a2ed2e..00000000 --- a/e2e/onboarding/test_p2_instant_play.py +++ /dev/null @@ -1,132 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""P2 Instant play: "Try it right now" → scripted conversation < 15 s → debrief → upgrade CTA. - -Journey: - fresh profile → create first_words_tutorial session (scripted runtime, no - model required) → 3 scripted turns → end session → debrief generated in - < 15 s wall-clock → record demo outcome → status shows demo choice (upgrade - CTA path). - -The 15 s budget is the acceptance-criteria wall-clock assertion from issue #387. -""" -from __future__ import annotations - -import time - -from .helpers import assert_no_forbidden_in_preflight - -_TUTORIAL_SCENARIO = "first_words_tutorial" -_SCRIPTED_TURNS = [ - "Hello, I'm ready to practice.", - "I understand, please continue.", - "That's helpful, thank you.", -] -_TIME_BUDGET_SECONDS = 15.0 - -_SESSION_SETUP = { - "scenario_id": _TUTORIAL_SCENARIO, - "difficulty": "standard", - "player_role_name": "P2 Instant Play Tester", - "language": "en", - "input_mode": "text-only", - "tts_enabled": False, - "show_state_meters": False, - "save_transcript": False, -} - - -class TestP2InstantPlay: - """P2: scripted tutorial is reachable and completes well under 15 s.""" - - def test_fresh_profile_is_never_run(self, fresh_profile): - client, _ = fresh_profile - assert client.get("/api/setup/status").json()["kind"] == "never-run" - - def test_tutorial_session_creates_successfully(self, fresh_profile): - client, _ = fresh_profile - resp = client.post("/api/sessions", json=_SESSION_SETUP) - assert resp.status_code == 201, ( - f"Tutorial session creation failed (status {resp.status_code})" - ) - assert resp.json().get("session_id", "").startswith("sess-") - - def test_tutorial_starts_and_delivers_npc_opening(self, fresh_profile): - client, _ = fresh_profile - session_id = client.post("/api/sessions", json=_SESSION_SETUP).json()["session_id"] - start_resp = client.post(f"/api/sessions/{session_id}/start") - assert start_resp.status_code == 200, ( - f"Tutorial session start failed (status {start_resp.status_code})" - ) - state_resp = client.get(f"/api/sessions/{session_id}") - assert state_resp.status_code == 200 - assert state_resp.json().get("state") == "PlayerTurnListening", ( - "After start the tutorial must be in PlayerTurnListening state" - ) - - def test_tutorial_completes_within_15s_wall_clock(self, fresh_profile): - """Acceptance criterion: first-conversation budget ≤ 15 s in CI.""" - client, _ = fresh_profile - - t0 = time.monotonic() - - session_id = client.post("/api/sessions", json=_SESSION_SETUP).json()["session_id"] - client.post(f"/api/sessions/{session_id}/start") - - for turn_text in _SCRIPTED_TURNS: - resp = client.post( - f"/api/sessions/{session_id}/turn", json={"content": turn_text} - ) - assert resp.status_code == 200, ( - f"Turn failed (status {resp.status_code})" - ) - - client.post(f"/api/sessions/{session_id}/end") - - elapsed = time.monotonic() - t0 - assert elapsed < _TIME_BUDGET_SECONDS, ( - f"Tutorial took {elapsed:.1f}s, exceeds the {_TIME_BUDGET_SECONDS}s budget. " - "The scripted tutorial must be fast enough for instant play." - ) - - def test_debrief_reachable_after_tutorial(self, fresh_profile): - client, _ = fresh_profile - session_id = client.post("/api/sessions", json=_SESSION_SETUP).json()["session_id"] - client.post(f"/api/sessions/{session_id}/start") - for turn_text in _SCRIPTED_TURNS: - client.post(f"/api/sessions/{session_id}/turn", json={"content": turn_text}) - client.post(f"/api/sessions/{session_id}/end") - - debrief_resp = client.post(f"/api/sessions/{session_id}/debrief") - assert debrief_resp.status_code == 200, ( - f"Debrief generation failed (status {debrief_resp.status_code})" - ) - body = debrief_resp.json() - assert body.get("session_id") == session_id - assert "summary" in body - - def test_demo_outcome_enables_upgrade_cta(self, fresh_profile): - """Recording demo outcome must not produce 'never-run' (that would hide the upgrade CTA).""" - client, _ = fresh_profile - - session_id = client.post("/api/sessions", json=_SESSION_SETUP).json()["session_id"] - client.post(f"/api/sessions/{session_id}/start") - for turn_text in _SCRIPTED_TURNS: - client.post(f"/api/sessions/{session_id}/turn", json={"content": turn_text}) - client.post(f"/api/sessions/{session_id}/end") - - rec_resp = client.post("/api/setup/outcome", json={"outcome": "demo"}) - assert rec_resp.status_code == 204 - - status = client.get("/api/setup/status").json() - assert status["kind"] != "never-run", ( - "After a demo session the status must not be 'never-run' — " - "the upgrade CTA (finish setup prompt) would be invisible" - ) - assert status.get("onboarding_outcome", {}).get("outcome") == "demo", ( - "Status must reflect the demo outcome for upgrade CTA routing" - ) - - def test_preflight_needs_human_no_forbidden_vocabulary(self, fresh_profile): - client, _ = fresh_profile - checks = client.get("/api/preflight").json()["checks"] - assert_no_forbidden_in_preflight(checks) diff --git a/e2e/onboarding/test_p5_remediation.py b/e2e/onboarding/test_p5_remediation.py index 54c75306..8b77a47c 100644 --- a/e2e/onboarding/test_p5_remediation.py +++ b/e2e/onboarding/test_p5_remediation.py @@ -242,17 +242,18 @@ def test_needs_human_fail_has_fix_action(self, fresh_profile): "first-run users have no path forward" ) - def test_text_only_wizard_step_choice_reachable(self, fresh_profile): - """The wizard exposes a text-only (demo) path independently of blocking checks. - - This verifies that the PreflightStep can always offer "text-only mode" as an - escape hatch for users who cannot resolve the blocking failure. - """ + def test_legacy_demo_outcome_recorded_but_incomplete(self, fresh_profile): + """Legacy 'demo' outcomes (written by old app versions; the no-model demo + path itself is gone, issue #473) still count as a recorded outcome — the + guard must not bounce such a profile back to Welcome — but they no longer + satisfy the model requirement, so the status is 'incomplete' and the app + shows the finish-setup banner.""" client, _ = fresh_profile - # Simulate the text-only path: record demo outcome without resolving preflight. resp = client.post("/api/setup/outcome", json={"outcome": "demo"}) assert resp.status_code == 204 status = client.get("/api/setup/status").json() assert status["kind"] != "never-run", ( - "After choosing text-only / demo mode the status must not remain 'never-run'" + "A recorded legacy demo outcome must not leave the status 'never-run'" ) + assert status["kind"] == "incomplete" + assert "llm-present" in status["missing"] diff --git a/e2e/onboarding/test_p7_regression_loop.py b/e2e/onboarding/test_p7_regression_loop.py index e7d8343e..d8408fc8 100644 --- a/e2e/onboarding/test_p7_regression_loop.py +++ b/e2e/onboarding/test_p7_regression_loop.py @@ -102,12 +102,13 @@ def test_setup_status_never_run_after_fix_action_cannot_occur(self, fresh_profil """ client, _ = fresh_profile - # Simulate the "choose demo / text-only" fix path (the universal escape hatch). + # A legacy 'demo' outcome (old app versions; the path is gone, issue #473) + # is still a recorded outcome and must not resurrect the Welcome redirect. client.post("/api/setup/outcome", json={"outcome": "demo"}) status = client.get("/api/setup/status").json() assert status["kind"] != "never-run", ( - "After the text-only escape fix_action (outcome=demo) the status must not be " - "'never-run' — that would redirect back to the Welcome screen" + "After recording an outcome the status must not be 'never-run' — that " + "would redirect back to the Welcome screen" ) def test_all_fix_action_kinds_have_non_welcome_href(self, fresh_profile): diff --git a/e2e/onboarding/test_p8_voice_deferral.py b/e2e/onboarding/test_p8_voice_deferral.py index c322e64e..cc74bcca 100644 --- a/e2e/onboarding/test_p8_voice_deferral.py +++ b/e2e/onboarding/test_p8_voice_deferral.py @@ -30,6 +30,8 @@ "tts_enabled": False, "show_state_meters": False, "save_transcript": False, + # Model-free sessions must pin their runtime explicitly (issue #473). + "runtime_id": "scripted", } diff --git a/services/convsim-core/convsim_core/routers/sessions.py b/services/convsim-core/convsim_core/routers/sessions.py index 36ac5d1e..6f9e7b8c 100644 --- a/services/convsim-core/convsim_core/routers/sessions.py +++ b/services/convsim-core/convsim_core/routers/sessions.py @@ -353,12 +353,15 @@ def _row_to_response(row: Any) -> SessionResponse: def _pinned_runtime_id(requested: str | None, conn: Any) -> str | None: """Return the runtime id to pin this session to, or None to follow the global one. - An explicit ``runtime_id`` on the create request wins: the scripted tutorial - asks for its runtime by name, so it cannot land on the fake runtime just - because the preceding ``use_model`` call failed, or because a background - model install flipped the global selection in the window between the two - requests. Otherwise fall back to the active selection, which pins demo-mode - and scripted sessions created through the ordinary setup form. + An explicit ``runtime_id`` on the create request wins: automated tests and + dev tooling ask for the model-free runtimes by name, so they cannot land on + a different runtime just because a background model install flipped the + global selection in the window between two requests. + + Without an explicit request, a global selection of ``fake``/``scripted`` + (e.g. the config default on a profile that never installed a model) is NOT + silently pinned — ``create_session`` rejects it instead (issue #473): a + player must never fall through to a facsimile conversation by accident. """ if requested in _SESSION_PINNED_RUNTIME_IDS: return requested @@ -410,13 +413,34 @@ async def create_session(body: SessionCreateRequest, request: Request) -> Sessio ) conn = request.app.state.db.connection() + + # Backstop for issue #473: a session may only run on a model-free runtime + # when the request explicitly pinned one (tests / dev tooling). Resolve the + # runtime the same way _resolve_runtime will — persisted active selection, + # else the shared app runtime (whose config default is "fake" on a profile + # that never installed a model) — and refuse to start a facsimile + # conversation that would masquerade as the product. + if body.runtime_id is None: + effective_runtime_id = get_active_config(conn).get("runtime_id") + if effective_runtime_id is None: + effective_runtime_id = getattr(request.app.state.runtime, "id", None) + if effective_runtime_id in _SESSION_PINNED_RUNTIME_IDS: + raise HTTPException( + status_code=409, + detail=( + "No AI model is configured, so a conversation cannot be " + "started. Finish setup (install the recommended model, or " + "connect Ollama or a local GGUF file) and try again." + ), + ) + session_id = _generate_session_id() now = _now_iso() setup_dict = body.model_dump() - # Pin scripted/fake sessions to their runtime for the whole session. Without - # this the tutorial would follow the global active runtime, which flips to - # llama.cpp the moment a background model install finishes — mid-conversation, - # or before the tutorial's authored debrief is generated. + # Pin explicitly-requested model-free sessions to their runtime for the + # whole session, so an automated test's scripted session keeps answering + # from its script even if a model install flips the global selection + # mid-conversation. pinned_runtime_id = _pinned_runtime_id(body.runtime_id, conn) if pinned_runtime_id is None: # Leave no key at all rather than a null one, so _resolve_runtime's diff --git a/services/convsim-core/convsim_core/routers/setup.py b/services/convsim-core/convsim_core/routers/setup.py index 4c6c028c..3eb1cbc0 100644 --- a/services/convsim-core/convsim_core/routers/setup.py +++ b/services/convsim-core/convsim_core/routers/setup.py @@ -28,7 +28,10 @@ class SetupStatusResponse(BaseModel): class RecordOutcomeRequest(BaseModel): - outcome: str # "completed-with-model" | "demo" | "skipped" + # "completed-with-model" | "skipped" ("demo" is legacy: written by app + # versions that offered the removed no-model demo path, issue #473; still + # accepted so old databases keep replaying their history cleanly). + outcome: str @router.post("/api/setup/outcome", status_code=204) @@ -86,19 +89,18 @@ async def get_setup_status(request: Request) -> SetupStatusResponse: active_cfg = get_active_config(conn) active_model_id: Optional[str] = active_cfg.get("model_id") - # Check LLM presence. Per issue #380, "ready" is engine + (model | demo - # choice) + packs — so a deliberate demo choice satisfies this requirement - # even though no real model is installed. Keying on the recorded outcome - # (not the default 'fake' runtime) keeps a failed model install from being - # mistaken for an intentional demo. + # Check LLM presence. "Ready" is engine + model + packs. A historical + # 'demo' outcome (recorded by app versions that offered the removed + # no-model demo path, issue #473) deliberately does NOT satisfy this any + # more: those profiles resolve to 'incomplete', which surfaces the + # non-blocking finish-setup banner steering them to install a real model. installed_row = conn.execute( "SELECT COUNT(*) AS cnt FROM installed_models " "WHERE install_status IN ('ready', 'complete')" ).fetchone() installed_count = installed_row["cnt"] if installed_row else 0 - chose_demo = outcome_row["outcome"] == "demo" - if installed_count == 0 and not active_model_id and not chose_demo: + if installed_count == 0 and not active_model_id: missing.append("llm-present") # Check packs diff --git a/services/convsim-core/tests/test_branch_session.py b/services/convsim-core/tests/test_branch_session.py index 3413a3c5..c3062c88 100644 --- a/services/convsim-core/tests/test_branch_session.py +++ b/services/convsim-core/tests/test_branch_session.py @@ -82,6 +82,9 @@ def client(tmp_config): "show_state_meters": False, "save_transcript": True, "seed": None, + # Tests pin the deterministic fake runtime explicitly (issue #473): a + # session may only run model-free when the request asks for it by name. + "runtime_id": "fake", } diff --git a/services/convsim-core/tests/test_debrief_engine.py b/services/convsim-core/tests/test_debrief_engine.py index 1b6e5c7a..46f0017a 100644 --- a/services/convsim-core/tests/test_debrief_engine.py +++ b/services/convsim-core/tests/test_debrief_engine.py @@ -80,8 +80,29 @@ def client(tmp_config): "show_state_meters": False, "save_transcript": True, "seed": None, + # Tests pin the deterministic fake runtime explicitly (issue #473): a + # session may only run model-free when the request asks for it by name. + "runtime_id": "fake", } + +# Sessions in the swap-runtime tests below stay UNPINNED so they follow +# app.state.runtime, which each test replaces with a purpose-built stub. The +# issue-#473 backstop requires a real-model selection for unpinned sessions, +# so these tests first activate one (the stub swap happens after creation, so +# the llama.cpp runtime itself is never invoked). +_UNPINNED_SETUP = dict(_VALID_SETUP) +_UNPINNED_SETUP.pop("runtime_id") + + +def _activate_real_runtime(app) -> None: + from convsim_core.services.model_manager_service import set_active_config + + set_active_config( + app.state.db.connection(), runtime_id="llama_cpp", model_id="/tmp/model.gguf" + ) + + _VALID_NARRATIVE = { "summary": "You had a solid practice session with room to grow.", "strengths": ["You gave clear STAR-format examples at turn 3."], @@ -739,7 +760,8 @@ class TestDebriefWithRubricObservations: def test_rubric_scores_reflect_observations(self, tmp_config): app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -770,7 +792,8 @@ def test_debrief_turning_points_reference_real_turns(self, tmp_config): """ app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -812,7 +835,8 @@ def test_debrief_turning_points_reference_real_turns(self, tmp_config): def test_debrief_with_multi_turn_session(self, tmp_config): app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -873,7 +897,8 @@ def test_debrief_generation_failure_transitions_session_to_error(self, tmp_confi """ app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") client.post( @@ -894,7 +919,8 @@ def test_debrief_retry_from_error_state_succeeds(self, tmp_config): """After a debrief failure (Error state), retrying with a working runtime succeeds.""" app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") client.post( @@ -1043,7 +1069,8 @@ def test_debrief_scores_still_computed_when_transcript_saving_disabled(self, tmp """Rubric scores are computed from turn_session_turns regardless of save_transcript.""" app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_NO_TRANSCRIPT_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json={**_UNPINNED_SETUP, "save_transcript": False}) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") diff --git a/services/convsim-core/tests/test_pack_scenario_sessions.py b/services/convsim-core/tests/test_pack_scenario_sessions.py index 95fc6f16..7c83b91f 100644 --- a/services/convsim-core/tests/test_pack_scenario_sessions.py +++ b/services/convsim-core/tests/test_pack_scenario_sessions.py @@ -46,6 +46,8 @@ def _create(client: TestClient, scenario_id: str, language: str = "en") -> str: "language": language, "player_role_name": "Test Player", "save_transcript": True, + # Explicit fake-runtime pin (issue #473). + "runtime_id": "fake", }, ) assert resp.status_code == 201, f"{scenario_id}: {resp.text}" @@ -89,6 +91,8 @@ def test_every_seeded_scenario_can_create_a_session(seeded_client): "language": language, "player_role_name": "Test Player", "save_transcript": True, + # Explicit pin (issue #473). + "runtime_id": "fake", }, ) if resp.status_code != 201: @@ -106,6 +110,8 @@ def test_pack_scenario_difficulty_presets_come_from_yaml(seeded_client): "language": "ja", "player_role_name": "Test Player", "save_transcript": True, + # Explicit pin (issue #473). + "runtime_id": "fake", }, ) assert resp.status_code == 201, resp.text diff --git a/services/convsim-core/tests/test_packaged_smoke.py b/services/convsim-core/tests/test_packaged_smoke.py index 6bad4fbc..e299ceac 100644 --- a/services/convsim-core/tests/test_packaged_smoke.py +++ b/services/convsim-core/tests/test_packaged_smoke.py @@ -188,6 +188,9 @@ def test_tutorial_is_playable_offline_with_zero_models(self, scripted_client): "show_state_meters": True, "save_transcript": False, "seed": 1, + # Explicit pin (issue #473): model-free sessions must ask for + # their runtime by name. + "runtime_id": "scripted", }, ) assert create.status_code == 201, create.text @@ -244,6 +247,8 @@ def test_tutorial_debrief_is_available_offline(self, scripted_client): "show_state_meters": True, "save_transcript": True, "seed": 1, + # Explicit pin (issue #473). + "runtime_id": "scripted", }, ) assert create.status_code == 201, create.text diff --git a/services/convsim-core/tests/test_scripted_runtime.py b/services/convsim-core/tests/test_scripted_runtime.py index 0d165840..eee7a0d9 100644 --- a/services/convsim-core/tests/test_scripted_runtime.py +++ b/services/convsim-core/tests/test_scripted_runtime.py @@ -330,39 +330,34 @@ def test_scripted_debrief_passes_debrief_validation(): } -def test_tutorial_turn_uses_scripted_runtime_when_active_config_is_scripted(tmp_config): - """When active_runtime_id='scripted', submit_turn produces the tutorial script, not a fake response.""" +def test_unpinned_session_on_a_model_free_selection_is_refused(tmp_config): + """Issue #473 backstop: a session that would silently resolve to a + model-free runtime (active selection 'scripted'/'fake', or the config + default 'fake' on a fresh profile) is refused with a clear 409 unless the + request pins that runtime explicitly. A player can never fall through to a + facsimile conversation by accident.""" from convsim_core.app import create_app from convsim_core.services.model_manager_service import set_active_config from fastapi.testclient import TestClient app = create_app(tmp_config) with TestClient(app) as client: - # Seed the official packs so first_words_tutorial is resolvable. - import convsim_core.scenarios # noqa: F401 - set_active_config(app.state.db.connection(), runtime_id="scripted") + # Fresh profile: no active selection; the shared app runtime is the + # config-default fake runtime. + res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) + assert res.status_code == 409, res.text + assert "finish setup" in res.json()["detail"].lower() + # Persisted model-free selection: same refusal. + set_active_config(app.state.db.connection(), runtime_id="scripted") res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) - assert res.status_code == 201, res.text - session_id = res.json()["session_id"] + assert res.status_code == 409, res.text - client.post(f"/api/sessions/{session_id}/start") - turn_res = client.post( - f"/api/sessions/{session_id}/turn", - json={"content": "Hello, let's get started!"}, - ) - assert turn_res.status_code == 200, turn_res.text - body = turn_res.json() - npc_events = [e for e in body["events"] if e["event_type"] == "npc_turn"] - assert npc_events, "No npc_turn event found in turn response" - npc_text = npc_events[0]["payload"]["content"] - # Scripted runtime produces the authored tutorial text, not the fake placeholder. - assert "meter" in npc_text.lower() or "engagement" in npc_text.lower() or "turn" in npc_text.lower(), ( - f"Expected scripted tutorial response, got: {npc_text!r}" - ) - assert "simulated npc" not in npc_text.lower(), ( - f"Got fake runtime response instead of scripted: {npc_text!r}" + # The explicit pin keeps working (tests / dev tooling). + res = client.post( + "/api/sessions", json={**_TUTORIAL_SESSION_SETUP, "runtime_id": "scripted"} ) + assert res.status_code == 201, res.text def test_tutorial_debrief_uses_scripted_debrief_when_active_config_is_scripted(tmp_config): @@ -376,7 +371,9 @@ def test_tutorial_debrief_uses_scripted_debrief_when_active_config_is_scripted(t with TestClient(app) as client: set_active_config(app.state.db.connection(), runtime_id="scripted") - res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) + res = client.post( + "/api/sessions", json={**_TUTORIAL_SESSION_SETUP, "runtime_id": "scripted"} + ) assert res.status_code == 201, res.text session_id = res.json()["session_id"] @@ -414,7 +411,9 @@ def test_tutorial_stays_scripted_after_a_model_install_flips_the_active_runtime( conn = app.state.db.connection() set_active_config(conn, runtime_id="scripted") - res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) + res = client.post( + "/api/sessions", json={**_TUTORIAL_SESSION_SETUP, "runtime_id": "scripted"} + ) assert res.status_code == 201, res.text session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -494,12 +493,17 @@ def test_session_runtime_id_rejects_a_sidecar_backed_runtime(tmp_config): def test_session_without_runtime_id_stores_no_runtime_key(tmp_config): - """A normal session records no pin, so it keeps following the global selection.""" + """A normal session on a real-model profile records no pin, so it keeps + following the global selection.""" from convsim_core.app import create_app + from convsim_core.services.model_manager_service import set_active_config from fastapi.testclient import TestClient app = create_app(tmp_config) with TestClient(app) as client: + set_active_config( + app.state.db.connection(), runtime_id="llama_cpp", model_id="/tmp/model.gguf" + ) res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) assert res.status_code == 201, res.text assert "runtime_id" not in res.json()["setup"] diff --git a/services/convsim-core/tests/test_setup_router.py b/services/convsim-core/tests/test_setup_router.py index 2d944d5a..f6ed1580 100644 --- a/services/convsim-core/tests/test_setup_router.py +++ b/services/convsim-core/tests/test_setup_router.py @@ -47,18 +47,17 @@ def test_most_recent_outcome_wins(client): assert body["onboarding_outcome"]["outcome"] == "demo" -def test_demo_choice_satisfies_the_llm_requirement(client): - """A deliberate demo choice counts as a model per issue #380. - - 'ready' is engine + (model | demo choice) + packs, so a demo user with no - installed model must NOT be told the LLM is missing — otherwise they'd see a - permanent, wrong "finish setup" banner. (packs-seeded is still reported - missing here because the throwaway DB seeds none.) - """ +def test_legacy_demo_choice_no_longer_satisfies_the_llm_requirement(client): + """Issue #473 reverses the #380 rule: the no-model demo path is gone, so a + historical 'demo' outcome no longer counts as a model. Such a profile is + 'incomplete' with llm-present missing, which surfaces the non-blocking + finish-setup banner steering the user to install a real model.""" client.post("/api/setup/outcome", json={"outcome": "demo"}) body = client.get("/api/setup/status").json() - assert "llm-present" not in body["missing"] + assert body["kind"] != "never-run" + assert "llm-present" in body["missing"] + assert body["kind"] == "incomplete" def test_completed_with_model_still_reports_missing_llm_without_a_model(client): diff --git a/services/convsim-core/tests/test_transcript_persistence.py b/services/convsim-core/tests/test_transcript_persistence.py index 9bd44791..3d4184d8 100644 --- a/services/convsim-core/tests/test_transcript_persistence.py +++ b/services/convsim-core/tests/test_transcript_persistence.py @@ -52,6 +52,9 @@ def client(tmp_config): "show_state_meters": False, "save_transcript": True, "seed": None, + # Tests pin the deterministic fake runtime explicitly (issue #473): a + # session may only run model-free when the request asks for it by name. + "runtime_id": "fake", } diff --git a/services/convsim-core/tests/test_tts_queue.py b/services/convsim-core/tests/test_tts_queue.py index 7af699cd..af9cf80b 100644 --- a/services/convsim-core/tests/test_tts_queue.py +++ b/services/convsim-core/tests/test_tts_queue.py @@ -221,6 +221,8 @@ def test_chunk_result_not_succeeded_when_audio_path_none(): "tts_enabled": False, "tts_voice_id": "af_heart", "save_transcript": True, + # Explicit fake-runtime pin (issue #473). + "runtime_id": "fake", } diff --git a/services/convsim-core/tests/test_turn_pipeline.py b/services/convsim-core/tests/test_turn_pipeline.py index a6dcaf63..c7ca196d 100644 --- a/services/convsim-core/tests/test_turn_pipeline.py +++ b/services/convsim-core/tests/test_turn_pipeline.py @@ -66,9 +66,30 @@ "show_state_meters": False, "save_transcript": True, "seed": None, + # Tests pin the deterministic fake runtime explicitly (issue #473): a + # session may only run model-free when the request asks for it by name. + "runtime_id": "fake", } +# Sessions in the tests below deliberately stay UNPINNED so they follow +# app.state.runtime, which each test swaps for a purpose-built stub. The +# issue-#473 backstop requires a real-model selection for unpinned sessions, +# so these tests first activate one (the stub swap happens after creation, so +# the llama.cpp runtime itself is never invoked). +_UNPINNED_SETUP = dict(_VALID_SETUP) +_UNPINNED_SETUP.pop("runtime_id") + + +def _activate_real_runtime(app) -> None: + from convsim_core.services.model_manager_service import set_active_config + + set_active_config( + app.state.db.connection(), runtime_id="llama_cpp", model_id="/tmp/model.gguf" + ) + + + def _create_and_start(client: TestClient) -> str: """Helper: create + start a session, return session_id.""" res = client.post("/api/sessions", json=_VALID_SETUP) @@ -429,7 +450,8 @@ def test_safety_stop_ends_session(self, tmp_config): app.state # ensure lifespan runs via TestClient with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -448,7 +470,8 @@ def test_safety_stop_ends_session(self, tmp_config): def test_safety_redirect_keeps_session_alive(self, tmp_config): app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -503,7 +526,8 @@ def test_invalid_model_output_uses_safe_fallback(self, tmp_config): from convsim_prompt import SAFE_FALLBACK_UTTERANCE app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -556,7 +580,8 @@ def test_state_delta_applied_and_persisted(self, tmp_config): """State changes from the runtime are applied and stored for next turn.""" app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -576,7 +601,8 @@ def test_state_carries_into_subsequent_turns(self, tmp_config): """State vars from turn N are available (via DB) for prompt building in turn N+1.""" app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") @@ -1230,7 +1256,8 @@ def test_debug_fallback_flag_set_when_runtime_returns_garbage(self, tmp_config): from convsim_prompt import SAFE_FALLBACK_UTTERANCE app = create_app(tmp_config) with TestClient(app) as client: - res = client.post("/api/sessions", json=_VALID_SETUP) + _activate_real_runtime(app) + res = client.post("/api/sessions", json=_UNPINNED_SETUP) session_id = res.json()["session_id"] client.post(f"/api/sessions/{session_id}/start") diff --git a/services/convsim-core/tests/test_voice_smoke.py b/services/convsim-core/tests/test_voice_smoke.py index bd8042d2..75e81840 100644 --- a/services/convsim-core/tests/test_voice_smoke.py +++ b/services/convsim-core/tests/test_voice_smoke.py @@ -173,6 +173,9 @@ def _create_and_start( "show_state_meters": False, "save_transcript": True, "seed": None, + # Explicit fake-runtime pin (issue #473): tests must ask for the + # model-free runtime by name. + "runtime_id": "fake", } create_resp = client.post("/api/sessions", json=setup) assert create_resp.status_code == 201, ( diff --git a/tests/acceptance/test_network_guard.py b/tests/acceptance/test_network_guard.py index c9193bdc..b3d0290e 100644 --- a/tests/acceptance/test_network_guard.py +++ b/tests/acceptance/test_network_guard.py @@ -213,6 +213,9 @@ def _guard_client(_voice_config): "show_state_meters": False, "save_transcript": True, "seed": None, + # Explicit fake-runtime pin (issue #473): model-free sessions must ask for + # their runtime by name. + "runtime_id": "fake", } _SETUP_VOICE = { **_SETUP_TEXT, diff --git a/tests/acceptance/test_player_text_path.py b/tests/acceptance/test_player_text_path.py index 5ec7d183..c13a6d6f 100644 --- a/tests/acceptance/test_player_text_path.py +++ b/tests/acceptance/test_player_text_path.py @@ -36,9 +36,23 @@ "show_state_meters": False, "save_transcript": True, "seed": None, + # Explicit fake-runtime pin (issue #473): model-free sessions must ask for + # their runtime by name. + "runtime_id": "fake", } +def _activate_real_runtime(app) -> None: + """Pretend a real model is selected so an UNPINNED session passes the + issue-#473 backstop and follows app.state.runtime (which swap-runtime + tests replace with a stub after creation).""" + from convsim_core.services.model_manager_service import set_active_config + + set_active_config( + app.state.db.connection(), runtime_id="llama_cpp", model_id="/tmp/model.gguf" + ) + + def _make_minimal_zip() -> bytes: """Build a minimal installable pack zip for acceptance scenario library tests.""" buf = io.BytesIO() @@ -303,7 +317,11 @@ def test_state_delta_is_present_in_npc_event(self, client): def test_state_delta_applied_to_session(self, tmp_config): app = create_app(tmp_config) with TestClient(app) as c: - res = c.post("/api/sessions", json=_SESSION_SETUP) + # Unpinned on purpose: the session must follow app.state.runtime, + # which is swapped below. The issue-#473 backstop requires a + # real-model selection for unpinned sessions, so activate one. + _activate_real_runtime(app) + res = c.post("/api/sessions", json={k: v for k, v in _SESSION_SETUP.items() if k != "runtime_id"}) session_id = res.json()["session_id"] c.post(f"/api/sessions/{session_id}/start") app.state.runtime = _StateDeltaRuntime() @@ -319,7 +337,8 @@ def test_state_delta_applied_to_session(self, tmp_config): def test_state_carries_into_subsequent_turn(self, tmp_config): app = create_app(tmp_config) with TestClient(app) as c: - res = c.post("/api/sessions", json=_SESSION_SETUP) + _activate_real_runtime(app) + res = c.post("/api/sessions", json={k: v for k, v in _SESSION_SETUP.items() if k != "runtime_id"}) session_id = res.json()["session_id"] c.post(f"/api/sessions/{session_id}/start") app.state.runtime = _StateDeltaRuntime() diff --git a/tests/e2e/test_scripted_playthrough.py b/tests/e2e/test_scripted_playthrough.py index 227c3a09..0ccfbb83 100644 --- a/tests/e2e/test_scripted_playthrough.py +++ b/tests/e2e/test_scripted_playthrough.py @@ -35,6 +35,9 @@ "show_state_meters": False, "save_transcript": False, # no transcript file written during smoke runs "seed": None, + # Explicit fake-runtime pin (issue #473): model-free sessions must ask for + # their runtime by name. + "runtime_id": "fake", } # Three scripted player turns — generic enough for any interview-style scenario.