Replaces the single 8k always-injected global prompt with cadence-tiered injection: - Tier 0 (.aipass/tier0_kernel.md, ~2k) injects EVERY turn — identity grounding, the drone --help reflex, disaster-preventer rules. Folds DPLAN-0213 C1 (faithful-reporting) + C2 (no-gold-plating sub-agent brief). - Tier 1 (.aipass/tier1_navmap.md, ~7.7k) injects every 5th turn + session-start + post-compaction — full agent roster, framework, conventions, plus a new Terminology section migrated from the S211 backup. - Old global_prompt loader retired (disabled in hooks.json, removed from cadence wiring); aipass_global_prompt.md kept as a reference snapshot. Engine (built by @hooks): per-loader period in cadence.py should_fire() (back-compatible: unset period falls back to global); new tier0_kernel + navmap handlers; bypass.json extended to cover the two new dynamically-dispatched handlers. 614/614 tests pass, seedgo @hooks 100%. Live-verified: tier0 fires every turn, navmap on turn 0/5/10, turn counter advances once per turn (not per loader), no double-injection. Machine-local wiring (cadence_config.json, ~/.claude/settings.json bridge) updated on this host; fresh-clone seeding of those is a tracked follow-up. PROMPT_STYLE.md reference updated to point at the tier files as canonical examples.
670 lines
24 KiB
Python
670 lines
24 KiB
Python
# =================== AIPass ====================
|
|
# Name: test_cadence.py
|
|
# Version: 1.0.0
|
|
# Description: Tests for cadence module (DPLAN-0200)
|
|
# Branch: hooks
|
|
# Created: 2026-06-08
|
|
# Modified: 2026-06-08
|
|
# =============================================
|
|
|
|
"""Tests for apps/modules/cadence.py.
|
|
|
|
Cadence runs MULTI-PROCESS in production: each UserPromptSubmit hook is a
|
|
separate OS process. Tests model that by resetting the module _turn cache
|
|
between calls (= new process) and aging the state file past the mtime
|
|
debounce window (= a real prior turn, not a sibling in the same turn).
|
|
"""
|
|
|
|
import json
|
|
import importlib
|
|
import os
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
MODULE = "aipass.hooks.apps.modules.cadence"
|
|
|
|
|
|
def _reset_module_globals():
|
|
"""Reset module-level caches between tests (also = simulate a new process)."""
|
|
import aipass.hooks.apps.modules.cadence as mod
|
|
|
|
mod._turn = None
|
|
mod._config = None
|
|
|
|
|
|
def _write_state(tmp_path, turn, token=-1, session="test-session", aged=True):
|
|
"""Write a cadence state file. aged=True backdates mtime past the debounce
|
|
window so it reads as a PREVIOUS turn; aged=False = sibling in same turn."""
|
|
state_file = tmp_path / f"aipass-cadence-{session}.json"
|
|
state_file.write_text(json.dumps({"turn": turn, "token": token}))
|
|
if aged:
|
|
old = time.time() - 10
|
|
os.utime(state_file, (old, old))
|
|
return state_file
|
|
|
|
|
|
class TestShouldFire:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_turn_0_always_fires(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is True
|
|
assert json.loads(state_file.read_text())["turn"] == 0
|
|
|
|
def test_turn_0_fires_all_loaders(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is True
|
|
assert should_fire("branch") is True
|
|
|
|
def test_non_fire_turn_returns_false(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
_write_state(tmp_path, turn=0)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is False
|
|
|
|
def test_fire_turn_returns_true(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
_write_state(tmp_path, turn=3)
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 4}}}))
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("global") is True
|
|
|
|
def test_cadence_disabled_always_fires(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
state_file.write_text(json.dumps({"turn": 1}))
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(json.dumps({"enabled": False}))
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("global") is True
|
|
|
|
def test_no_session_id_fires(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {}, clear=False),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
env = dict(__import__("os").environ)
|
|
env.pop("CLAUDE_CODE_SESSION_ID", None)
|
|
with patch.dict("os.environ", env, clear=True):
|
|
assert should_fire("global") is True
|
|
|
|
def test_counter_increments_once_across_sibling_processes(self, tmp_path):
|
|
"""Each loader is a SEPARATE OS process. The counter must advance
|
|
exactly once per real turn no matter how many siblings call it."""
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
state_file = _write_state(tmp_path, turn=3)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
should_fire("global")
|
|
for _ in range(4): # 4 more siblings, each a fresh process
|
|
_reset_module_globals()
|
|
should_fire("branch")
|
|
data = json.loads(state_file.read_text())
|
|
assert data["turn"] == 4
|
|
|
|
def test_sibling_processes_agree_on_turn_no_leapfrog(self, tmp_path):
|
|
"""The S210 live bug: global saw turn N, branch saw N+1 — they
|
|
leapfrogged and never both fired. Both siblings must see the SAME
|
|
turn and make the SAME decision."""
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
_write_state(tmp_path, turn=4) # next real turn = 5 = fire (5 % 5 == 0)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is True
|
|
_reset_module_globals() # branch runs as a separate process
|
|
assert should_fire("branch") is True
|
|
|
|
def test_token_backstop_blocks_double_increment(self, tmp_path):
|
|
"""Even past the debounce window, an unchanged transcript token means
|
|
no new turn happened — the counter must not advance."""
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
transcript = tmp_path / "transcript.jsonl"
|
|
transcript.write_text("x" * 100)
|
|
state_file = _write_state(tmp_path, turn=3, token=100)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
should_fire("global", {"transcript_path": str(transcript)})
|
|
assert json.loads(state_file.read_text())["turn"] == 3
|
|
|
|
def test_reset_special_case_survives_debounce(self, tmp_path):
|
|
"""turn < 0 (post-compact reset) must ALWAYS increment to 0, even when
|
|
the reset just happened (fresh mtime would normally debounce)."""
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
state_file = _write_state(tmp_path, turn=-1, aged=False)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is True
|
|
assert json.loads(state_file.read_text())["turn"] == 0
|
|
|
|
def test_period_zero_always_fires(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
state_file.write_text(json.dumps({"turn": 2}))
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(json.dumps({"enabled": True, "period": 0}))
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("global") is True
|
|
|
|
def test_stagger_offsets(self, tmp_path):
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 0}, "branch": {"offset": 2}}})
|
|
)
|
|
|
|
_write_state(tmp_path, turn=4)
|
|
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("global") is True
|
|
assert should_fire("branch") is False
|
|
|
|
def test_unknown_loader_uses_offset_zero(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
_write_state(tmp_path, turn=4)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("unknown_loader") is True
|
|
|
|
|
|
class TestResetCounter:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_reset_writes_minus_one(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import reset_counter
|
|
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
state_file.write_text(json.dumps({"turn": 7}))
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
):
|
|
reset_counter()
|
|
|
|
data = json.loads(state_file.read_text())
|
|
assert data["turn"] == -1
|
|
|
|
def test_reset_then_next_turn_is_zero(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import reset_counter, should_fire
|
|
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
state_file.write_text(json.dumps({"turn": 7}))
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
reset_counter()
|
|
|
|
_reset_module_globals()
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is True
|
|
data = json.loads(state_file.read_text())
|
|
assert data["turn"] == 0
|
|
|
|
def test_reset_no_session_id_is_noop(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import reset_counter
|
|
|
|
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
|
|
env = dict(__import__("os").environ)
|
|
env.pop("CLAUDE_CODE_SESSION_ID", None)
|
|
with patch.dict("os.environ", env, clear=True):
|
|
reset_counter()
|
|
|
|
assert not list(tmp_path.glob("aipass-cadence-*"))
|
|
|
|
def test_reset_creates_file_if_missing(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import reset_counter
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
):
|
|
reset_counter()
|
|
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
assert state_file.exists()
|
|
assert json.loads(state_file.read_text())["turn"] == -1
|
|
|
|
|
|
class TestConfig:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_defaults_used_when_no_config_file(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import _load_config
|
|
|
|
with patch(f"{MODULE}._CONFIG_PATH", tmp_path / "nonexistent.json"):
|
|
config = _load_config()
|
|
|
|
assert config["enabled"] is True
|
|
assert config["period"] == 5
|
|
assert config["loaders"]["global"]["offset"] == 0
|
|
assert config["loaders"]["branch"]["offset"] == 0
|
|
|
|
def test_config_deep_merges_over_defaults(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import _load_config
|
|
|
|
config_file = tmp_path / "cadence.json"
|
|
config_file.write_text(json.dumps({"period": 10, "loaders": {"global": {"offset": 3}}}))
|
|
|
|
with patch(f"{MODULE}._CONFIG_PATH", config_file):
|
|
config = _load_config()
|
|
|
|
assert config["period"] == 10
|
|
assert config["loaders"]["global"]["offset"] == 3
|
|
assert config["loaders"]["branch"]["offset"] == 0
|
|
assert config["enabled"] is True
|
|
|
|
def test_bad_config_falls_back_to_defaults(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import _load_config
|
|
|
|
config_file = tmp_path / "cadence.json"
|
|
config_file.write_text("not valid json{{{")
|
|
|
|
with patch(f"{MODULE}._CONFIG_PATH", config_file):
|
|
config = _load_config()
|
|
|
|
assert config["period"] == 5
|
|
|
|
|
|
class TestDeepMerge:
|
|
def test_nested_merge(self):
|
|
from aipass.hooks.apps.modules.cadence import _deep_merge
|
|
|
|
base = {"a": 1, "b": {"c": 2, "d": 3}}
|
|
updates = {"b": {"c": 99}, "e": 4}
|
|
result = _deep_merge(base, updates)
|
|
|
|
assert result["a"] == 1
|
|
assert result["b"]["c"] == 99
|
|
assert result["b"]["d"] == 3
|
|
assert result["e"] == 4
|
|
|
|
def test_overwrites_non_dict(self):
|
|
from aipass.hooks.apps.modules.cadence import _deep_merge
|
|
|
|
base = {"a": [1, 2]}
|
|
result = _deep_merge(base, {"a": [3]})
|
|
assert result["a"] == [3]
|
|
|
|
|
|
class TestPerSessionIsolation:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_different_sessions_use_different_files(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
state_a = _write_state(tmp_path, turn=4, session="session-a")
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "session-a"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
should_fire("global")
|
|
data_a = json.loads(state_a.read_text())
|
|
assert data_a["turn"] == 5
|
|
|
|
_reset_module_globals()
|
|
|
|
state_b = tmp_path / "aipass-cadence-session-b.json"
|
|
assert not state_b.exists()
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "session-b"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
assert should_fire("global") is True
|
|
data_b = json.loads(state_b.read_text())
|
|
assert data_b["turn"] == 0
|
|
|
|
|
|
class TestModuleInterface:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_handle_command_cadence_returns_true(self):
|
|
from aipass.hooks.apps.modules.cadence import handle_command
|
|
|
|
with patch(f"{MODULE}.print_introspection"):
|
|
assert handle_command("cadence", []) is True
|
|
|
|
def test_handle_command_unknown_returns_false(self):
|
|
from aipass.hooks.apps.modules.cadence import handle_command
|
|
|
|
assert handle_command("other", []) is False
|
|
|
|
def test_print_introspection_runs(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import print_introspection
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
print_introspection()
|
|
|
|
|
|
class TestCompactIntegration:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_compact_handler_resets_cadence(self, tmp_path):
|
|
state_file = tmp_path / "aipass-cadence-test-session.json"
|
|
state_file.write_text(json.dumps({"turn": 7}))
|
|
|
|
import aipass.hooks.apps.modules.cadence as cadence_mod
|
|
|
|
with (
|
|
patch.object(cadence_mod, "_GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
):
|
|
mock_cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
|
mock_cadence.reset_counter()
|
|
|
|
data = json.loads(state_file.read_text())
|
|
assert data["turn"] == -1
|
|
|
|
|
|
class TestLoaderCadenceGuard:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_global_loader_skips_on_non_fire_turn(self, tmp_path):
|
|
"""Skip = empty stdout AND no sound key — a skipped loader is SILENT."""
|
|
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
|
|
|
_write_state(tmp_path, turn=0) # next turn = 1 = skip
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
result = handle({})
|
|
|
|
assert result["stdout"] == ""
|
|
assert result["exit_code"] == 0
|
|
assert "sound" not in result
|
|
|
|
def test_branch_loader_skips_on_non_fire_turn(self, tmp_path):
|
|
"""Skip = empty stdout AND no sound key — a skipped loader is SILENT."""
|
|
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
|
|
|
_write_state(tmp_path, turn=0) # next turn = 1 = skip
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
|
):
|
|
result = handle({})
|
|
|
|
assert result["stdout"] == ""
|
|
assert result["exit_code"] == 0
|
|
assert "sound" not in result
|
|
|
|
|
|
class TestPerLoaderPeriod:
|
|
def setup_method(self):
|
|
_reset_module_globals()
|
|
|
|
def test_loader_period_overrides_global(self, tmp_path):
|
|
"""A loader with period:1 fires every turn, even when global period is 5."""
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"tier0": {"period": 1}, "global": {"offset": 0}},
|
|
}
|
|
)
|
|
)
|
|
|
|
_write_state(tmp_path, turn=2)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("tier0") is True
|
|
_reset_module_globals()
|
|
assert should_fire("global") is False
|
|
|
|
def test_loader_without_period_uses_global(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"global": {"offset": 0}},
|
|
}
|
|
)
|
|
)
|
|
|
|
_write_state(tmp_path, turn=2)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("global") is False
|
|
|
|
def test_tier0_period_1_fires_every_turn(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"tier0": {"period": 1}},
|
|
}
|
|
)
|
|
)
|
|
|
|
for turn_val in range(1, 8):
|
|
_reset_module_globals()
|
|
_write_state(tmp_path, turn=turn_val - 1)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("tier0") is True, f"tier0 should fire on turn {turn_val}"
|
|
|
|
def test_navmap_period_5_skips_non_fire_turns(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"navmap": {"period": 5, "offset": 0}},
|
|
}
|
|
)
|
|
)
|
|
|
|
_write_state(tmp_path, turn=2)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("navmap") is False
|
|
|
|
def test_navmap_fires_on_turn_0(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"navmap": {"period": 5, "offset": 0}},
|
|
}
|
|
)
|
|
)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("navmap") is True
|
|
|
|
def test_navmap_fires_after_reset_counter(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire, reset_counter
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"navmap": {"period": 5, "offset": 0}},
|
|
}
|
|
)
|
|
)
|
|
|
|
_write_state(tmp_path, turn=7)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
reset_counter()
|
|
|
|
_reset_module_globals()
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("navmap") is True
|
|
|
|
def test_per_loader_period_zero_always_fires(self, tmp_path):
|
|
from aipass.hooks.apps.modules.cadence import should_fire
|
|
|
|
config = tmp_path / "cadence.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"period": 5,
|
|
"loaders": {"always": {"period": 0}},
|
|
}
|
|
)
|
|
)
|
|
|
|
_write_state(tmp_path, turn=2)
|
|
|
|
with (
|
|
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
|
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
|
patch(f"{MODULE}._CONFIG_PATH", config),
|
|
):
|
|
assert should_fire("always") is True
|