Files
praxis/tests/test_assist_session.py
T
Praxis CI 81d43666c7 feat(P01): complete assist core + guardrail phase — v0.1.11 tagged
Phase 1 (Assist Core + Guardrail) complete. 8 slices, 4 waves, 24 tasks.
12 REQs covered (3 ASSIST + 3 NFR + 6 IDEATE). 92 new tests (409 total).
G-049 + G-067 MUSTs resolved. Verify: APPROVE_WITH_NOTES, 5 P1+ flagged.

Live Assist voice loop: shift-bounded sessions, context-binding,
3-layer guardrail (prompt + regex filter + audit log), tap-to-talk
client control, warm WebRTC, reconnect logic, incremental audit write,
PII policy, consent disclosure, mode-conflict enforcement.

---ci---
project: praxis
phase: 1
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-05, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 21:19:20 +00:00

293 lines
11 KiB
Python

"""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