diff --git a/apps/web/src/__tests__/FirstRunWizard.test.tsx b/apps/web/src/__tests__/FirstRunWizard.test.tsx index 3e879403..fabf3170 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(), + 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 } } }), }, })) @@ -108,6 +109,7 @@ function renderWizard() { } /> } /> } /> + } /> , ) @@ -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 ───────────────────────────────────────────────────────────── @@ -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') }) @@ -1083,17 +1086,17 @@ 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') }) @@ -1101,7 +1104,7 @@ describe('FirstRunWizard — "Try it right now" tutorial path', () => { 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()) }) }) @@ -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 () => { @@ -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') }) }) diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index f1d4490a..0206266b 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e43f34e0..e32673de 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -227,6 +227,10 @@ async function handleResponse(res: Response): Promise> { 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 diff --git a/apps/web/src/setup/__tests__/useSetupFlow.test.ts b/apps/web/src/setup/__tests__/useSetupFlow.test.ts index 3d2493df..be12dec2 100644 --- a/apps/web/src/setup/__tests__/useSetupFlow.test.ts +++ b/apps/web/src/setup/__tests__/useSetupFlow.test.ts @@ -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() + return { ...actual, useNavigate: () => mockNavigate } +}) + import type { ModelsResponse, PreflightResponse } from '@convsim/shared' const MODELS_DATA: ModelsResponse = { @@ -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', () => { @@ -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') diff --git a/apps/web/src/setup/useSetupFlow.ts b/apps/web/src/setup/useSetupFlow.ts index fee271de..7ced04af 100644 --- a/apps/web/src/setup/useSetupFlow.ts +++ b/apps/web/src/setup/useSetupFlow.ts @@ -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() { diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 39d0dc07..4027f43b 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -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 { diff --git a/services/convsim-core/convsim_core/routers/sessions.py b/services/convsim-core/convsim_core/routers/sessions.py index f30fce0f..cf5c5093 100644 --- a/services/convsim-core/convsim_core/routers/sessions.py +++ b/services/convsim-core/convsim_core/routers/sessions.py @@ -24,10 +24,13 @@ from fastapi.responses import PlainTextResponse from pydantic import BaseModel, field_validator +from convsim_core.runtime import build_runtime +from convsim_core.runtime.base import ChatRuntime from convsim_core.scenario_state import build_variable_defs, partition_state_by_visibility from convsim_core.scenarios import get_scenario_info from convsim_core.services.branch_service import fork_session from convsim_core.services.debrief_engine import generate_debrief +from convsim_core.services.model_manager_service import get_active_config from convsim_core.services.relationship_memory import update_relationship_memory from convsim_core.services.timing import thinking_pause_ms_for_difficulty from convsim_core.services.transcript_export import format_transcript_as_markdown @@ -79,6 +82,10 @@ class SessionCreateRequest(BaseModel): show_state_meters: bool = False save_transcript: bool = True seed: Optional[int] = None + # Pins the session to a model-free runtime for its whole lifetime (issue #427). + # Restricted to the runtimes that need no model and reach nothing off-box, so a + # client can never point a session at a sidecar-backed runtime this way. + runtime_id: Optional[Literal["scripted", "fake"]] = None @field_validator("player_role_name") @classmethod @@ -332,6 +339,52 @@ def _row_to_response(row: Any) -> SessionResponse: ) +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +#: Runtimes that are stateless, model-free and cheap to instantiate per request. +#: A session that starts on one of these keeps it for its whole lifetime, so a +#: scripted tutorial cannot be hijacked by a model install that finishes mid-play. +_SESSION_PINNED_RUNTIME_IDS = ("scripted", "fake") + + +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. + """ + if requested in _SESSION_PINNED_RUNTIME_IDS: + return requested + runtime_id = get_active_config(conn).get("runtime_id") + return runtime_id if runtime_id in _SESSION_PINNED_RUNTIME_IDS else None + + +def _resolve_runtime(request: Request, setup: Dict[str, Any]) -> ChatRuntime: + """Return the runtime this session was pinned to, else the shared runtime. + + ``setup["runtime_id"]`` is snapshotted at session creation (see + ``create_session``) when the session asked for a scripted/fake runtime or the + active selection was one of those, so a tutorial keeps answering from its + authored script even after a background model install flips the global active + runtime to llama.cpp. + + For sidecar-based runtimes (llama.cpp, Ollama) — and for sessions created + before this snapshot existed — the shared ``app.state.runtime`` is returned + to preserve connection-pool state and support test-injected runtimes. + """ + runtime_id = setup.get("runtime_id") + if runtime_id in _SESSION_PINNED_RUNTIME_IDS: + return build_runtime(runtime_id) + return request.app.state.runtime + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -361,6 +414,17 @@ async def create_session(body: SessionCreateRequest, request: Request) -> Sessio 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. + 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 + # membership test reads the same for old and new sessions. + setup_dict.pop("runtime_id", None) + else: + setup_dict["runtime_id"] = pinned_runtime_id conn.execute( "INSERT INTO turn_sessions " @@ -487,7 +551,7 @@ async def submit_turn(session_id: str, body: TurnSubmitRequest, request: Request scenario_data = info.get_scenario_data(difficulty) max_turns = info.max_turns - runtime = request.app.state.runtime + runtime = _resolve_runtime(request, setup) save_transcript = setup.get("save_transcript", True) source_mode = setup.get("input_mode", "text-only") @@ -727,7 +791,7 @@ async def create_debrief(session_id: str, request: Request) -> DebriefResponse: raise HTTPException(status_code=500, detail=f"Scenario {scenario_id!r} not found in registry") scenario_data = info.get_scenario_data(difficulty) - runtime = request.app.state.runtime + runtime = _resolve_runtime(request, setup) try: result = await generate_debrief( diff --git a/services/convsim-core/tests/test_scripted_runtime.py b/services/convsim-core/tests/test_scripted_runtime.py index 7dacb383..0d165840 100644 --- a/services/convsim-core/tests/test_scripted_runtime.py +++ b/services/convsim-core/tests/test_scripted_runtime.py @@ -310,3 +310,196 @@ def test_scripted_debrief_passes_debrief_validation(): narrative = _validate_narrative(_DEBRIEF_RESPONSE) assert narrative.used_fallback is False assert narrative.turning_points # scripted debrief keeps its turning point + + +# ── Session router integration — scripted runtime selection (issue #427) ────── +# When use_model selects the "scripted" runtime, submit_turn and generate_debrief +# must use ScriptedChatRuntime (not the fake runtime stored in app.state.runtime). +# These tests drive the tutorial scenario via the HTTP API to confirm end-to-end. + +_TUTORIAL_SESSION_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": None, +} + + +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.""" + 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") + + res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) + assert res.status_code == 201, res.text + session_id = res.json()["session_id"] + + 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}" + ) + + +def test_tutorial_debrief_uses_scripted_debrief_when_active_config_is_scripted(tmp_config): + """Debrief for a tutorial session uses the scripted runtime's authored debrief content.""" + from convsim_core.app import create_app + from convsim_core.services.model_manager_service import set_active_config + from convsim_core.runtime.scripted import _DEBRIEF_RESPONSE + from fastapi.testclient import TestClient + + app = create_app(tmp_config) + with TestClient(app) as client: + 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"] + + client.post(f"/api/sessions/{session_id}/start") + for _ in range(6): + client.post( + f"/api/sessions/{session_id}/turn", + json={"content": "I'm excited to learn!"}, + ) + client.post(f"/api/sessions/{session_id}/end") + + debrief_res = client.post(f"/api/sessions/{session_id}/debrief") + assert debrief_res.status_code == 200, debrief_res.text + body = debrief_res.json() + # Scripted debrief has the authored summary, not the fake boilerplate. + assert body["summary"] == _DEBRIEF_RESPONSE["summary"] + + +def test_tutorial_stays_scripted_after_a_model_install_flips_the_active_runtime(tmp_config): + """A tutorial in progress keeps its scripted runtime when an install completes mid-play. + + The "Start now" CTA runs the tutorial while a model downloads in the + background; the setup-install pipeline calls set_active_config(llama_cpp) + when that download finishes. The session must stay pinned to the runtime it + was created with, or the rest of the tutorial — and its authored debrief — + would be routed to the freshly installed model. + """ + from convsim_core.app import create_app + from convsim_core.services.model_manager_service import set_active_config + from convsim_core.runtime.scripted import _DEBRIEF_RESPONSE + from fastapi.testclient import TestClient + + app = create_app(tmp_config) + with TestClient(app) as client: + conn = app.state.db.connection() + set_active_config(conn, 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"] + client.post(f"/api/sessions/{session_id}/start") + client.post(f"/api/sessions/{session_id}/turn", json={"content": "Hello!"}) + + # The background install finishes: the pipeline flips the global runtime. + set_active_config(conn, runtime_id="llama_cpp", model_id="/tmp/model.gguf") + + turn_res = client.post( + f"/api/sessions/{session_id}/turn", + json={"content": "Still here — what next?"}, + ) + assert turn_res.status_code == 200, turn_res.text + npc_events = [e for e in turn_res.json()["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"] + assert "simulated npc" not in npc_text.lower(), ( + f"Tutorial fell back to app.state.runtime after the install: {npc_text!r}" + ) + + for _ in range(5): + client.post(f"/api/sessions/{session_id}/turn", json={"content": "Go on."}) + client.post(f"/api/sessions/{session_id}/end") + + debrief_res = client.post(f"/api/sessions/{session_id}/debrief") + assert debrief_res.status_code == 200, debrief_res.text + assert debrief_res.json()["summary"] == _DEBRIEF_RESPONSE["summary"] + + +def test_session_runtime_id_pins_the_script_when_the_global_selection_is_not_scripted(tmp_config): + """An explicit runtime_id on the create request pins the session on its own. + + The web client sets the global selection with use_model before creating the + tutorial session, but that call can fail, and the background install can flip + the selection to llama_cpp in the window between the two requests. Neither may + drop the tutorial onto the fake NPC. + """ + 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: + # Global selection is a real model — as if use_model('scripted') never landed. + 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, "runtime_id": "scripted"}, + ) + assert res.status_code == 201, res.text + session_id = res.json()["session_id"] + + client.post(f"/api/sessions/{session_id}/start") + turn_res = client.post(f"/api/sessions/{session_id}/turn", json={"content": "Hello!"}) + assert turn_res.status_code == 200, turn_res.text + npc_events = [e for e in turn_res.json()["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"] + assert "simulated npc" not in npc_text.lower(), ( + f"Explicit runtime_id was ignored; got the fake runtime: {npc_text!r}" + ) + + +def test_session_runtime_id_rejects_a_sidecar_backed_runtime(tmp_config): + """Only the model-free runtimes may be requested per-session.""" + from convsim_core.app import create_app + from fastapi.testclient import TestClient + + app = create_app(tmp_config) + with TestClient(app) as client: + res = client.post( + "/api/sessions", + json={**_TUTORIAL_SESSION_SETUP, "runtime_id": "llama_cpp"}, + ) + assert res.status_code == 422, res.text + + +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.""" + from convsim_core.app import create_app + from fastapi.testclient import TestClient + + app = create_app(tmp_config) + with TestClient(app) as client: + res = client.post("/api/sessions", json=_TUTORIAL_SESSION_SETUP) + assert res.status_code == 201, res.text + assert "runtime_id" not in res.json()["setup"]