Skip to content
23 changes: 13 additions & 10 deletions apps/web/src/__tests__/FirstRunWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock('../api/client', () => ({
startSetupInstall: vi.fn(),
getSetupInstallStatus: vi.fn(),
cancelSetupInstall: 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 } } }),
},
}))

Expand Down Expand Up @@ -108,6 +109,7 @@ function renderWizard() {
<Route path="/" element={<div data-testid="home-page" />} />
<Route path="/library" element={<div data-testid="library-page" />} />
<Route path="/setup/*" element={<div data-testid="setup-page" />} />
<Route path="/conversation/:sessionId" element={<div data-testid="conversation-page" />} />
</Routes>
</MemoryRouter>,
)
Expand Down Expand Up @@ -158,6 +160,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.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 } } })
})

// ── Welcome step ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -213,16 +216,16 @@ describe('FirstRunWizard — welcome step', () => {
expect(mockApi.startSetupInstall).toHaveBeenCalledWith('qwen3-4b-instruct-q4_k_m')
})

it('Try it right now starts the scripted tutorial (navigates to the tutorial scenario setup route)', async () => {
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('setup-page')).toBeInTheDocument())
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('setup-page')).toBeInTheDocument())
await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument())
expect(localStorage.getItem(SETUP_KEYS.firstRunComplete)).toBe('true')
})

Expand Down Expand Up @@ -1083,25 +1086,25 @@ describe('FirstRunWizard — "Try it right now" tutorial path', () => {
)
})

it('navigates to the tutorial and marks setup complete', async () => {
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('setup-page')).toBeInTheDocument())
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('setup-page')).toBeInTheDocument())
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('setup-page')).toBeInTheDocument())
await waitFor(() => expect(screen.getByTestId('conversation-page')).toBeInTheDocument())
})
})

Expand Down Expand Up @@ -1151,7 +1154,7 @@ describe('FirstRunWizard — tutorial CTA during install', () => {
await waitFor(() =>
expect(mockApi.useModel).toHaveBeenCalledWith({ runtime_id: 'scripted', model_id: null }),
)
await screen.findByTestId('setup-page')
await screen.findByTestId('conversation-page')
})

it('marks tutorial complete in localStorage when starting the tutorial', async () => {
Expand Down Expand Up @@ -1188,10 +1191,10 @@ describe('FirstRunWizard — tutorial CTA during install', () => {
)
})

it('navigates to the tutorial scenario setup route (a real, mounted route)', async () => {
it('navigates directly to the tutorial conversation (a real, mounted route)', async () => {
await goToInstalling()
fireEvent.click(screen.getByRole('button', { name: /start now/i }))
await screen.findByTestId('setup-page')
await screen.findByTestId('conversation-page')
})
})

Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,21 @@ describe('api — network errors', () => {
});
});

// ---------------------------------------------------------------------------
// Empty response bodies
// ---------------------------------------------------------------------------

describe('api — 204 No Content', () => {
it('returns ok:true for an endpoint that answers 204 with an empty body', async () => {
// POST /api/setup/outcome is declared status_code=204. Parsing its empty body
// as JSON throws, which used to surface a bogus runtime-unreachable error.
mockFetch(204, '');
const result = await api.recordOnboardingOutcome('demo');
expect(result.ok).toBe(true);
if (result.ok) expect(result.data).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
// api.getScenario
// ---------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ async function handleResponse<T>(res: Response): Promise<ApiResult<T>> {
if (!res.ok) {
return { ok: false, error: await errorFromResponse(res) }
}
// 204 No Content — the server intentionally sent no body.
if (res.status === 204) {
return { ok: true, data: undefined as T }
}
let text = ''
try { text = await res.text() } catch { /* ignore */ }
let data: T
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/setup/__tests__/useSetupFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,19 @@ vi.mock('../../api/client', () => ({
startSetupInstall: vi.fn(),
getSetupInstallStatus: vi.fn(),
cancelSetupInstall: vi.fn(),
createSession: vi.fn().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 } } }),
},
}))

import { api } from '../../api/client'
const mockApi = vi.mocked(api)

const mockNavigate = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>()
return { ...actual, useNavigate: () => mockNavigate }
})

import type { ModelsResponse, PreflightResponse } from '@convsim/shared'

