"""Tests for interruptibility (TASK-03-05) + branch classifier (TASK-03-06).""" from __future__ import annotations import asyncio from types import SimpleNamespace import pytest from server.interruptibility import pipeline_allows_interruptions from server.scenarios.classifier import ( classify_branch, classify_branch_sync_heuristic, _parse_branch, ) from server.scenarios.loader import load # ─── TASK-03-05: interruptibility ──────────────────────────────────────────── def test_pipeline_allows_interruptions_flag(): """The pipeline task must be configured with allow_interruptions=True (D-008).""" task = SimpleNamespace(params=SimpleNamespace(allow_interruptions=True)) assert pipeline_allows_interruptions(task) is True def test_pipeline_allows_interruptions_false_flag(): task = SimpleNamespace(params=SimpleNamespace(allow_interruptions=False)) assert pipeline_allows_interruptions(task) is False def test_pipeline_allows_interruptions_missing_params(): task = SimpleNamespace() assert pipeline_allows_interruptions(task) is False # ─── TASK-03-06: branch classifier ─────────────────────────────────────────── def test_heuristic_classifier_accept(): """A scripted empathetic transcript → accept_resolution.""" scenario = load("customer_service_refund_ca_v01") turns = [ "I'm really sorry you're frustrated. I can offer a full refund right now.", "Let me confirm the next steps for you.", ] branch_id = classify_branch_sync_heuristic(scenario, turns) assert branch_id == "accept_resolution" def test_heuristic_classifier_escalate(): """A scripted defensive transcript → escalate.""" scenario = load("customer_service_refund_ca_v01") turns = [ "Well, our policy says we don't do refunds after 30 days.", "That's just how it works, I can't help you.", ] branch_id = classify_branch_sync_heuristic(scenario, turns) assert branch_id == "escalate" def test_parse_branch_valid_json(): scenario = load("customer_service_refund_ca_v01") text = '{"branch_id": "accept_resolution", "reason": "empathy shown"}' bid, reason = _parse_branch(text, scenario) assert bid == "accept_resolution" assert "empathy" in reason def test_parse_branch_code_fenced_json(): scenario = load("customer_service_refund_ca_v01") text = '```json\n{"branch_id": "escalate", "reason": "defensive"}\n```' bid, reason = _parse_branch(text, scenario) assert bid == "escalate" def test_parse_branch_unknown_id_falls_back(): scenario = load("customer_service_refund_ca_v01") text = '{"branch_id": "not_real", "reason": "x"}' bid, reason = _parse_branch(text, scenario) # Falls back to the first branch. assert bid in scenario.branch_ids() assert "fallback" in reason def test_parse_branch_malformed_json_scans_for_id(): scenario = load("customer_service_refund_ca_v01") text = "The learner matches the escalate branch." bid, reason = _parse_branch(text, scenario) assert bid == "escalate" assert "fallback" in reason class _FakeLLM: """A fake LLMProvider for the classifier test (no real API call).""" debrief_model = "deepseek-v4-flash:cloud" roleplay_model = "gemma4:cloud" async def chat_full(self, messages, *, model=None, no_think=False): return ( '{"branch_id": "accept_resolution", "reason": "empathy + concrete_resolution"}', {"output_tokens": 10}, ) def test_classify_branch_with_fake_llm(): """The async classifier returns the LLM's branch verdict (R7, offline).""" scenario = load("customer_service_refund_ca_v01") turns = ["I'm sorry, I can offer a refund."] bid, reason = asyncio.run(classify_branch(_FakeLLM(), scenario, turns)) assert bid == "accept_resolution" assert "empathy" in reason def test_classifier_runs_offline_from_voice_loop(): """D-P1-05: the classifier is a one-shot end-of-session call, not per-turn.""" # This is a structural assertion: classify_branch takes the full turns list, # not a single turn — confirming it runs at session end, not on the # latency-critical voice path. scenario = load("customer_service_refund_ca_v01") turns = ["turn 1", "turn 2", "turn 3"] bid = classify_branch_sync_heuristic(scenario, turns) assert bid in scenario.branch_ids()