Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""Phase 07.2-04 R3 / A3 integration test — startup + per-tick TTL drain wired into daemon.
|
|
|
|
Strategy: Plan 04 Task 1 threads an explicit `now=datetime.now(timezone.utc)`
|
|
kwarg from BOTH wire-in call sites into `prune_first_turn_pending`. This
|
|
means the helper is fully testable by passing a fixed `NOW` directly —
|
|
no datetime monkeypatching dance.
|
|
|
|
Three checks:
|
|
1. Direct helper invocation with mixed stale/fresh state proves the
|
|
eviction contract (5 stale evict, 5 fresh keep, dropped IDs returned).
|
|
2. Smoke import confirms the names daemon.py imports are reachable.
|
|
3. Source-grep on daemon.py confirms both wire-in sites pass the explicit
|
|
`now=` kwarg (Task 1's structural contract).
|
|
|
|
Project async-test idiom (mandatory): sync `def test_*`. No
|
|
`@pytest.mark.asyncio`. The helper itself is sync, so all tests here
|
|
are plain sync `def test_*` with no `asyncio.run` needed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
NOW = datetime(2026, 4, 27, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _make_mixed_state() -> dict:
|
|
"""Return a state dict with 5 stale + 5 fresh first_turn_pending entries.
|
|
|
|
Stale = 2 h old (well past the 1 h TTL).
|
|
Fresh = 30 s old (well within the TTL).
|
|
Both timestamps are RELATIVE TO `NOW` so the test is deterministic
|
|
regardless of when it runs — `prune_first_turn_pending` only sees the
|
|
explicit `now` we pass in.
|
|
"""
|
|
stale_entries = {
|
|
f"sess-stale-{i}": (NOW - timedelta(hours=2)).isoformat()
|
|
for i in range(5)
|
|
}
|
|
fresh_entries = {
|
|
f"sess-fresh-{i}": (NOW - timedelta(seconds=30)).isoformat()
|
|
for i in range(5)
|
|
}
|
|
return {
|
|
"fsm_state": "WAKE",
|
|
"first_turn_pending": {**stale_entries, **fresh_entries},
|
|
}
|
|
|
|
|
|
def test_prune_helper_drops_5_stale_keeps_5_fresh_with_fixed_now():
|
|
"""A3 acceptance (helper contract): with NOW fixed and 5 stale + 5 fresh
|
|
entries, the helper returns 5 dropped IDs and a state holding only the
|
|
fresh entries. This is exactly the contract Plan 04's wire-in invokes
|
|
at startup and per-tick.
|
|
"""
|
|
from iai_mcp.daemon_state import (
|
|
FIRST_TURN_PENDING_TTL_SEC_DEFAULT,
|
|
prune_first_turn_pending,
|
|
)
|
|
|
|
state = _make_mixed_state()
|
|
# Plan 04 Task 1 calls this with the EXACT signature shown below at
|
|
# both wire-in sites. The test mirrors the wire-in call shape so any
|
|
# future signature drift breaks BOTH sides at once.
|
|
new_state, dropped = prune_first_turn_pending(state, now=NOW)
|
|
|
|
# 5 stale IDs evict.
|
|
assert sorted(dropped) == sorted(f"sess-stale-{i}" for i in range(5)), (
|
|
f"Expected exactly 5 stale session_ids dropped; got {dropped}"
|
|
)
|
|
# 5 fresh IDs survive.
|
|
kept = new_state["first_turn_pending"]
|
|
assert len(kept) == 5
|
|
for k in kept:
|
|
assert k.startswith("sess-fresh-"), f"unexpected key kept: {k}"
|
|
# Helper exposes the TTL constant Plan 04 wire-in uses for the event
|
|
# payload — sanity-check it has the documented value (1 h).
|
|
assert FIRST_TURN_PENDING_TTL_SEC_DEFAULT == 3600.0
|
|
|
|
|
|
def test_prune_helper_no_drop_when_only_fresh_entries():
|
|
"""Control: NOW fixed and only fresh entries → 0 dropped, 5 kept,
|
|
state.first_turn_pending unchanged in shape."""
|
|
from iai_mcp.daemon_state import prune_first_turn_pending
|
|
|
|
state = {
|
|
"fsm_state": "WAKE",
|
|
"first_turn_pending": {
|
|
f"sess-fresh-{i}": (NOW - timedelta(seconds=30)).isoformat()
|
|
for i in range(5)
|
|
},
|
|
}
|
|
new_state, dropped = prune_first_turn_pending(state, now=NOW)
|
|
|
|
assert dropped == [], f"Expected zero drops on all-fresh state; got {dropped}"
|
|
assert len(new_state["first_turn_pending"]) == 5
|
|
|
|
|
|
def test_first_turn_pending_drain_helper_imported_in_daemon_main():
|
|
"""Smoke: daemon.main() can import the helper without error.
|
|
|
|
If Plan 04's import block is wrong (typo, wrong module, etc.), this
|
|
fails fast.
|
|
"""
|
|
from iai_mcp.daemon_state import (
|
|
FIRST_TURN_PENDING_TTL_SEC_DEFAULT,
|
|
prune_first_turn_pending,
|
|
)
|
|
assert FIRST_TURN_PENDING_TTL_SEC_DEFAULT == 3600.0
|
|
assert callable(prune_first_turn_pending)
|
|
|
|
|
|
def test_daemon_wire_in_passes_explicit_now_kwarg_at_both_sites():
|
|
"""Structural check: read daemon.py source and confirm BOTH wire-in
|
|
sites pass `now=datetime.now(timezone.utc)` explicitly.
|
|
|
|
This is the wire-up half of A3 — without it, Task 2 only proves the
|
|
helper works, not that Task 1 wired it in correctly. Plan 04 Task 1's
|
|
contract is that BOTH call sites thread `now=` explicitly so the
|
|
helper is testable without datetime mocking.
|
|
"""
|
|
daemon_src = Path(__file__).resolve().parent.parent / "src" / "iai_mcp" / "daemon.py"
|
|
text = daemon_src.read_text()
|
|
|
|
# Match `prune_first_turn_pending(\n state, now=datetime.now(timezone.utc)`
|
|
# tolerantly across whitespace + line breaks.
|
|
pat = re.compile(
|
|
r"prune_first_turn_pending\s*\(\s*state\s*,\s*now\s*=\s*datetime\.now\(\s*timezone\.utc\s*\)",
|
|
re.MULTILINE,
|
|
)
|
|
matches = pat.findall(text)
|
|
assert len(matches) >= 2, (
|
|
f"Expected >= 2 wire-in sites with explicit `now=datetime.now(timezone.utc)` "
|
|
f"kwarg in daemon.py; found {len(matches)}. Plan 04 Task 1 contract:"
|
|
f" both startup-prune (in main()) and tick-prune (in _tick_body Step 0.5)"
|
|
f" must thread `now=` explicitly."
|
|
)
|
|
|
|
# Both event-emit phases ("startup" and "tick") must be present.
|
|
assert '"phase": "startup"' in text or "'phase': 'startup'" in text, (
|
|
"Startup-side event emit missing `phase: startup` in payload."
|
|
)
|
|
assert '"phase": "tick"' in text or "'phase': 'tick'" in text, (
|
|
"Tick-side event emit missing `phase: tick` in payload."
|
|
)
|