Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
373 lines
13 KiB
Python
373 lines
13 KiB
Python
"""Tests for iai_mcp.dream -- Task 1.
|
|
|
|
Covers 9 behaviours from the plan:
|
|
1. run_rem_cycle calls sleep.run_heavy_consolidation with SleepConfig(llm_enabled=False)
|
|
and has_api_key=False.
|
|
2. run_rem_cycle calls schema.induce_schemas_tier1 with llm_enabled=False (Tier-0).
|
|
3. Non-last cycle does NOT invoke insight.generate_overnight_insight even if
|
|
claude_enabled=True.
|
|
4. Last cycle WITH claude_enabled=True invokes insight.generate_overnight_insight
|
|
and surfaces text into result.
|
|
5. Last cycle with claude_enabled=False does NOT invoke insight.
|
|
6. rem_cycle_started + rem_cycle_completed events emitted.
|
|
7. 15min cap enforced via asyncio.timeout; emits rem_cycle_timeout and returns
|
|
timed_out=True.
|
|
8. Exception inside run_heavy_consolidation is caught; rem_cycle_error event
|
|
emitted; function returns a partial result dict (daemon never dies).
|
|
9. literal preservation -- no daemon-side code path mutates
|
|
MemoryRecord.literal_surface during a cycle (static assertion on dream.py).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers: lightweight store stub + event capture
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _EventLog:
|
|
"""In-memory capture of write_event calls for test assertions."""
|
|
|
|
def __init__(self) -> None:
|
|
self.events: list[tuple[str, dict, str | None]] = []
|
|
|
|
def capture(self, store, kind, data, *, severity=None, **kwargs):
|
|
self.events.append((kind, dict(data), severity))
|
|
return None
|
|
|
|
def kinds(self) -> list[str]:
|
|
return [k for (k, _d, _s) in self.events]
|
|
|
|
|
|
def _fresh_store(tmp_path, monkeypatch):
|
|
"""Minimal MemoryStore tied to a tmp path (pattern reused from tests)."""
|
|
monkeypatch.setenv("IAI_MCP_STORE", str(tmp_path / "iai"))
|
|
monkeypatch.setenv("IAI_MCP_EMBED_DIM", "384")
|
|
from iai_mcp.store import MemoryStore
|
|
return MemoryStore()
|
|
|
|
|
|
def _install_stubs(
|
|
monkeypatch,
|
|
*,
|
|
heavy_return=None,
|
|
heavy_raises=None,
|
|
heavy_sleep_sec: float | None = None,
|
|
candidates_return=None,
|
|
insight_return=None,
|
|
event_log: _EventLog | None = None,
|
|
):
|
|
"""Monkeypatch the three external callables dream.run_rem_cycle invokes.
|
|
|
|
Returns the (heavy_calls, schema_calls, insight_calls) recorders.
|
|
"""
|
|
heavy_calls: list[tuple] = []
|
|
schema_calls: list[tuple] = []
|
|
insight_calls: list[tuple] = []
|
|
|
|
def fake_heavy(store, session_id, cfg, budget, rate, has_api_key):
|
|
heavy_calls.append((session_id, cfg, has_api_key))
|
|
if heavy_sleep_sec is not None:
|
|
time.sleep(heavy_sleep_sec)
|
|
if heavy_raises is not None:
|
|
raise heavy_raises
|
|
return heavy_return if heavy_return is not None else {
|
|
"mode": "heavy", "tier": "tier0",
|
|
"summaries_created": 3, "schemas_induced": 1,
|
|
"decay_result": {"decayed": 0, "pruned": 0},
|
|
"schema_candidates": [],
|
|
}
|
|
|
|
def fake_induce(store, budget, rate, llm_enabled):
|
|
schema_calls.append((llm_enabled,))
|
|
return candidates_return if candidates_return is not None else []
|
|
|
|
async def fake_insight(store, session_id):
|
|
insight_calls.append((session_id,))
|
|
return insight_return if insight_return is not None else {
|
|
"ok": True, "text": "test insight"
|
|
}
|
|
|
|
monkeypatch.setattr("iai_mcp.dream.run_heavy_consolidation", fake_heavy)
|
|
monkeypatch.setattr("iai_mcp.dream.induce_schemas_tier1", fake_induce)
|
|
monkeypatch.setattr("iai_mcp.insight.generate_overnight_insight", fake_insight)
|
|
|
|
if event_log is not None:
|
|
monkeypatch.setattr("iai_mcp.dream.write_event", event_log.capture)
|
|
|
|
# Stub BudgetLedger / RateLimitLedger ctors so a bare store object works.
|
|
class _NoOp:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
monkeypatch.setattr("iai_mcp.dream.BudgetLedger", _NoOp)
|
|
monkeypatch.setattr("iai_mcp.dream.RateLimitLedger", _NoOp)
|
|
|
|
return heavy_calls, schema_calls, insight_calls
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1: heavy consolidation called with llm_enabled=False + has_api_key=False
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rem_cycle_invokes_heavy(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
heavy_calls, _schema_calls, _insight_calls = _install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
)
|
|
|
|
store = object() # dream.py never touches store directly; stubs handle it.
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 1, 4, "sess-X",
|
|
is_last=False, claude_enabled=False,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
assert len(heavy_calls) == 1, "run_heavy_consolidation not called"
|
|
session_id, cfg, has_api_key = heavy_calls[0]
|
|
assert session_id == "sess-X"
|
|
assert has_api_key is False, "daemon must pass has_api_key=False"
|
|
assert getattr(cfg, "llm_enabled", None) is False, "llm_enabled must be False"
|
|
|
|
# The heavy result stub returns summaries_created=3.
|
|
assert result["summaries_created"] == 3
|
|
assert result["timed_out"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2: Tier-0 schema induction (llm_enabled=False)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rem_cycle_invokes_tier0_induction(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
_h, schema_calls, _i = _install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
candidates_return=[{"pattern": "foo"}, {"pattern": "bar"}],
|
|
)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 2, 4, "sess-Y",
|
|
is_last=False, claude_enabled=False,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
assert len(schema_calls) == 1, "induce_schemas_tier1 not called"
|
|
(llm_enabled,) = schema_calls[0]
|
|
assert llm_enabled is False, "Tier-0 path requires llm_enabled=False"
|
|
assert result["schema_candidates"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3: non-last cycle with claude_enabled=True does NOT invoke insight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_non_last_cycle_does_not_invoke_insight(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
_h, _s, insight_calls = _install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 2, 4, "sess-Y",
|
|
is_last=False, claude_enabled=True,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
assert insight_calls == [], "insight called on non-last cycle (D-08 violation)"
|
|
assert result["claude_call_used"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4: last cycle with claude_enabled=True invokes insight and surfaces text
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_last_cycle_triggers_insight(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
_h, _s, insight_calls = _install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
insight_return={"ok": True, "text": "unified insight about patterns"},
|
|
)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 4, 4, "sess-Z",
|
|
is_last=True, claude_enabled=True,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
assert len(insight_calls) == 1, "last cycle must invoke insight"
|
|
assert insight_calls[0] == ("sess-Z",)
|
|
assert result["claude_call_used"] is True
|
|
assert result["main_insight_text"] == "unified insight about patterns"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5: last cycle with claude_enabled=False does NOT invoke insight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_last_cycle_respects_host_disabled(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
_h, _s, insight_calls = _install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 4, 4, "sess-W",
|
|
is_last=True, claude_enabled=False,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
assert insight_calls == [], "claude_enabled=False must gate insight call"
|
|
assert result["claude_call_used"] is False
|
|
assert result["main_insight_text"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 6: rem_cycle_started + rem_cycle_completed events emitted
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cycle_start_and_completed_events(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
_install_stubs(monkeypatch, event_log=event_log)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 1, 4, "sess-E",
|
|
is_last=False, claude_enabled=False,
|
|
)
|
|
|
|
asyncio.run(runner())
|
|
|
|
kinds = event_log.kinds()
|
|
assert "rem_cycle_started" in kinds
|
|
assert "rem_cycle_completed" in kinds
|
|
assert kinds.index("rem_cycle_started") < kinds.index("rem_cycle_completed")
|
|
|
|
# rem_cycle_started payload shape
|
|
started = next(e for e in event_log.events if e[0] == "rem_cycle_started")
|
|
assert started[1] == {"n": 1, "of": 4}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 7: 15min cap enforced; timeout emits rem_cycle_timeout, timed_out=True
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rem_cycle_respects_15min_cap(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
# Shrink the cap so the test is fast; make run_heavy_consolidation slow
|
|
# enough (sleep 0.3s) to trigger the timeout.
|
|
monkeypatch.setattr(dream, "REM_CYCLE_MAX_SEC", 0.1)
|
|
|
|
event_log = _EventLog()
|
|
_install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
heavy_sleep_sec=0.3,
|
|
)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
return await dream.run_rem_cycle(
|
|
store, 3, 4, "sess-T",
|
|
is_last=False, claude_enabled=False,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
assert result["timed_out"] is True
|
|
kinds = event_log.kinds()
|
|
assert "rem_cycle_timeout" in kinds, f"missing rem_cycle_timeout; kinds={kinds}"
|
|
# Timeout still completes with rem_cycle_completed (non-crashing).
|
|
assert "rem_cycle_completed" in kinds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 8: exception inside heavy-consolidation is caught, error event emitted
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rem_cycle_exception_does_not_crash_daemon(tmp_path, monkeypatch):
|
|
from iai_mcp import dream
|
|
|
|
event_log = _EventLog()
|
|
_install_stubs(
|
|
monkeypatch, event_log=event_log,
|
|
heavy_raises=RuntimeError("boom from heavy"),
|
|
)
|
|
|
|
store = object()
|
|
|
|
async def runner():
|
|
# Must NOT raise -- daemon's outer loop relies on this invariant.
|
|
return await dream.run_rem_cycle(
|
|
store, 1, 4, "sess-X",
|
|
is_last=False, claude_enabled=False,
|
|
)
|
|
|
|
result = asyncio.run(runner())
|
|
|
|
kinds = event_log.kinds()
|
|
assert "rem_cycle_error" in kinds, (
|
|
f"rem_cycle_error must be emitted on exception; got {kinds}"
|
|
)
|
|
err_event = next(e for e in event_log.events if e[0] == "rem_cycle_error")
|
|
assert "boom from heavy" in err_event[1]["error"]
|
|
# Partial result still returned (no exception propagates).
|
|
assert "cycle" in result
|
|
assert result["cycle"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 9: literal preservation -- dream.py does not mutate literal_surface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_dream_does_not_mutate_literal_surface():
|
|
"""C5 static guard. dream.py must contain zero writes to
|
|
record.literal_surface (read-access is fine but assignment is forbidden)."""
|
|
dream_src = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "src" / "iai_mcp" / "dream.py"
|
|
).read_text()
|
|
pattern = re.compile(r"\.literal_surface\s*=")
|
|
assert not pattern.search(dream_src), (
|
|
"C5 violation: dream.py assigns to literal_surface"
|
|
)
|