"""Unit tests for the assist session model + context-binding + mode-conflict (TASK-01-06). Covers SLICE-01: - AssistContextBinder.bind() — ≤200-word system prompt, defaults on missing state - AssistSession.start / log_assist_turn / end — session_type='assist', verdict logged - D-063: end() does NOT call run_mastery_flow (no mastery update for assist) - Mode-conflict (REQ-IDEATE-03): assist during active practice → ModeConflictError - Backward compat: existing practice-session store methods still work """ from __future__ import annotations import asyncio import json from pathlib import Path import pytest from db.migrate import apply_migrations from db.store import PraxisStore, HARDCODED_LEARNER_ID from server.assist.context import AssistContextBinder, COACHING_INSTRUCTION from server.assist.mode_conflict import ModeConflictError, enforce_mutual_exclusivity from server.assist.session import AssistSession @pytest.fixture def store(tmp_path: Path) -> PraxisStore: db = tmp_path / "test_assist.db" apply_migrations(db) s = PraxisStore(db) asyncio.run(s.init()) return s def _ctx(week: int = 1, tag: str = "damaged-product refund"): """Build a minimal AssistContext for tests that don't need the binder.""" from server.assist.context import AssistContext return AssistContext( system_prompt=f"{COACHING_INSTRUCTION}\n\nWeek {week}, {tag}.\n\nBe brief.", current_week=week, scenario_tag=tag, theta=0.0, coaching_focus="empathy", path_slug="customer_service", ) # ── AssistContextBinder ────────────────────────────────────────────────────── def test_context_binder_returns_prompt(store: PraxisStore): binder = AssistContextBinder(store) async def _run(): return await binder.bind(HARDCODED_LEARNER_ID, "customer_service", "damaged-product refund") ctx = asyncio.run(_run()) assert ctx.system_prompt assert len(ctx.system_prompt.split()) <= 200 # D-066 word budget assert "coaching" in ctx.system_prompt.lower() or "coach" in ctx.system_prompt.lower() assert "Week 1" in ctx.system_prompt # default week (no progress row) assert "damaged-product refund" in ctx.system_prompt assert "Be brief" in ctx.system_prompt # voice-conciseness tail def test_context_binder_defaults_on_missing_state(store: PraxisStore): """No progress row, no theta → defaults (week=1, theta=0.0, focus=generic).""" binder = AssistContextBinder(store) async def _run(): return await binder.bind(HARDCODED_LEARNER_ID, "customer_service", "escalation") ctx = asyncio.run(_run()) assert ctx.current_week == 1 assert ctx.theta == 0.0 assert ctx.coaching_focus # non-empty (default fallback) def test_context_binder_prompt_never_empty(store: PraxisStore): binder = AssistContextBinder(store) async def _run(): return await binder.bind(HARDCODED_LEARNER_ID, "customer_service", "policy exception") ctx = asyncio.run(_run()) assert ctx.system_prompt.strip() != "" # ── AssistSession ──────────────────────────────────────────────────────────── def test_assist_session_start_creates_assist_row(store: PraxisStore): ctx = _ctx() session = AssistSession(store, HARDCODED_LEARNER_ID, ctx) async def _run(): return await session.start() sid = asyncio.run(_run()) assert sid is not None # Verify the session row has session_type='assist'. row = asyncio.run(store.get_session(sid)) assert row is not None assert row.session_type == "assist" assert row.scenario_id == "assist:damaged-product refund" def test_assist_session_log_turn_writes_verdict(store: PraxisStore): ctx = _ctx() session = AssistSession(store, HARDCODED_LEARNER_ID, ctx) async def _run(): sid = await session.start() await session.log_assist_turn( asr_text="The customer wants a refund", tts_text="What do you think the customer needs?", guardrail_verdict={"allowed": True, "category": "coaching"}, latency_ms=580.0, ) return sid sid = asyncio.run(_run()) turns = asyncio.run(store.get_turns(sid)) assert len(turns) == 1 t = turns[0] assert t.asr_text == "The customer wants a refund" assert t.tts_text == "What do you think the customer needs?" assert t.guardrail_verdict_json is not None verdict = json.loads(t.guardrail_verdict_json) assert verdict["allowed"] is True assert verdict["category"] == "coaching" assert session.turn_count == 1 def test_assist_session_end_returns_outcome(store: PraxisStore): ctx = _ctx() session = AssistSession(store, HARDCODED_LEARNER_ID, ctx) async def _run(): await session.start() await session.log_assist_turn( "Customer is upset", "How could you acknowledge their frustration?", {"allowed": True, "category": "coaching"}, ) return await session.end("completed") outcome = asyncio.run(_run()) assert outcome["session_type"] == "assist" assert outcome["assist_turn_count"] == 1 assert outcome["guardrail_blocks"] == 0 # The session row should have ended_at + outcome set. row = asyncio.run(store.get_session(session.session_id)) assert row is not None assert row.ended_at is not None assert row.outcome == "completed" def test_d063_assist_does_not_update_mastery(store: PraxisStore): """D-063 binding: AssistSession.end() never calls run_mastery_flow.""" ctx = _ctx() session = AssistSession(store, HARDCODED_LEARNER_ID, ctx) async def _run(): await session.start() return await session.end("completed") outcome = asyncio.run(_run()) # No mastery_result field (the practice SessionRecorder sets this; assist does not). assert "mastery_result" not in outcome assert not hasattr(session, "mastery_result") or session.mastery_result is None # No progress row should be created for assist (D-063 — assist is not assessment). # update_progress is never called by AssistSession. def test_assist_session_block_count_increments(store: PraxisStore): ctx = _ctx() session = AssistSession(store, HARDCODED_LEARNER_ID, ctx) async def _run(): await session.start() await session.log_assist_turn( "Customer wants refund", "You should say sorry to the customer.", {"allowed": False, "category": "blocked_direct_script"}, ) return await session.end("completed") outcome = asyncio.run(_run()) assert outcome["guardrail_blocks"] == 1 assert session.guardrail_block_count == 1 # ── Mode-conflict (REQ-IDEATE-03) ──────────────────────────────────────────── def test_mode_conflict_assist_during_active_practice(store: PraxisStore): """Starting an assist shift while a practice session is active → ModeConflictError.""" # Start a practice session (active — no end). sid = asyncio.run( store.start_session_typed(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "practice") ) assert sid async def _run(): await enforce_mutual_exclusivity(store, HARDCODED_LEARNER_ID, "assist") with pytest.raises(ModeConflictError, match="practice session is active"): asyncio.run(_run()) def test_mode_conflict_practice_during_active_assist(store: PraxisStore): """Starting a practice session while an assist shift is active → ModeConflictError.""" sid = asyncio.run( store.start_session_typed(HARDCODED_LEARNER_ID, "assist:refund", "assist") ) assert sid async def _run(): await enforce_mutual_exclusivity(store, HARDCODED_LEARNER_ID, "practice") with pytest.raises(ModeConflictError, match="assist shift is active"): asyncio.run(_run()) def test_mode_conflict_no_conflict_when_no_active_other(store: PraxisStore): """No active session of the other type → no error.""" async def _run(): # No active practice → assist should be allowed. await enforce_mutual_exclusivity(store, HARDCODED_LEARNER_ID, "assist") # No active assist → practice should be allowed. await enforce_mutual_exclusivity(store, HARDCODED_LEARNER_ID, "practice") asyncio.run(_run()) # should not raise def test_mode_conflict_ended_sessions_dont_trigger(store: PraxisStore): """Ended sessions don't trigger the conflict (only active sessions count).""" # Start + end a practice session. sid = asyncio.run( store.start_session_typed(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "practice") ) asyncio.run(store.end_session(sid, branch_path=[], outcome="success")) async def _run(): await enforce_mutual_exclusivity(store, HARDCODED_LEARNER_ID, "assist") asyncio.run(_run()) # should not raise — the practice session is ended # ── Backward compat ────────────────────────────────────────────────────────── def test_backward_compat_practice_session(store: PraxisStore): """Existing practice-session store methods still work (start_session / log_turn / end_session).""" sid = asyncio.run(store.start_session(HARDCODED_LEARNER_ID, "cs_refund_ca_v01")) asyncio.run(store.log_turn(sid, 0, "assistant", tts_text="Hi", latency_ms=None)) asyncio.run(store.end_session(sid, branch_path=[], outcome="success")) row = asyncio.run(store.get_session(sid)) assert row is not None assert row.session_type == "practice" # default turns = asyncio.run(store.get_turns(sid)) assert len(turns) == 1 assert turns[0].guardrail_verdict_json is None # practice turns have no verdict def test_migration_0004_adds_session_type_column(tmp_path: Path): """0004_assist.sql adds session_type + guardrail_verdict_json + the index.""" db = tmp_path / "test_migrate.db" apply_migrations(db) import sqlite3 conn = sqlite3.connect(str(db)) # session_type column on sessions. cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()} assert "session_type" in cols # guardrail_verdict_json column on turns. tcols = {r[1] for r in conn.execute("PRAGMA table_info(turns)").fetchall()} assert "guardrail_verdict_json" in tcols # Index exists. idxs = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='index'").fetchall()} assert "idx_sessions_active_by_type" in idxs conn.close() def test_migration_0004_idempotent(tmp_path: Path): """Re-running migrations is idempotent (no error).""" db = tmp_path / "test_migrate_idem.db" apply_migrations(db) apply_migrations(db) # should not raise