Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
192
tests/test_first_turn_recall.py
Normal file
192
tests/test_first_turn_recall.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""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]}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue