cadence fresh-context reset + aipass misroute guidance: SessionStart wiring (handler/config/setup.sh), loaders period-5, kernel+navmap aipass-exception, guide-not-crash (drone/aipass)
This commit is contained in:
@@ -138,5 +138,13 @@
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
}
|
||||
},
|
||||
|
||||
"SessionStart": {
|
||||
"cadence_reset": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
}
|
||||
},
|
||||
|
||||
"SessionStart": {
|
||||
"cadence_reset": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
"PreCompact": {
|
||||
"pre_compact": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIPass — Kernel
|
||||
|
||||
<!-- .aipass/tier0_kernel.md — Tier 0, injected EVERY turn (cadence period 1). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
|
||||
<!-- .aipass/tier0_kernel.md — Tier 0, injected every 5 turns (cadence period 5) + on every fresh context (new chat / clear / after compact). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your branch is your home and address. CWD is your identity: always know which branch you're standing in. The system runs on `drone`.
|
||||
|
||||
@@ -13,6 +13,8 @@ You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your
|
||||
- `drone @agent` — bare → the agent's live self-map.
|
||||
- `drone systems` — list every agent.
|
||||
|
||||
`aipass` is the one exception — the user's own front-door CLI and concierge (onboarding, `doctor`, OS/system help). Run `aipass` / `aipass --help` directly, **never `drone @aipass`** (drone can't resolve it). Serves humans, not agents.
|
||||
|
||||
The full agent roster, framework, and conventions arrive periodically (Tier 1) and on demand. Unsure of anything? Fetch it: `drone @agent --help` / the agent's `README.md` / `drone @memory search "query"`.
|
||||
|
||||
# Don't get lost
|
||||
|
||||
@@ -41,7 +41,7 @@ src/aipass/<name>/
|
||||
|
||||
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
|
||||
- @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes.
|
||||
- @aipass — the user's front-door concierge and its OWN CLI, NOT drone-routed: run `aipass` / `aipass --help` directly, never `drone @aipass` (drone can't resolve it). The human's best friend — onboarding (`aipass init`/`install`), `doctor` health, help chat, and OS/system questions ("why's my wifi dropping", "why's CC hogging CPU", "what is drone", "how do I make a project"). Serves humans, not agents — reads, never writes.
|
||||
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
|
||||
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
|
||||
|
||||
@@ -44,7 +44,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
||||
- Update their execution logs, status, decision logs with current state
|
||||
- If a plan was completed, note it (but don't close — the user does that)
|
||||
|
||||
## 3. Git State
|
||||
## 3. Git State (Devpulse only)
|
||||
|
||||
- Run `git status` — report uncommitted changes
|
||||
- If there's a logical commit waiting, suggest it (don't commit without asking)
|
||||
|
||||
@@ -9,6 +9,32 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-07]
|
||||
|
||||
### Added
|
||||
|
||||
- **Fresh-context grounding: cadence reset on new chat / clear / compact.**
|
||||
Both prompt loaders (tier0 kernel + navmap) now run at period 5, and a new
|
||||
`SessionStart` hook resets the cadence counter on `startup`/`clear` (skips
|
||||
`resume` — restored context already carries grounding; `compact` was already
|
||||
reset via PreCompact). Net effect: the first message of every fresh context
|
||||
gets full grounding, then every 5th turn after. Wired end-to-end: handler
|
||||
(`session_start.py`), project config (`.aipass/hooks.json` + the
|
||||
`project_hooks.json` template for external projects), and `setup.sh` seeds
|
||||
the provider `SessionStart` entry for new installs. (built by @hooks +
|
||||
@devpulse)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`aipass` ≠ drone-routed — misroutes now guide instead of crash.** `aipass`
|
||||
is the user's front-door CLI, deliberately not resolvable by drone. But
|
||||
`drone aipass` misdirected, `drone @aipass` crashed with a traceback, and
|
||||
`aipass @drone` dead-ended. All three now print clear guidance (what aipass
|
||||
is, what drone is, how to reach each). Kernel + navmap prompts updated so
|
||||
agents know the exception. (built by @drone + @aipass)
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-06]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -662,6 +662,7 @@ else:
|
||||
# UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging
|
||||
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
|
||||
# PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries
|
||||
# SessionStart: cadence reset on startup/clear (handler skips resume itself)
|
||||
aipass_hooks = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:tier0_kernel"}]},
|
||||
@@ -696,6 +697,9 @@ aipass_hooks = {
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
],
|
||||
"SessionStart": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} SessionStart:cadence_reset", "timeout": 30}]},
|
||||
],
|
||||
}
|
||||
|
||||
# Merge, don't replace (DPLAN-0234 Strand C): refresh every AIPass bridge entry
|
||||
|
||||
@@ -106,6 +106,14 @@ def main():
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
if command.startswith("@"):
|
||||
print(f"{command} is a drone routing target, not an aipass command.")
|
||||
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||
print()
|
||||
print(f" Reach an agent: drone {command} ... · drone systems")
|
||||
print(" aipass commands: aipass --help")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -252,6 +252,38 @@ class TestMain:
|
||||
assert result == 0
|
||||
mod.handle_command.assert_called_once_with("doctor", [])
|
||||
|
||||
def test_at_prefix_shows_drone_guidance(self) -> None:
|
||||
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "@drone" in printed
|
||||
assert "drone routing target" in printed
|
||||
assert "Unknown command" not in printed
|
||||
|
||||
def test_at_prefix_uses_actual_name(self) -> None:
|
||||
"""@memory prints guidance with the actual @name the user typed."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "@memory" in printed
|
||||
assert "drone @memory" in printed
|
||||
|
||||
def test_plain_bad_command_still_unknown(self) -> None:
|
||||
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
mock_print.assert_called_with("Unknown command: frobnicate")
|
||||
|
||||
def test_command_with_remaining_args(self) -> None:
|
||||
"""Remaining args are passed to route_command."""
|
||||
mod = MagicMock()
|
||||
|
||||
@@ -568,6 +568,19 @@ def main() -> int:
|
||||
if command == "rm":
|
||||
return _handle_rm(args[1:])
|
||||
|
||||
# aipass is a user-facing CLI, not a drone-routable branch
|
||||
if command.lstrip("@") == "aipass":
|
||||
err_console.print(
|
||||
"aipass isn't reachable through drone — it's your own front-door CLI,"
|
||||
" the AIPass concierge (onboarding, doctor, help, OS/system questions)."
|
||||
" drone routes the agent citizens (@git, @devpulse, @memory...);"
|
||||
" aipass is separate and serves you directly.\n"
|
||||
"\n"
|
||||
" Use aipass: aipass · aipass --help\n"
|
||||
" See agents: drone systems"
|
||||
)
|
||||
return 1
|
||||
|
||||
# @target — route to branch or module
|
||||
if command.startswith("@"):
|
||||
return _handle_target(args)
|
||||
|
||||
@@ -839,3 +839,67 @@ class TestCliEntryPoint:
|
||||
):
|
||||
cli_main()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# aipass intercept — drone aipass / drone @aipass
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestAipassIntercept:
|
||||
"""'aipass' is a user CLI, not a drone-routable branch."""
|
||||
|
||||
def test_bare_aipass_shows_guidance(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""'drone aipass' prints guidance to stderr."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("sys.argv", ["drone", "aipass"]):
|
||||
result = main()
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "aipass isn't reachable through drone" in captured.err
|
||||
assert "aipass --help" in captured.err
|
||||
|
||||
def test_at_aipass_shows_guidance(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""'drone @aipass' prints guidance to stderr."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("sys.argv", ["drone", "@aipass"]):
|
||||
result = main()
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "aipass isn't reachable through drone" in captured.err
|
||||
assert "drone systems" in captured.err
|
||||
|
||||
def test_bare_aipass_no_traceback(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""No python traceback leaks on 'drone aipass'."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("sys.argv", ["drone", "aipass"]):
|
||||
result = main()
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Traceback" not in captured.err
|
||||
assert "ModuleNotFoundError" not in captured.err
|
||||
|
||||
def test_at_aipass_no_at_misdirect(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""No 'use @aipass' misdirect on 'drone @aipass'."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("sys.argv", ["drone", "@aipass"]):
|
||||
result = main()
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Use '@aipass'" not in captured.err
|
||||
|
||||
def test_real_branch_still_routes(self) -> None:
|
||||
"""Real branches still route normally after aipass intercept."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["drone", "@git", "status"]),
|
||||
patch(f"{_DRONE}.is_module", return_value=True),
|
||||
patch(f"{_DRONE}.route_module_command", return_value={"stdout": "ok", "stderr": "", "exit_code": 0}),
|
||||
):
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
@@ -241,6 +241,21 @@
|
||||
"standard": "json_structure",
|
||||
"reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/session_start.py",
|
||||
"standard": "dead_code",
|
||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/session_start.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in SessionStart.cadence_reset."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/session_start.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/cadence.py",
|
||||
"standard": "dead_code",
|
||||
@@ -1068,6 +1083,26 @@
|
||||
"file": "tests/test_session_boot.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_start.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_start.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_start.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_start.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -73,7 +73,8 @@ src/aipass/hooks/
|
||||
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
||||
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
|
||||
│ │ │ ├── compact.py # Pre-compact memory archival
|
||||
│ │ │ └── rollover.py # Pre-compact memory rollover
|
||||
│ │ │ ├── rollover.py # Pre-compact memory rollover
|
||||
│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart)
|
||||
│ │ └── notification/ # Sound/alert hooks
|
||||
│ │ ├── announce.py # Announcement tone on notification
|
||||
│ │ ├── email.py # Inbox check on prompt
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: session_start.py
|
||||
# Version: 1.0.0
|
||||
# Description: Resets cadence counter on new chat / clear (SessionStart)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-07-07
|
||||
# Modified: 2026-07-07
|
||||
# =============================================
|
||||
|
||||
"""Resets cadence counter on SessionStart so loaders re-fire at turn 0.
|
||||
|
||||
Fires on source=startup (new chat) and source=clear (/clear).
|
||||
Skips source=resume — restored context already carries grounding.
|
||||
source=compact is already handled by PreCompact; a duplicate reset is
|
||||
harmless (idempotent), so we allow it rather than adding a fragile gate.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
_SKIP_SOURCES = frozenset({"resume"})
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Reset cadence counter unless this is a resume."""
|
||||
source = hook_data.get("source", "")
|
||||
|
||||
if source in _SKIP_SOURCES:
|
||||
logger.info("[HOOKS] session_start: skipped cadence reset (source=%s)", source)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
try:
|
||||
cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
cadence.reset_counter(hook_data=hook_data)
|
||||
logger.info("[HOOKS] session_start: cadence reset (source=%s)", source)
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] session_start: cadence reset failed: %s", exc)
|
||||
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -45,7 +45,7 @@ DEFAULTS = {
|
||||
"enabled": True,
|
||||
"period": 5,
|
||||
"loaders": {
|
||||
"tier0": {"period": 1},
|
||||
"tier0": {"period": 5, "offset": 0},
|
||||
"navmap": {"period": 5, "offset": 0},
|
||||
"branch": {"offset": 0},
|
||||
},
|
||||
|
||||
@@ -332,7 +332,7 @@ class TestConfig:
|
||||
with patch(f"{MODULE}._CONFIG_PATH", tmp_path / "nonexistent.json"):
|
||||
config = _load_config()
|
||||
|
||||
assert config["loaders"]["tier0"]["period"] == 1
|
||||
assert config["loaders"]["tier0"]["period"] == 5
|
||||
assert config["loaders"]["navmap"]["period"] == 5
|
||||
assert config["loaders"]["navmap"]["offset"] == 0
|
||||
|
||||
@@ -709,7 +709,9 @@ class TestPostCompactDeterminism:
|
||||
def test_compact_handler_calls_reset_with_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
hook_data = {"cwd": "/tmp/fake", "session_id": "test-123"}
|
||||
import tempfile
|
||||
|
||||
hook_data = {"cwd": tempfile.gettempdir() + "/fake", "session_id": "test-123"}
|
||||
|
||||
with (
|
||||
patch("importlib.import_module") as mock_import,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_session_start.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for SessionStart cadence reset handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-07-07
|
||||
# Modified: 2026-07-07
|
||||
# =============================================
|
||||
|
||||
"""Tests for apps/handlers/lifecycle/session_start.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
CADENCE_MODULE = "aipass.hooks.apps.modules.cadence"
|
||||
|
||||
|
||||
def _reset_cadence_globals():
|
||||
import aipass.hooks.apps.modules.cadence as mod
|
||||
|
||||
mod._turn = None
|
||||
mod._config = None
|
||||
|
||||
|
||||
def _write_state(tmp_path, turn, session="test-session"):
|
||||
import time
|
||||
|
||||
state_file = tmp_path / f"aipass-cadence-{session}.json"
|
||||
state_file.write_text(json.dumps({"turn": turn, "token": -1}))
|
||||
old = time.time() - 10
|
||||
os.utime(state_file, (old, old))
|
||||
return state_file
|
||||
|
||||
|
||||
class TestSessionStartHandler:
|
||||
def setup_method(self):
|
||||
_reset_cadence_globals()
|
||||
|
||||
def test_startup_resets_cadence(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
state_file = _write_state(tmp_path, turn=7)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
result = handle({"source": "startup", "session_id": "test-session"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
def test_clear_resets_cadence(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
state_file = _write_state(tmp_path, turn=3)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
result = handle({"source": "clear", "session_id": "test-session"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
def test_resume_skips_reset(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
state_file = _write_state(tmp_path, turn=7)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
result = handle({"source": "resume", "session_id": "test-session"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == 7
|
||||
|
||||
def test_compact_source_resets(self, tmp_path):
|
||||
"""source=compact is idempotent with PreCompact — allowed."""
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
state_file = _write_state(tmp_path, turn=5)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
result = handle({"source": "compact", "session_id": "test-session"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
def test_empty_source_resets(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
state_file = _write_state(tmp_path, turn=4)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
result = handle({"session_id": "test-session"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
def test_no_stdout_output(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
with patch("importlib.import_module"):
|
||||
result = handle({"source": "startup"})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_cadence_import_failure_does_not_crash(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
|
||||
with patch("importlib.import_module", side_effect=ImportError("boom")):
|
||||
result = handle({"source": "startup"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
||||
class TestSessionStartCadenceIntegration:
|
||||
"""End-to-end: SessionStart reset -> next turn fires all loaders."""
|
||||
|
||||
def setup_method(self):
|
||||
_reset_cadence_globals()
|
||||
|
||||
def test_clear_then_all_loaders_fire(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
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": 5, "offset": 0},
|
||||
"navmap": {"period": 5, "offset": 0},
|
||||
"branch": {"offset": 0},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
_write_state(tmp_path, turn=3)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{CADENCE_MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
handle({"source": "clear", "session_id": "test-session"})
|
||||
|
||||
_reset_cadence_globals()
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{CADENCE_MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
assert should_fire("tier0") is True
|
||||
_reset_cadence_globals()
|
||||
assert should_fire("navmap") is True
|
||||
_reset_cadence_globals()
|
||||
assert should_fire("branch") is True
|
||||
|
||||
def test_resume_does_not_reset_counter_continues(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.session_start import handle
|
||||
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": 5, "offset": 0},
|
||||
"navmap": {"period": 5, "offset": 0},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
_write_state(tmp_path, turn=2)
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{CADENCE_MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
handle({"source": "resume", "session_id": "test-session"})
|
||||
|
||||
_reset_cadence_globals()
|
||||
|
||||
with (
|
||||
patch(f"{CADENCE_MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{CADENCE_MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
assert should_fire("tier0") is False
|
||||
_reset_cadence_globals()
|
||||
assert should_fire("navmap") is False
|
||||
Reference in New Issue
Block a user