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
426
tests/test_core_bedtime_inject.py
Normal file
426
tests/test_core_bedtime_inject.py
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"""Tests for core.py additions -- DAEMON-06 / DAEMON-09.
|
||||
|
||||
Covers 8 behaviours:
|
||||
1. consent=False short-circuits: socket is NEVER opened (C2 guard)
|
||||
2. consent=True opens socket, sends NDJSON, returns daemon response
|
||||
3. Missing / wrong-typed consent raises ValueError (ASVS V5 schema)
|
||||
4. force_wake opens socket, sends NDJSON with 900s timeout
|
||||
5. force_wake handles daemon-unreachable gracefully
|
||||
6. memory_recall dispatch injects sleep_suggestion when dual-gate passes
|
||||
7. memory_recall dispatch does NOT include sleep_suggestion key when gate fails
|
||||
8. memory_recall does NOT break if detect_wind_down raises (silent fail)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iai_mcp import core
|
||||
|
||||
|
||||
# ----------------------------------------------------------- threaded helper
|
||||
|
||||
|
||||
class _ThreadedFakeDaemon:
|
||||
"""Fake daemon that survives across multiple asyncio.run() calls.
|
||||
|
||||
`core.dispatch` uses its own asyncio.run per JSON-RPC method, which tears
|
||||
down the event loop each call. A server started via asyncio.run() inside
|
||||
the test body dies when that call returns, so the next asyncio.run can
|
||||
connect to the socket file but no task is accepting -> timeout. Running
|
||||
the server on a private background loop in a daemon thread keeps the
|
||||
accept loop alive for the full test lifetime.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, captured: list, reply: dict) -> None:
|
||||
self.path = path
|
||||
self.captured = captured
|
||||
self.reply = reply
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server: asyncio.AbstractServer | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
def _run() -> None:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
line = await reader.readline()
|
||||
if line:
|
||||
self.captured.append(json.loads(line.decode("utf-8")))
|
||||
writer.write((json.dumps(self.reply) + "\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _serve() -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._server = await asyncio.start_unix_server(_handle, path=str(self.path))
|
||||
self._ready.set()
|
||||
async with self._server:
|
||||
await self._server.serve_forever()
|
||||
|
||||
try:
|
||||
self._loop.run_until_complete(_serve())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._loop.close()
|
||||
|
||||
self._thread = threading.Thread(target=_run, daemon=True)
|
||||
self._thread.start()
|
||||
assert self._ready.wait(timeout=5.0), "fake daemon failed to start within 5s"
|
||||
|
||||
def stop(self) -> None:
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
return
|
||||
|
||||
async def _shutdown() -> None:
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
|
||||
fut = asyncio.run_coroutine_threadsafe(_shutdown(), loop)
|
||||
try:
|
||||
fut.result(timeout=5.0)
|
||||
except Exception:
|
||||
pass
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fixtures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_socket(tmp_path: Path) -> Path:
|
||||
"""Provide a short unique unix-socket path.
|
||||
|
||||
Unix domain sockets have a ~104-byte path limit on macOS; tmp_path can be
|
||||
too long when driven by `pytest-xdist` worker names. Fall back to /tmp
|
||||
when tmp_path would overflow.
|
||||
"""
|
||||
candidate = tmp_path / "d.sock"
|
||||
if len(str(candidate)) > 100:
|
||||
candidate = Path(tempfile.mkdtemp(prefix="iai-sock-")) / "d.sock"
|
||||
return candidate
|
||||
|
||||
|
||||
async def _run_fake_server(
|
||||
sock: Path,
|
||||
captured: list,
|
||||
reply: dict,
|
||||
*,
|
||||
delay_before_reply: float = 0.0,
|
||||
) -> asyncio.AbstractServer:
|
||||
"""Spin up a single-shot fake daemon over unix socket.
|
||||
|
||||
Reads one NDJSON line, records it in `captured`, sleeps `delay_before_reply`
|
||||
seconds, writes `reply` as NDJSON back, closes. Returns the server object
|
||||
so the caller can close it afterwards.
|
||||
"""
|
||||
|
||||
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
line = await reader.readline()
|
||||
if line:
|
||||
captured.append(json.loads(line.decode("utf-8")))
|
||||
if delay_before_reply > 0:
|
||||
await asyncio.sleep(delay_before_reply)
|
||||
writer.write((json.dumps(reply) + "\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sock.parent.mkdir(parents=True, exist_ok=True)
|
||||
return await asyncio.start_unix_server(_handle, path=str(sock))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- consent gate
|
||||
|
||||
|
||||
def test_consent_false_short_circuits_no_socket_touch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""C2 invariant: consent=False must NEVER open the daemon socket."""
|
||||
|
||||
async def _explode(*args, **kwargs):
|
||||
raise AssertionError(
|
||||
"C2 violation: asyncio.open_unix_connection reached with consent=False"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_unix_connection", _explode)
|
||||
|
||||
result = asyncio.run(
|
||||
core.handle_initiate_sleep_mode({"consent": False, "reason": "not ready"})
|
||||
)
|
||||
assert result == {"ok": False, "reason": "consent_declined"}
|
||||
|
||||
|
||||
def test_consent_missing_raises_value_error() -> None:
|
||||
with pytest.raises(ValueError, match="consent"):
|
||||
asyncio.run(core.handle_initiate_sleep_mode({"reason": "missing"}))
|
||||
|
||||
|
||||
def test_consent_wrong_type_raises_value_error() -> None:
|
||||
# Strings / ints / None must all be rejected; only literal bool passes.
|
||||
for bad in ["true", 1, 0, None, [True]]:
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(
|
||||
core.handle_initiate_sleep_mode({"consent": bad, "reason": "x"})
|
||||
)
|
||||
|
||||
|
||||
def test_reason_missing_raises_value_error() -> None:
|
||||
with pytest.raises(ValueError, match="reason"):
|
||||
asyncio.run(core.handle_initiate_sleep_mode({"consent": True}))
|
||||
|
||||
|
||||
def test_reason_wrong_type_raises_value_error() -> None:
|
||||
with pytest.raises(ValueError, match="reason"):
|
||||
asyncio.run(
|
||||
core.handle_initiate_sleep_mode({"consent": True, "reason": 42})
|
||||
)
|
||||
|
||||
|
||||
def test_consent_true_opens_socket_and_returns_reply(
|
||||
tmp_socket: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""consent=True path: real socket round-trip against a fake daemon."""
|
||||
captured: list[dict] = []
|
||||
|
||||
async def _runner() -> dict:
|
||||
server = await _run_fake_server(
|
||||
tmp_socket, captured, {"ok": True, "state": "TRANSITIONING"},
|
||||
)
|
||||
try:
|
||||
async with server:
|
||||
# Monkeypatch core's SOCKET_PATH so _send_to_daemon uses ours.
|
||||
monkeypatch.setattr(core, "SOCKET_PATH", tmp_socket)
|
||||
return await core.handle_initiate_sleep_mode(
|
||||
{"consent": True, "reason": "good night"},
|
||||
)
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
result = asyncio.run(_runner())
|
||||
assert result == {"ok": True, "state": "TRANSITIONING"}
|
||||
assert len(captured) == 1
|
||||
sent = captured[0]
|
||||
assert sent["type"] == "user_initiated_sleep"
|
||||
assert sent["reason"] == "good night"
|
||||
assert "ts" in sent # ISO timestamp attached
|
||||
|
||||
|
||||
def test_consent_true_daemon_unreachable_returns_graceful_error(
|
||||
tmp_socket: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Daemon down (socket file absent) must return daemon_not_running."""
|
||||
# Do NOT start a server.
|
||||
assert not tmp_socket.exists()
|
||||
monkeypatch.setattr(core, "SOCKET_PATH", tmp_socket)
|
||||
result = asyncio.run(
|
||||
core.handle_initiate_sleep_mode(
|
||||
{"consent": True, "reason": "night"},
|
||||
)
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert result["reason"] == "daemon_not_running"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- force_wake
|
||||
|
||||
|
||||
def test_force_wake_sends_correct_message(
|
||||
tmp_socket: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: list[dict] = []
|
||||
|
||||
async def _runner() -> dict:
|
||||
server = await _run_fake_server(
|
||||
tmp_socket, captured, {"ok": True, "state": "WAKE"},
|
||||
)
|
||||
try:
|
||||
async with server:
|
||||
monkeypatch.setattr(core, "SOCKET_PATH", tmp_socket)
|
||||
return await core.handle_force_wake({})
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
result = asyncio.run(_runner())
|
||||
assert result == {"ok": True, "state": "WAKE"}
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["type"] == "force_wake"
|
||||
assert "ts" in captured[0]
|
||||
|
||||
|
||||
def test_force_wake_daemon_unreachable_graceful(
|
||||
tmp_socket: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
assert not tmp_socket.exists()
|
||||
monkeypatch.setattr(core, "SOCKET_PATH", tmp_socket)
|
||||
result = asyncio.run(core.handle_force_wake({}))
|
||||
assert result["ok"] is False
|
||||
assert result["reason"] == "daemon_not_running"
|
||||
|
||||
|
||||
def test_force_wake_timeout_is_fifteen_minutes() -> None:
|
||||
"""cooperative cap is 15 minutes = 900 seconds."""
|
||||
assert core.FORCE_WAKE_TIMEOUT_SEC == 900
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- inject helper
|
||||
|
||||
|
||||
def _window_covering_now() -> tuple[int, int]:
|
||||
"""Return a quiet_window (start_bucket, duration) that contains `now`.
|
||||
|
||||
Uses the current local time so the dual-gate is satisfied deterministically
|
||||
regardless of the test-host clock.
|
||||
"""
|
||||
from iai_mcp.tz import load_user_tz
|
||||
|
||||
tz = load_user_tz()
|
||||
now_local = datetime.now(timezone.utc).astimezone(tz)
|
||||
cur_bucket = (now_local.hour * 60 + now_local.minute) // 30
|
||||
# Make the window start 2 buckets (1h) before now and last 4h (8 buckets).
|
||||
start = (cur_bucket - 2) % 48
|
||||
return (start, 8)
|
||||
|
||||
|
||||
def test_inject_sleep_suggestion_dual_gate_pass(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When phrase + window both pass, response gains sleep_suggestion."""
|
||||
fake_state = {"quiet_window": _window_covering_now()}
|
||||
|
||||
def _load() -> dict:
|
||||
return dict(fake_state)
|
||||
|
||||
monkeypatch.setattr("iai_mcp.daemon_state.load_state", _load)
|
||||
|
||||
response: dict = {"hits": [], "anti_hits": []}
|
||||
core._inject_sleep_suggestion(response, cue="good night", language="en")
|
||||
assert "sleep_suggestion" in response, (
|
||||
f"expected injection on dual-gate pass, got {response!r}"
|
||||
)
|
||||
assert response["sleep_suggestion"]["message_hint"] == "user_wind_down_detected"
|
||||
|
||||
|
||||
def test_inject_sleep_suggestion_no_phrase(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No phrase match -> response has no sleep_suggestion key."""
|
||||
fake_state = {"quiet_window": _window_covering_now()}
|
||||
monkeypatch.setattr(
|
||||
"iai_mcp.daemon_state.load_state",
|
||||
lambda: dict(fake_state),
|
||||
)
|
||||
|
||||
response: dict = {"hits": [], "anti_hits": []}
|
||||
core._inject_sleep_suggestion(
|
||||
response, cue="how do I configure pytest", language="en",
|
||||
)
|
||||
assert "sleep_suggestion" not in response
|
||||
|
||||
|
||||
def test_inject_sleep_suggestion_no_window(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phrase match but no quiet_window -> response has no sleep_suggestion."""
|
||||
monkeypatch.setattr("iai_mcp.daemon_state.load_state", lambda: {})
|
||||
|
||||
response: dict = {"hits": [], "anti_hits": []}
|
||||
core._inject_sleep_suggestion(response, cue="good night", language="en")
|
||||
assert "sleep_suggestion" not in response
|
||||
|
||||
|
||||
def test_inject_sleep_suggestion_detector_raises_is_silent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If detect_wind_down raises, response goes out untouched."""
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("synthetic bedtime failure")
|
||||
|
||||
monkeypatch.setattr("iai_mcp.bedtime.detect_wind_down", _boom)
|
||||
|
||||
response: dict = {"hits": [], "anti_hits": [], "budget_used": 0}
|
||||
# Must not propagate the RuntimeError.
|
||||
core._inject_sleep_suggestion(response, cue="good night", language="en")
|
||||
assert "sleep_suggestion" not in response
|
||||
# Pre-existing keys untouched.
|
||||
assert response == {"hits": [], "anti_hits": [], "budget_used": 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dispatch wiring
|
||||
|
||||
|
||||
def test_dispatch_routes_initiate_sleep_mode(
|
||||
tmp_socket: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The synchronous `core.dispatch` entrypoint must route the new
|
||||
methods through asyncio.run -- verified by having a fake daemon
|
||||
respond to a real socket round-trip.
|
||||
|
||||
The fake daemon runs in a background thread/loop so it survives
|
||||
dispatch()'s own asyncio.run (which tears down the calling loop).
|
||||
"""
|
||||
captured: list[dict] = []
|
||||
daemon = _ThreadedFakeDaemon(tmp_socket, captured, {"ok": True})
|
||||
daemon.start()
|
||||
try:
|
||||
monkeypatch.setattr(core, "SOCKET_PATH", tmp_socket)
|
||||
# store arg is unused by our handlers -- pass None sentinel.
|
||||
result = core.dispatch(
|
||||
None,
|
||||
"initiate_sleep_mode",
|
||||
{"consent": True, "reason": "test"},
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
assert captured[0]["type"] == "user_initiated_sleep"
|
||||
finally:
|
||||
daemon.stop()
|
||||
|
||||
|
||||
def test_dispatch_routes_force_wake(
|
||||
tmp_socket: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: list[dict] = []
|
||||
daemon = _ThreadedFakeDaemon(tmp_socket, captured, {"ok": True, "state": "WAKE"})
|
||||
daemon.start()
|
||||
try:
|
||||
monkeypatch.setattr(core, "SOCKET_PATH", tmp_socket)
|
||||
result = core.dispatch(None, "force_wake", {})
|
||||
assert result == {"ok": True, "state": "WAKE"}
|
||||
assert captured[0]["type"] == "force_wake"
|
||||
finally:
|
||||
daemon.stop()
|
||||
Loading…
Add table
Add a link
Reference in a new issue