"""Phase 5 RED-state test scaffold. Tasks 2-5 turn these GREEN. Covers TOK-12 / D5-03: first-turn auto-recall hook in core.dispatch that fires exactly once per session and injects a scoped recall into the response. """ from __future__ import annotations from datetime import datetime, timezone from uuid import uuid4 import pytest from iai_mcp import core from iai_mcp.store import MemoryStore from iai_mcp.types import EMBED_DIM, MemoryRecord def _seed_one_record(store: MemoryStore, text: str = "reference content") -> None: now = datetime.now(timezone.utc) rec = MemoryRecord( id=uuid4(), tier="semantic", literal_surface=text, aaak_index="", embedding=[0.1] * EMBED_DIM, community_id=None, centrality=0.5, detail_level=3, pinned=False, stability=0.0, difficulty=0.0, last_reviewed=None, never_decay=False, never_merge=False, provenance=[], created_at=now, updated_at=now, tags=[], language="en", ) store.insert(rec) def test_first_turn_fires_exactly_once(tmp_path, monkeypatch): """D5-03: first dispatch injects first_turn_recall; second dispatch does not.""" # Patch daemon_state to emulate first-turn-pending for session s1 exactly once. pending = {"s1": True} def _load_state(): return {"first_turn_pending": dict(pending)} def _save_state(state): # Update the outer dict state per what the test sets. fresh = state.get("first_turn_pending", {}) pending.clear() pending.update(fresh) monkeypatch.setattr("iai_mcp.daemon_state.load_state", _load_state) monkeypatch.setattr("iai_mcp.daemon_state.save_state", _save_state) store = MemoryStore(path=tmp_path) _seed_one_record(store, "session one reference content") params = { "cue": "reference content", "session_id": "s1", "cue_embedding": [0.1] * EMBED_DIM, } resp1 = core.dispatch(store, "memory_recall", params) resp2 = core.dispatch(store, "memory_recall", params) assert "first_turn_recall" in resp1, f"first dispatch missing hook: {resp1.keys()}" assert "first_turn_recall" not in resp2, ( f"second dispatch should NOT have hook: {resp2.keys()}" ) def test_first_turn_budget_capped_at_400(tmp_path, monkeypatch): """D5-03: first_turn_recall budget_tokens ≤ 400.""" pending = {"s2": True} monkeypatch.setattr( "iai_mcp.daemon_state.load_state", lambda: {"first_turn_pending": dict(pending)}, ) monkeypatch.setattr( "iai_mcp.daemon_state.save_state", lambda s: pending.clear(), ) store = MemoryStore(path=tmp_path) _seed_one_record(store) resp = core.dispatch(store, "memory_recall", { "cue": "X", "session_id": "s2", "cue_embedding": [0.1] * EMBED_DIM, }) ftr = resp.get("first_turn_recall") assert ftr is not None, f"first_turn_recall missing: {resp.keys()}" assert ftr.get("budget_tokens", 0) <= 400, f"budget too high: {ftr}" def test_daemon_unreachable_falls_back_silently(tmp_path, monkeypatch): """D5-03 silent-fail: daemon_state read error must not break dispatch.""" def _boom(): raise RuntimeError("synthetic daemon_state failure") monkeypatch.setattr("iai_mcp.daemon_state.load_state", _boom) store = MemoryStore(path=tmp_path) _seed_one_record(store) # Must not raise. resp = core.dispatch(store, "memory_recall", { "cue": "X", "session_id": "s3", "cue_embedding": [0.1] * EMBED_DIM, }) # Normal response shape preserved; first_turn_recall absent. assert "hits" in resp assert "first_turn_recall" not in resp def test_first_turn_emits_event(tmp_path, monkeypatch): """D5-03: first_turn hook writes kind=first_turn_recall event.""" from iai_mcp.events import query_events pending = {"s4": True} monkeypatch.setattr( "iai_mcp.daemon_state.load_state", lambda: {"first_turn_pending": dict(pending)}, ) monkeypatch.setattr( "iai_mcp.daemon_state.save_state", lambda s: pending.clear(), ) store = MemoryStore(path=tmp_path) _seed_one_record(store) core.dispatch(store, "memory_recall", { "cue": "something", "session_id": "s4", "cue_embedding": [0.1] * EMBED_DIM, }) events = query_events(store, kind="first_turn_recall", limit=10) assert len(events) >= 1, "first_turn_recall event should have been emitted" def test_input_length_clamp_2000(tmp_path, monkeypatch): """V5 security: first-turn cue clamped to 2000 chars before recall.""" pending = {"s5": True} monkeypatch.setattr( "iai_mcp.daemon_state.load_state", lambda: {"first_turn_pending": dict(pending)}, ) monkeypatch.setattr( "iai_mcp.daemon_state.save_state", lambda s: pending.clear(), ) store = MemoryStore(path=tmp_path) _seed_one_record(store) # Huge cue — should be clamped by the hook. huge_cue = "X" * 5000 # Wrap retrieve.recall to capture the cue_text arg. seen_cues: list[str] = [] from iai_mcp import retrieve as _retrieve orig = _retrieve.recall def _spy(*args, **kwargs): cue = kwargs.get("cue_text", "") if "first-turn" not in cue[:20]: # avoid capturing the outer dispatch seen_cues.append(cue) return orig(*args, **kwargs) monkeypatch.setattr("iai_mcp.retrieve.recall", _spy) core.dispatch(store, "memory_recall", { "cue": huge_cue, "session_id": "s5", "cue_embedding": [0.1] * EMBED_DIM, }) # The hook must have called recall with a clamped cue — any cue longer than # 2000 chars indicates the clamp failed. assert any(len(c) <= 2000 for c in seen_cues), ( f"no clamped cue observed; len spread: {[len(c) for c in seen_cues]}" )