193 lines
5.9 KiB
Python
193 lines
5.9 KiB
Python
|
|
"""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]}"
|
||
|
|
)
|