const MODELS_DATA: ModelsResponse = {
Expand Down Expand Up @@ -102,12 +109,14 @@ const RUNNING_JOB = {

beforeEach(() => {
vi.clearAllMocks()
mockNavigate.mockReset()
localStorage.clear()
mockApi.getModels.mockResolvedValue({ ok: true, data: MODELS_DATA })
mockApi.preflight.mockResolvedValue({ ok: true, data: PREFLIGHT_PASS })
mockApi.startSetupInstall.mockResolvedValue({ ok: true, data: RUNNING_JOB })
mockApi.getSetupInstallStatus.mockResolvedValue({ ok: true, data: RUNNING_JOB })
mockApi.cancelSetupInstall.mockResolvedValue({ ok: true, data: undefined })
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 } } })
})

describe('useSetupFlow step machine', () => {
Expand Down Expand Up @@ -452,6 +461,63 @@ describe('useSetupFlow step machine', () => {
expect(localStorage.getItem('convsim.tutorial.complete')).toBe('true')
})

it('handleStartTutorial creates tutorial session directly, bypassing setup form', async () => {
mockApi.useModel.mockResolvedValue(SCRIPTED_RUNTIME_RESPONSE)
const { result } = renderHook(() => useSetupFlow('welcome'), { wrapper })

await act(async () => { await result.current.handleStartTutorial() })

expect(mockApi.createSession).toHaveBeenCalledWith({
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',
})
})

it('handleStartTutorial pins the tutorial session to the scripted runtime even if useModel fails', async () => {
// useModel only moves the global selection, and it can lose a race with the
// background install that flips it back to llama.cpp. The session must carry
// its own runtime so the tutorial never lands on the fake NPC.
mockApi.useModel.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'runtime unavailable' } })
const { result } = renderHook(() => useSetupFlow('welcome'), { wrapper })

await act(async () => { await result.current.handleStartTutorial() })

expect(mockApi.createSession).toHaveBeenCalledWith(
expect.objectContaining({ runtime_id: 'scripted' }),
)
expect(mockNavigate).toHaveBeenCalledWith('/conversation/sess-tutorial-1', expect.anything())
})

it('handleStartTutorial navigates to /conversation/:sessionId on successful session creation', async () => {
mockApi.useModel.mockResolvedValue(SCRIPTED_RUNTIME_RESPONSE)
const { result } = renderHook(() => useSetupFlow('welcome'), { wrapper })

await act(async () => { await result.current.handleStartTutorial() })

expect(mockNavigate).toHaveBeenCalledWith(
'/conversation/sess-tutorial-1',
expect.objectContaining({ state: expect.objectContaining({ scenario_id: 'first_words_tutorial', show_state_meters: true }) }),
)
})

it('handleStartTutorial falls back to /setup/first_words_tutorial when createSession fails', async () => {
mockApi.useModel.mockResolvedValue(SCRIPTED_RUNTIME_RESPONSE)
mockApi.createSession.mockResolvedValue({ ok: false, error: { kind: 'runtime-unreachable', message: 'Core not running' } })
const { result } = renderHook(() => useSetupFlow('welcome'), { wrapper })

await act(async () => { await result.current.handleStartTutorial() })

expect(mockNavigate).toHaveBeenCalledWith('/setup/first_words_tutorial')
})

it('install-complete effect clears runtime hint keys so real-model sessions are not mislabeled', async () => {
// Seed localStorage with stale keys that would have been written by handleStartTutorial
localStorage.setItem('convsim.active_runtime_hint', 'scripted')
Expand Down
66 changes: 50 additions & 16 deletions apps/web/src/setup/useSetupFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,23 +363,57 @@ export function useSetupFlow(
// called here.
const activeInstallId = installId
setActionLoading(true)
try { await api.useModel({ runtime_id: 'scripted', model_id: null }) } catch { /* best-effort */ }
finally { setActionLoading(false) }
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()
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)
}
navigate('/setup/first_words_tutorial')
}

async function handleCancelInstall() {
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface SessionCreateRequest {
show_state_meters: boolean;
save_transcript: boolean;
seed: number | null;
// Pins the session to a model-free runtime for its whole lifetime, instead of
// following the globally selected runtime. Only the scripted tutorial and demo
// mode need this; omit it and the backend uses the active selection.
runtime_id?: 'scripted' | 'fake';
}

export interface SessionCreateResponse {
Expand Down
Loading
Loading