From 4476008440dcb630c8b941f0013ae278d8220fe2 Mon Sep 17 00:00:00 2001 From: Sebastian Gonzalez Date: Thu, 1 Oct 2026 12:08:16 -0700 Subject: [PATCH] test: bring public's suites up to the v0.103.0 plugin A promotion replaces plugins/memhub/ only, so public's tests/ and its codex-hooks generator stayed at 0.88.0 while the plugin moved to 0.103.0. Nineteen suites then failed guard and probe-tests on #282, each on an intentional internal change: directive recall retired (#80), capture never routed to a brain (#79), the rule judge and state sweep (#96, #87), and the one-line harness hand-off (#90, #97). The eighteen suites and scripts/gen_codex_hooks.py are internal's at ca51713 (the promoted commit), with plugins/memhub-staging rewritten to plugins/memhub. directive_recall_test.py goes from tests/ and scripts/, as it did on internal. No file under plugins/ changes. Locally: run_all 72/72, check-plugin.sh ALL PASS, version parity and test registration pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/directive_recall_test.py | 158 ---- scripts/gen_codex_hooks.py | 8 +- tests/brain_brief_test.py | 215 +++--- tests/brain_resolve_test.py | 437 +++-------- tests/brief_identifiers_test.py | 84 +-- tests/claude_hook_guard_test.py | 11 +- tests/codex_hooks_parity_test.py | 2 +- tests/codex_hooks_setup_test.py | 141 ++-- tests/compute_budget_test.py | 12 +- tests/directive_recall_test.py | 286 -------- tests/documentation_test.py | 145 +++- tests/flush_session_test.py | 167 +---- tests/flush_turn_test.py | 8 - tests/harness_extract_test.py | 182 ++--- tests/harness_stop_test.py | 1181 +++++++++--------------------- tests/md_capture_test.py | 71 +- tests/platform_routing_test.py | 122 ++- tests/plugin_operations_test.py | 1 - tests/plugin_upgrade_test.py | 8 +- tests/rulebook_hook_test.py | 328 ++++++++- tests/turn_flush_bound_test.py | 11 +- 21 files changed, 1349 insertions(+), 2229 deletions(-) delete mode 100644 scripts/directive_recall_test.py delete mode 100644 tests/directive_recall_test.py diff --git a/scripts/directive_recall_test.py b/scripts/directive_recall_test.py deleted file mode 100644 index 771f3704..00000000 --- a/scripts/directive_recall_test.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Self-test for the directive_recall hook's session state + dedup + render. - -Exercises the exact failure class measured in production (2026-07 replay of a -real session: 1804 injections, 76% of them REPEATS of a directive already shown -that session) plus the state-file mechanics and provenance rendering. Run: - - python3 scripts/directive_recall_test.py -""" -from __future__ import annotations - -import importlib.util -import json -import sys -import tempfile -import time -from pathlib import Path - -_SCRIPT = Path(__file__).resolve().parents[1] / "plugins/memhub/scripts/directive_recall.py" -spec = importlib.util.spec_from_file_location("directive_recall", _SCRIPT) -dr = importlib.util.module_from_spec(spec) -sys.modules["directive_recall"] = dr -spec.loader.exec_module(dr) - -FAILURES: list[str] = [] - - -def check(name: str, cond: bool) -> None: - print(f"{'PASS' if cond else 'FAIL'} {name}") - if not cond: - FAILURES.append(name) - - -# Real items shaped like the babysit lessons that fired 40+ times per session. -REPEAT = { - "id": "d-babysit-1", "type": "lesson", - "content": "When babysitting a PR, treat the first pass as collection-only.", - "triggers": ["gh pr view", "statusCheckRollup"], - "as_of": "2026-07-08", "seen": 4, -} -FRESH = { - "id": "d-fresh-1", "type": "procedure", - "content": "Run the targeted directive suites before pushing.", - "triggers": ["test_directive_rank.py"], - "as_of": "2026-07-09", "seen": 1, -} - -with tempfile.TemporaryDirectory() as td: - dr._STATE_DIR = Path(td) - - dr._save_fired("sess-A", ["d-babysit-1"]) - check("state round-trip", dr._load_fired("sess-A") == ["d-babysit-1"]) - check("unknown session is empty", dr._load_fired("sess-B") == []) - check("hostile session id is inert", dr._state_path("../../etc/passwd").name != "passwd.json" - if dr._state_path("../../etc/passwd") else True) - - # 2. The 76% class: an id already injected this session is dropped - # client-side, independent of server version. - fired = set(dr._load_fired("sess-A")) - items = [d for d in [REPEAT, FRESH] if str(d.get("id") or "") not in fired] - check("repeat dropped, fresh kept", [d["id"] for d in items] == ["d-fresh-1"]) - - # 3. Injections-only recording: a directive filtered out before render must - # NOT be recorded (it keeps its chance at its real moment). - check("gate-dropped id not recorded", "d-fresh-1" not in dr._load_fired("sess-A")) - - old = dr._STATE_DIR / "old-sess.json" - old.write_text("[]") - import os - os.utime(old, (time.time() - 8 * 24 * 3600,) * 2) - dr._save_fired("sess-A", ["d-babysit-1", "d-fresh-1"]) - check("stale session pruned", not old.exists()) - check("current session kept", dr._load_fired("sess-A") == ["d-babysit-1", "d-fresh-1"]) - -# 5. Render carries provenance the agent can weight (as_of / seen). -block = dr._render([REPEAT]) -check("render has as_of", "as of 2026-07-08" in block) -check("render has seen", "seen 4×" in block) -check("render skips seen=1", "seen 1×" not in dr._render([FRESH])) -check("render valid as hook payload", json.dumps({"additionalContext": block}) != "") - -# 6. Repo name derivation: this checkout has an origin remote; the name must be -# the remote basename, not a worktree dir name. -repo = dr._repo_name(str(_SCRIPT.parent)) -check("repo from git remote", repo == "agent-plugins") -check("no cwd → empty repo", dr._repo_name("") == "") - -# 7. The precision filter: the real misfire class (repo-name-only trigger) -# still drops — now via the RESOLVED repo (remote basename), exercised on -# a real throwaway git checkout since a bare directory name no longer -# mints a scope bucket. Concrete file-path triggers still pass. -import subprocess as _sp -import tempfile as _tf - -with _tf.TemporaryDirectory() as _td: - _repo = Path(_td) / "some-checkout-dir" # basename ≠ remote name - _repo.mkdir() - for _cmd in (["git", "init", "-q"], - ["git", "remote", "add", "origin", - "https://github.com/XTraceAI/memhub-claude-plugin.git"]): - _sp.run(_cmd, cwd=_repo, capture_output=True, check=True) - - repo_only = {"id": "r", "content": "x", "triggers": ["memhub-claude-plugin"]} - concrete = {"id": "c", "content": "y", "triggers": ["directive_recall.py"]} - kept = dr._precision_filter( - [repo_only, concrete], - {"command": "python plugins/memhub/scripts/directive_recall.py"}, - str(_repo), - ) - check("repo-only trigger dropped (resolved via remote)", - [d["id"] for d in kept] == ["c"]) - - # 8. Multi-repo parent workflow (field report): cwd is a NON-git parent; - # the acted-on file lives in a child repo. Scope resolves from the - # file's repo — never from the parent directory's basename. - check("repo from acted-on path under non-git parent", - dr._repo_name(_td, {"file_path": str(_repo / "app" / "x.py")}) - == "memhub-claude-plugin") - check("non-git cwd, no path → unknown scope, not a bucket", - dr._repo_name(_td) == "") - - # Containment: git only ever runs inside the session cwd. A payload - # path OUTSIDE it must not steer resolution (falls back to the cwd's - # own remote), and with no cwd at all a relative path must not bind - # to the hook process's cwd. - check("acted-on path outside cwd falls back to cwd's remote", - dr._repo_name(str(_repo), {"file_path": "/somewhere/else/x.py"}) - == "memhub-claude-plugin") - check("relative path with empty cwd → unknown scope", - dr._repo_name("", {"file_path": "src/x.py"}) == "") - -check("repo tokens are the name + its parts", - dr._repo_tokens("MemHub-Backend") == {"memhub-backend", "memhub", - "backend"}) -check("unknown scope blocks no repo tokens", dr._repo_tokens("") == set()) - -# 9. Windows-path noise (field report): OS-path segments and the machine's -# own account name can never be a directive's sole anchor, while a -# concrete file trigger still matches a backslashed Windows payload. -_account = Path.home().name.lower() -win_payload = {"file_path": "C:\\Users\\" + _account + "\\proj\\foo_helper.py"} -noisy = {"id": "n", "content": "x", "triggers": ["Users"]} -selfname = {"id": "s", "content": "x", "triggers": [_account]} -concrete_win = {"id": "w", "content": "x", "triggers": ["foo_helper.py"]} -kept = dr._precision_filter([noisy, selfname, concrete_win], win_payload, "") -check("path filler + account name dropped; windows path still matches", - [d["id"] for d in kept] == ["w"]) - -# 10. Boundary-aware matching: a token must not match inside a longer word. -inside = {"id": "i", "content": "x", "triggers": ["part"]} -kept = dr._precision_filter([inside], {"command": "grep particular file"}, "") -check("token never matches inside a longer word", - [d["id"] for d in kept] == []) - -print() -if FAILURES: - print(f"{len(FAILURES)} FAILED: {FAILURES}") - raise SystemExit(1) -print("all checks passed") diff --git a/scripts/gen_codex_hooks.py b/scripts/gen_codex_hooks.py index f89f7e1d..c30e4c59 100644 --- a/scripts/gen_codex_hooks.py +++ b/scripts/gen_codex_hooks.py @@ -3,12 +3,12 @@ Codex clones Claude's hook contract, but hook trust is per command definition. The Codex file therefore folds MemHub's behaviors into one dispatcher per event -instead of exposing every directive, artifact, and capture subprocess as its +instead of exposing every rulebook, artifact, and capture subprocess as its own approval. Capture still routes through ``codex_flush.py`` inside the bridge because Codex sessions are rollout files, not Claude transcripts. Generated, checked in, and pinned by tests/codex_hooks_parity_test.py — the -generator is the single place the Claude→Codex delta lives, so a directive +generator is the single place the Claude→Codex delta lives, so a hook edited in claude-hooks.json cannot silently drift out of the Codex file (the parity test fails until this is re-run). @@ -78,7 +78,7 @@ def generate(claude_hooks: dict) -> dict: """The Codex hooks document derived from the Claude one.""" source = json.dumps(claude_hooks) - for required in ("directive_recall.py", "artifact_sync_reminder.py"): + for required in ("rulebook_hook.py", "artifact_sync_reminder.py"): if required not in source: raise ValueError(f"Claude hooks no longer expose {required}") @@ -106,7 +106,7 @@ def handler(event: str, timeout: int, status: str | None = None) -> dict: "PreToolUse": [{ "matcher": _ALL_TOOLS, "hooks": [handler( - "PreToolUse", 8, "MemHub: checking for relevant directives" + "PreToolUse", 8, "MemHub: checking team rules" )], }], "PostToolUse": [{ diff --git a/tests/brain_brief_test.py b/tests/brain_brief_test.py index 59e23419..82dacfb5 100644 --- a/tests/brain_brief_test.py +++ b/tests/brain_brief_test.py @@ -18,11 +18,11 @@ * ``refresh`` is throttled and never raises, whatever the cache contains; * the map is drawn from the Index when the digest has one, else from the footer counts; the prose clip is short; -* apply / recall pointers come from a cache a detached child refreshes, are - rendered once per session (served ids are shared with directive_recall), - and are the first thing cut under the budget — never the map; -* the prompt hook is silent and free on a prompt with no identifier, fires - one recall otherwise, and delivers a pending pointer cache exactly once. +* recall pointers come from a cache a detached child refreshes, are rendered + once per session, and are the first thing cut under the budget — never the + map; a legacy ``apply`` list in an old cache is never rendered; +* the prompt hook makes no recall of its own: it delivers a pending pointer + cache exactly once, and is silent otherwise. Run: python3 tests/brain_brief_test.py (from the repo root; stdlib only). """ @@ -136,8 +136,8 @@ def _brief(payload: dict | None = None) -> dict: check("says so when capture is switched off", "capture is OFF" in _ctx(off)) os.environ.pop("MEMHUB_TURN_FLUSH") on = _brief() -check("says sessions are captured when it is on", - "captured into it automatically" in _ctx(on)) +check("says sessions go to personal memory, not the brain", + "captured into your personal memory, never into this brain" in _ctx(on)) # ── the overview: injected, and clipped ──────────────────────────────────── cache = brain_brief._cache_path("staging", BRAIN) @@ -287,8 +287,12 @@ def __init__(self, content=(), structured=None): " ├── Specs 4" in m and "Docs 5" not in m and "(full Index" in m) check("with an Index the counts tree is not drawn too", "Facts " not in m) m = "\n".join(brain_brief._render_map(BRAIN, FOOTERED)) -check("without an Index the map is drawn from the footer counts", - "├── Facts 2,553" in m and 'memory_type="episodes")' in m) +check("without an Index the map is drawn from the footer's artifact count", + "└── Artifacts 51" in m and 'kind="artifact")' in m + and "read_memory(id)" in m) +check("the footer map names no facts, no in-brain episodes, no removed tool", + "Facts" not in m and "Episodes" not in m and "get_artifact" not in m + and "memory_type" not in m) check("no digest at all points at get_brain_overview", "No compiled overview cached yet" in "\n".join(brain_brief._render_map(BRAIN, ""))) m = "\n".join(brain_brief._render_map(BRAIN, "just prose, no footer")) @@ -298,21 +302,19 @@ def __init__(self, content=(), structured=None): check("the prose clip is 600 chars", "truncated" in m and len(m) < brain_brief._MAX_OVERVIEW_CHARS + 200) -# ── assembly under budget: recall goes first, then apply, never the map ──── +# ── assembly under budget: recall is cut, never the map ──────────────────── HEAD = ["H"] MAP = ["## Map", "m1", "m2"] -APPLY = ["## Apply"] + [f"• a{i}" for i in range(5)] -RECALL = ["## Recall"] + [f"• r{i}" for i in range(5)] + ["(open one: …)"] -full = brain_brief._assemble(HEAD, MAP, copy.copy(APPLY), copy.copy(RECALL), 10_000) +RECALL = ["## Recall"] + [f"• r{i} {'x' * 30}" for i in range(5)] + ["(open one: …)"] +full = brain_brief._assemble(HEAD, MAP, copy.copy(RECALL), 10_000) check("under budget nothing is cut", "• r4" in full and brain_brief._TRIMMED_FOOTER not in full) -just_apply = len("\n".join(HEAD + [""] + MAP + [""] + APPLY)) -tight = brain_brief._assemble(HEAD, MAP, copy.copy(APPLY), copy.copy(RECALL), just_apply + 40) -check("recall pointers are dropped before any apply pointer", - "• a4" in tight and "• r" not in tight and "## Recall" not in tight) +tight = brain_brief._assemble(HEAD, MAP, copy.copy(RECALL), len(full) - 1) +check("recall pointers are dropped from the end", + "• r0" in tight and "• r4" not in tight) check("a cut brief says so", tight.endswith(brain_brief._TRIMMED_FOOTER)) -tighter = brain_brief._assemble(HEAD, MAP, copy.copy(APPLY), copy.copy(RECALL), 10) -check("the map is never cut", "m2" in tighter and "• a" not in tighter - and tighter.endswith(brain_brief._TRIMMED_FOOTER)) +tighter = brain_brief._assemble(HEAD, MAP, copy.copy(RECALL), 10) +check("the map is never cut", "m2" in tighter and "• r" not in tighter + and "## Recall" not in tighter and tighter.endswith(brain_brief._TRIMMED_FOOTER)) # ── brief + pointer cache: rendered, served, not repeated, refreshed ─────── _stub_room(room) @@ -324,38 +326,42 @@ def __init__(self, content=(), structured=None): brain_brief._spawn_pointers = lambda cwd: spawned.append(cwd) # type: ignore[assignment] ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s1"})) -check("no pointer cache: no Apply/Recall, but the worker is spawned", - "## Apply" not in ctx and spawned == ["/repo"]) +check("no pointer cache: no Recall, but the worker is spawned", + "## Recall" not in ctx and spawned == ["/repo"]) PCACHE = brain_brief._pointers_path("staging", BRAIN, "/repo") POINTERS = { "computed_at": time.time(), "head": "h1", "branch": "b", "base": "origin/main", - "apply": [{"id": "d1", "type": "lesson", "text": "lesson one", "as_of": "2026-09-01", "match": "x.py"}, - {"id": "d2", "type": "procedure", "text": "proc two", "as_of": "", "match": ""}], + # A cache written by an older plugin still carries directive pointers. + "apply": [{"id": "d1", "type": "lesson", "text": "lesson one", "as_of": "2026-09-01", "match": "x.py"}], "recall": [{"id": "e1", "type": "episode", "text": "episode one", "match": "PR #1"}, - {"id": "a1", "type": "artifact", "text": "artifact one", "match": "x.py"}], + {"id": "e2", "type": "episode", "text": "episode two", "match": "x.py"}, + {"id": "a1", "type": "artifact", "text": "artifact one", "match": "x.py"}, + {"id": "a2", "type": "artifact", "text": "artifact two", "match": "PR #1"}], } +RECALL_IDS = {"e1", "e2", "a1", "a2"} brain_brief._write_json(PCACHE, POINTERS) spawned.clear() ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s1"})) -check("a fresh cache renders Apply", "## Apply" in ctx and "• [lesson] lesson one — 2026-09-01 · on x.py [d1]" in ctx) -check("a fresh cache renders Recall & Consult", "## Recall & Consult" in ctx and "[artifact] artifact one" in ctx) +check("a fresh cache renders Recall & Consult", + "## Recall & Consult" in ctx and "• [artifact] artifact one — on x.py [a1]" in ctx) +check("a legacy apply list is never rendered", "## Apply" not in ctx and "[d1]" not in ctx) check("every rendered id joins the served list", - set(served_state.load_ids(served_state.STATE_DIR, "s1")) == {"d1", "d2", "e1", "a1"}) + set(served_state.load_ids(served_state.STATE_DIR, "s1")) == RECALL_IDS) check("a fresh cache on the same HEAD is not recomputed", spawned == []) ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s1"})) -check("served ids are not rendered again", "## Apply" not in ctx and "## Recall" not in ctx) +check("served ids are not rendered again", "## Recall" not in ctx) ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s2"})) -check("another session still sees them", "[d1]" in ctx and "[a1]" in ctx) +check("another session still sees them", "[e1]" in ctx and "[a1]" in ctx) brain_brief._write_json(PCACHE, {**POINTERS, "head": "h0"}) spawned.clear(); _brief({"cwd": "/repo", "session_id": "s3"}) check("HEAD moved: the worker is spawned", spawned == ["/repo"]) brain_brief._write_json(PCACHE, {**POINTERS, "branch": "other"}) spawned.clear(); ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s4"})) check("a cache computed for another branch is neither rendered nor kept", - "## Apply" not in ctx and spawned == ["/repo"]) + "## Recall" not in ctx and spawned == ["/repo"]) brain_brief._write_json(PCACHE, {**POINTERS, "computed_at": time.time() - 2 * 86400}) spawned.clear(); ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s5"})) -check("a stale cache is neither rendered nor trusted", "## Apply" not in ctx and spawned == ["/repo"]) +check("a stale cache is neither rendered nor trusted", "## Recall" not in ctx and spawned == ["/repo"]) brain_brief._write_json(PCACHE, POINTERS) # the real spawner: disabled by env, else a detached `pointers` child @@ -381,31 +387,84 @@ def __init__(self, content=(), structured=None): sys.modules["_memhub_auth"] = _fake_auth -def _fake_recall_items(url, bearer, brain_id, repo, entities, served, session_id, limit, timeout): - recorded.update(served=list(served), session_id=session_id, limit=limit) - return [{"id": f"w{i}", "type": "lesson", "content": f"lesson {i}", "triggers": ["x.py"]} - for i in range(limit)] +def _fake_search_items(url, bearer, brain_id, identifiers, timeout): + recorded.update(identifiers=list(identifiers), brain_id=brain_id) + return [{"id": f"w{i}", "type": "episode", "text": f"episode {i}", "match": "x.py"} + for i in range(3 * brain_brief._MAX_RECALL)] -_saved = (brain_brief._recall_items, brain_brief._search_items, brain_brief._repo_name) -brain_brief._recall_items = _fake_recall_items # type: ignore[assignment] -brain_brief._search_items = lambda *a, **k: [] # type: ignore[assignment] -brain_brief._repo_name = lambda root: "r" # type: ignore[assignment] +_saved_search = brain_brief._search_items +brain_brief._search_items = _fake_search_items # type: ignore[assignment] served_state.add_ids(served_state.STATE_DIR, "s1", ["w0"]) check("worker exits clean", brain_brief.cmd_pointers("/repo") == 0) -check("the worker sends no served list and no session id", - recorded.get("served") == [] and recorded.get("session_id") == "") +check("the worker searches on the branch's identifiers", + recorded.get("identifiers") == ["x.py"] and recorded.get("brain_id") == BRAIN) wcache = brain_brief._read_json(PCACHE) check("the cache holds twice the cap so the per-session filter still fills a block", - recorded.get("limit") == 2 * brain_brief._MAX_APPLY - and len(wcache.get("apply") or []) == 2 * brain_brief._MAX_APPLY) + len(wcache.get("recall") or []) == 2 * brain_brief._MAX_RECALL + and "apply" not in wcache) ctx = _ctx(_brief({"cwd": "/repo", "session_id": "s1"})) check("a session's served ids are filtered at render time, not in the cache", - "[w0]" not in ctx and ctx.count("\n• ") == brain_brief._MAX_APPLY) -brain_brief._recall_items, brain_brief._search_items, brain_brief._repo_name = _saved + "[w0]" not in ctx and ctx.count("\n• ") == brain_brief._MAX_RECALL) +brain_brief._search_items = _saved_search del sys.modules["_memhub_auth"] brain_brief._write_json(PCACHE, POINTERS) +# ── _search_items: the two searches it asks, and the title/abstract grep ──── +# The server returns POINTERS (no body): episodes are personal and refused +# with an agent_brain_id, artifacts are searched inside the brain. +search_calls: list[dict] = [] +POINTER_HITS = { + "episode": [ + {"id": "ep1", "kind": "episode", "title": "Fix brain_brief.py recall", + "abstract": "…", "as_of": "2026-09-20", "score": 0.4}, + {"id": "ep2", "kind": "episode", "title": "Unrelated", "abstract": "talks about recall", + "content": "brain_brief.py appears only in a body", "score": 0.9}, + ], + "artifact": [ + {"id": "ar1", "kind": "artifact", "title": "Recall design", + "abstract": "Shipped in PR #42.", "score": 0.7}, + {"id": "ar2", "kind": "artifact", "title": "PR #420 notes", "abstract": "", "score": 0.8}, + ], +} +_fake_http = types.ModuleType("mcp_http") + + +def _fake_call_tool(url, bearer, name, args, timeout=None): + search_calls.append({"name": name, **args}) + return types.SimpleNamespace(isError=False, structuredContent={ + "items": POINTER_HITS[args["kind"]]}) + + +_fake_http.call_tool = _fake_call_tool # type: ignore[attr-defined] +_saved_http = sys.modules.get("mcp_http") +sys.modules["mcp_http"] = _fake_http +got = brain_brief._search_items("https://x", "b", BRAIN, ["brain_brief.py", "PR #42"], 5.0) +if _saved_http is not None: + sys.modules["mcp_http"] = _saved_http +else: + del sys.modules["mcp_http"] +by_kind = {c["kind"]: c for c in search_calls} +check("two searches: one per kind, singular kind values, no memory_type", + [c["name"] for c in search_calls] == ["search_memory", "search_memory"] + and set(by_kind) == {"episode", "artifact"} + and not any("memory_type" in c for c in search_calls)) +check("episodes are searched WITHOUT agent_brain_id (your own sessions)", + "agent_brain_id" not in by_kind["episode"]) +check("artifacts are searched inside the repo brain", + by_kind["artifact"].get("agent_brain_id") == BRAIN) +check("no body is requested — pointers only", + not any(c.get("include_content") for c in search_calls)) +check("a hit is kept only when its title/abstract names an identifier", + [d["id"] for d in got] == ["ar1", "ep1"]) +check("a body-only mention is not a match (pointers carry no body)", + "ep2" not in [d["id"] for d in got]) +check("PR #42 does not match PR #420", "ar2" not in [d["id"] for d in got]) +ep1 = next(d for d in got if d["id"] == "ep1") +check("a kept pointer carries kind, title text, as_of and the matched identifier", + ep1["type"] == "episode" and ep1["text"] == "Fix brain_brief.py recall" + and ep1["as_of"] == "2026-09-20" and ep1["match"] == "brain_brief.py") + # ── the budget: one env var, 2:1, trimmed footer ─────────────────────────── os.environ["MEMHUB_BRIEF_TOKEN_BUDGET"] = "300" check("budget is tokens × 4 chars, split 2:1", @@ -425,24 +484,7 @@ def _fake_recall_items(url, bearer, brain_id, repo, entities, served, session_id # ── the prompt hook ──────────────────────────────────────────────────────── brain_brief.room_map.repo_root = lambda cwd=None: Path("/repo") # type: ignore[assignment] -brain_brief.brief_identifiers.repo_files = lambda root: {"room_map", "room_map.py"} # type: ignore[assignment] brain_brief.brief_identifiers.current_branch = lambda root: "b" # type: ignore[assignment] -recall_calls: list[dict] = [] -PROMPT_ITEMS = [ - {"id": "p1", "type": "lesson", "content": "lesson about room_map", "as_of": "2026-09-02", - "triggers": ["room_map.read_room"]}, - {"id": "p2", "type": "lesson", "content": "second", "triggers": ["other"]}, - {"id": "p3", "type": "procedure", "content": "third", "triggers": []}, - {"id": "p4", "type": "lesson", "content": "fourth", "triggers": []}, -] - - -def _fake_prompt_recall(brain_id, root, entities, served, session_id): - recall_calls.append({"entities": entities, "served": list(served)}) - return [d for d in PROMPT_ITEMS if d["id"] not in served][:brain_brief._MAX_PROMPT] - - -brain_brief._prompt_recall = _fake_prompt_recall # type: ignore[assignment] def _prompt(payload: dict) -> dict: @@ -453,46 +495,25 @@ def _prompt(payload: dict) -> dict: return json.loads(raw) if raw else {} -# s1 has already seen the cache, so only the prompt's own identifiers matter -out = _prompt({"cwd": "/repo", "session_id": "s1", "prompt": "summarise what we did yesterday"}) -check("a prompt with no identifier makes no recall and prints nothing", - out == {} and recall_calls == []) +# s1 has already seen the cache, so nothing is pending for it out = _prompt({"cwd": "/repo", "session_id": "s1", "prompt": "why does room_map.read_room return None for PR #182?"}) -pctx = _ctx(out) -check("identifiers in the prompt fire one recall on them", - len(recall_calls) == 1 and {"room_map.read_room", "PR #182"} <= set(recall_calls[0]["entities"])) -check("the hook answers as UserPromptSubmit", - out.get("hookSpecificOutput", {}).get("hookEventName") == "UserPromptSubmit") -check("at most three pointers", pctx.count("\n• ") + pctx.startswith("• ") <= 3 and "[p4]" not in pctx) -check("the matched trigger is shown", "on room_map.read_room [p1]" in pctx) -check("prompt pointers join the served list", - {"p1", "p2", "p3"} <= set(served_state.load_ids(served_state.STATE_DIR, "s1"))) -out = _prompt({"cwd": "/repo", "session_id": "s1", "prompt": "again: room_map.read_room"}) -check("served pointers are not repeated: only the unseen one remains", - "[p4]" in _ctx(out) and "[p1]" not in _ctx(out)) -out = _prompt({"cwd": "/repo", "session_id": "s1", "prompt": "again: room_map.read_room"}) -check("and once everything is served the hook is silent", out == {}) - -LONG_ITEMS = [{"id": f"L{i}", "type": "lesson", "content": "w" * 400, "triggers": []} for i in range(3)] -brain_brief._prompt_recall = lambda *a, **k: LONG_ITEMS # type: ignore[assignment] -pctx = _ctx(_prompt({"cwd": "/repo", "session_id": "s7", "prompt": "see room_map.read_room"})) -check("the prompt block stays under 600 chars, cut from the end", - 0 < len(pctx) <= brain_brief._PROMPT_MAX_CHARS and "[L0]" in pctx and "[L2]" not in pctx) -brain_brief._prompt_recall = _fake_prompt_recall # type: ignore[assignment] +check("with nothing pending the prompt hook prints nothing, identifiers or not", out == {}) # the pointer cache the brief could not deliver arrives on the first prompt — once out = _prompt({"cwd": "/repo", "session_id": "s8", "prompt": "no identifiers here"}) check("a pending pointer cache is delivered by the prompt hook", - "## Apply" in _ctx(out) and "[a1]" in _ctx(out)) + "## Recall & Consult" in _ctx(out) and "[a1]" in _ctx(out) + and out.get("hookSpecificOutput", {}).get("hookEventName") == "UserPromptSubmit") +check("…without the legacy apply list", "[d1]" not in _ctx(out)) out = _prompt({"cwd": "/repo", "session_id": "s8", "prompt": "no identifiers here"}) check("…and only once per refresh", out == {}) brain_brief._write_json(PCACHE, {**POINTERS, "computed_at": time.time() + 1, - "apply": [{"id": "d9", "type": "lesson", "text": "new", "as_of": "", "match": ""}], - "recall": []}) + "recall": [*POINTERS["recall"], + {"id": "e9", "type": "episode", "text": "new", "match": ""}]}) out = _prompt({"cwd": "/repo", "session_id": "s8", "prompt": "no identifiers here"}) check("a refreshed cache is delivered again, minus served ids", - "[d9]" in _ctx(out) and "[d1]" not in _ctx(out)) + "[e9]" in _ctx(out) and "[e1]" not in _ctx(out)) brain_brief._write_json(PCACHE, {**POINTERS, "computed_at": time.time() + 2, "branch": "other"}) spawned.clear() out = _prompt({"cwd": "/repo", "session_id": "s9", "prompt": "no identifiers here"}) @@ -501,8 +522,10 @@ def _prompt(payload: dict) -> dict: and served_state.load_ids(served_state.STATE_DIR, "s9") == []) brain_brief._write_json(PCACHE, POINTERS) -# a budget cut at SessionStart leaves the marker alone, so the prompt delivers the rest -os.environ["MEMHUB_BRIEF_TOKEN_BUDGET"] = "300" +# a budget cut at SessionStart leaves the marker alone, so the prompt delivers the rest. +# 330 is picked to fit some but not all pointers under the head sentence +# (320-340 does); re-pick it if the head grows. +os.environ["MEMHUB_BRIEF_TOKEN_BUDGET"] = "330" bctx = _ctx(_brief({"cwd": "/repo", "session_id": "s10"})) shown_at_start = set(brain_brief._ids_in(bctx)) check("the trimmed brief showed some but not all cached pointers", @@ -513,7 +536,7 @@ def _prompt(payload: dict) -> dict: out = _prompt({"cwd": "/repo", "session_id": "s10", "prompt": "no identifiers here"}) rest = set(brain_brief._ids_in(_ctx(out))) check("the first prompt delivers exactly the pointers the budget cut", - rest and rest.isdisjoint(shown_at_start) and shown_at_start | rest == {"d1", "d2", "e1", "a1"}) + rest and rest.isdisjoint(shown_at_start) and shown_at_start | rest == RECALL_IDS) check("…and then the marker is advanced", served_state.load_marker(served_state.STATE_DIR, "s10", "brief").get("computed_at") == POINTERS["computed_at"]) @@ -532,7 +555,7 @@ def _prompt(payload: dict) -> dict: "buf = io.StringIO()\n" "with contextlib.redirect_stdout(buf): brain_brief.cmd_brief({'cwd': '/repo', 'session_id': 'probe'})\n" "ctx = json.loads(buf.getvalue())['hookSpecificOutput']['additionalContext']\n" - "print(json.dumps([sorted(m for m in sys.modules if m in ('mcp_http', '_memhub_auth', 'urllib.request', 'http.client')), '## Apply' in ctx]))" + "print(json.dumps([sorted(m for m in sys.modules if m in ('mcp_http', '_memhub_auth', 'urllib.request', 'http.client')), '## Recall' in ctx]))" % (str(SCRIPTS), room, FAKE_GIT)], capture_output=True, text=True, env={**os.environ, "HOME": _TMP_HOME, "USERPROFILE": _TMP_HOME, "MEMHUB_BRIEF_POINTERS": "0"}, diff --git a/tests/brain_resolve_test.py b/tests/brain_resolve_test.py index aa2f1981..ed9626a5 100644 --- a/tests/brain_resolve_test.py +++ b/tests/brain_resolve_test.py @@ -46,8 +46,7 @@ class _Session: ``result`` is either one result returned for every tool, or a callable ``(name, arguments) -> result`` when a test needs different answers per - tool or per org — which resolution now needs, since it asks ``list_orgs`` - and then ``list_agent_brains`` once per org until it finds the room. + tool. """ def __init__(self, result, record=None): @@ -65,21 +64,6 @@ async def call_tool(self, name, arguments=None): return result -def _listing(org_id, brains): - """A ``list_agent_brains`` payload that echoes its scope, as the real - server does — verified against staging, where every listing carries - ``scope.org_name``.""" - return _Result({"agent_brains": brains, "scope": {"org_name": org_id}}) - - -def _orgs(*org_ids): - """A ``list_orgs`` payload. The FIRST id is the default org.""" - return _Result({"orgs": [ - {"org_id": o, "name": o, "is_default": i == 0} - for i, o in enumerate(org_ids) - ]}) - - NAME = "Repo: XTraceAI/agent-plugins" BID = "11111111-2222-3333-4444-555555555555" @@ -107,16 +91,20 @@ def test_resolves_and_caches(): _isolate(tmp) session = _Session(_Result({"agent_brains": [ {"name": "Repo: XTraceAI/other", "agent_brain_id": "nope"}, - {"name": NAME, "agent_brain_id": BID}, + {"name": NAME, "agent_brain_id": BID, "org_id": "org-with-room"}, ]})) room = run(br.resolve_repo_brain(session, "/repo", "staging")) check("found the exact match", (room or {}).get("brain_id"), BID) - # Orgs are enumerated first so the match can be cached WITH its org. - # The guarantee that matters is below: this happens once, then never - # again for this repo. - check("listed brains once", session.calls.count("list_agent_brains"), 1) - check("cached for next time", - (rm.read_room("/repo", "staging") or {}).get("brain_id"), BID) + check("returned the org the row names", (room or {}).get("org_id"), + "org-with-room") + check("one call, and it is the repo lookup", session.calls, + ["list_agent_brains"]) + check("asked with repo=/, no org walk", session.args[0][1], + {"repo": "XTraceAI/agent-plugins"}) + cached = rm.read_room("/repo", "staging") or {} + check("cached for next time", cached.get("brain_id"), BID) + check("cached with its org", cached.get("org_id"), "org-with-room") + check("and settled", rm.resolve_due("/repo", "staging"), False) session2 = _Session(_Result({"agent_brains": []})) room = run(br.resolve_repo_brain(session2, "/repo", "staging")) @@ -125,25 +113,18 @@ def test_resolves_and_caches(): def test_no_brain_is_remembered_as_a_miss(): - """Without a negative entry every turn would re-query for a repo that + """Without a negative entry every save would re-query for a repo that simply has no room.""" print("negative cache") with tempfile.TemporaryDirectory() as tmp: _isolate(tmp) - - # ``list_orgs`` has to answer for real now: a miss is only recorded - # after a COMPLETE search, so a fixture that cannot enumerate orgs - # would (correctly) decline to brand this repo room-less. - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default") - return _Result({"agent_brains": [ - {"name": "Repo: someone/else", "agent_brain_id": "x"}]}) - + answer = _Result({"agent_brains": []}) session = _Session(answer) room = run(br.resolve_repo_brain(session, "/repo", "staging")) check("no match -> no room", room, None) check("miss recorded", rm.resolve_due("/repo", "staging"), False) + entry = rm._load()["repos"][NAME]["staging"] + check("a 24h miss, not a probe backoff", "missed_at" in entry, True) session2 = _Session(answer) run(br.resolve_repo_brain(session2, "/repo", "staging")) @@ -177,12 +158,23 @@ def test_never_routes_on_a_fuzzy_or_broken_match(): run(br.resolve_repo_brain(session, "/repo", "staging")), None) +def test_a_listing_that_ignored_repo_is_still_name_matched(): + """A backend that did not honour ``repo=`` hands back every brain. The + one-row answer is not "the repo's brain" unless it carries the room name.""" + print("repo filter not trusted blindly") + with tempfile.TemporaryDirectory() as tmp: + _isolate(tmp) + session = _Session(_Result({"agent_brains": [ + {"name": "Some newest brain", "agent_brain_id": "newest"}]})) + check("an unrelated sole row is not routed to", + run(br.resolve_repo_brain(session, "/repo", "staging")), None) + check("nothing cached", rm.read_room("/repo", "staging"), None) + + def test_a_miss_never_clobbers_a_resolved_room(): """The lookup behind a miss listed brains at some EARLIER moment, so by the time it writes, someone else may have resolved or created the room — - /memhub:onboard racing a background flush is the obvious case. Clobbering - there would silently send capture to personal memory for the whole TTL, - right after the user did the thing meant to fix that.""" + /memhub:onboard racing a background save is the obvious case.""" print("miss vs resolved id") with tempfile.TemporaryDirectory() as tmp: _isolate(tmp) @@ -200,37 +192,62 @@ def test_a_miss_never_clobbers_a_resolved_room(): def test_duplicate_room_names_are_not_guessed_between(): - """Duplicate rooms for one repo happen. Picking whichever came first would - route this repo's memory into an arbitrary one — invisibly, and differently - for different teammates.""" + """Duplicate rooms for one repo happen — now across orgs too, since one + call sees every org. Picking whichever came first would route this repo's + memory into an arbitrary one — invisibly, and differently for different + teammates.""" print("duplicate room names") with tempfile.TemporaryDirectory() as tmp: _isolate(tmp) session = _Session(_Result({"agent_brains": [ - {"name": NAME, "agent_brain_id": BID}, - {"name": NAME, "agent_brain_id": "22222222-3333-4444-5555-666666666666"}, + {"name": NAME, "agent_brain_id": BID, "org_id": "org-a"}, + {"name": NAME, "agent_brain_id": "22222222-3333-4444-5555-666666666666", + "org_id": "org-b"}, ]})) check("ambiguous -> no room", run(br.resolve_repo_brain(session, "/repo", "staging")), None) check("nothing cached", rm.read_room("/repo", "staging"), None) # Deliberately NOT a miss: merging the duplicates should take effect on - # the next flush, not after a 24h TTL. + # the next save, not after a 24h TTL. check("stays due so a fix applies immediately", rm.resolve_due("/repo", "staging"), True) +def test_one_brain_listed_twice_is_not_a_duplicate(): + print("same brain, two rows") + with tempfile.TemporaryDirectory() as tmp: + _isolate(tmp) + session = _Session(_Result({"agent_brains": [ + {"name": NAME, "agent_brain_id": BID}, + {"name": NAME, "agent_brain_id": BID, "org_id": "org-confirmed"}, + ]})) + room = run(br.resolve_repo_brain(session, "/repo", "staging")) + check("resolved to the one brain", (room or {}).get("brain_id"), BID) + check("took the sighting that names its org", (room or {}).get("org_id"), + "org-confirmed") + + def test_failures_degrade_to_no_room(): - """A capture hook must never fail because a lookup did.""" + """A capture hook must never fail because a lookup did — and a lookup + that got no answer must not brand the repo room-less for a day.""" print("failure handling") for label, session in [ ("tool error", _Session(_Result({}, is_error=True))), ("exception", _Session(RuntimeError("network down"))), - ("unparseable payload", _Session(_Result(None, text="not json"))), ]: with tempfile.TemporaryDirectory() as tmp: _isolate(tmp) check(f"{label} -> no room", run(br.resolve_repo_brain(session, "/repo", "staging")), None) + entry = rm._load()["repos"].get(NAME, {}).get("staging", {}) + check(f"{label} -> no 24h miss", "missed_at" in entry, False) + check(f"{label} -> backed off for minutes", + rm.resolve_due("/repo", "staging"), False) + with tempfile.TemporaryDirectory() as tmp: + _isolate(tmp) + check("unparseable payload -> no room", + run(br.resolve_repo_brain(_Session(_Result(None, text="not json")), + "/repo", "staging")), None) def test_result_shapes_are_tolerated(): @@ -252,277 +269,12 @@ def test_result_shapes_are_tolerated(): BID) -def test_finds_a_room_outside_the_default_org(): - """The failure this exists to fix. A room in a non-default org was - invisible to resolution — ``list_agent_brains`` lists ONE org — and every - capture into it then died with "Agent brain not found".""" - print("cross-org resolution") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name, args): - if name == "list_orgs": - return _orgs("org-default", "org-with-room") - if args.get("org_id") == "org-with-room": - return _listing("org-with-room", - [{"name": NAME, "agent_brain_id": BID}]) - return _listing(args.get("org_id"), []) # default org has nothing - - session = _Session(answer) - room = run(br.resolve_repo_brain(session, "/repo", "staging")) - check("found it in the non-default org", (room or {}).get("brain_id"), BID) - check("returned the org too", (room or {}).get("org_id"), "org-with-room") - cached = rm.read_room("/repo", "staging") or {} - check("cached the org alongside the id", cached.get("org_id"), - "org-with-room") - check("searched the default org first", - [a.get("org_id") for n, a in session.args if n == "list_agent_brains"], - ["org-default", "org-with-room"]) - - -def test_a_room_visible_from_several_orgs_is_not_a_duplicate(): - """The SAME brain id seen from two orgs is one room, not an ambiguity. - - Every org is still searched — that is what makes a real cross-org duplicate - visible — so this also pins the cost: one listing per org, paid once on a - cache miss and never again. - """ - print("shared room across orgs") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - return _listing(args.get("org_id"), - [{"name": NAME, "agent_brain_id": BID}]) - - session = _Session(answer) - room = run(br.resolve_repo_brain(session, "/repo", "staging")) - check("resolved to the one brain", (room or {}).get("brain_id"), BID) - check("recorded the org nearest the user", (room or {}).get("org_id"), - "org-default") - check("searched every org", session.calls.count("list_agent_brains"), 2) - - # ...and the cost is paid once. - session2 = _Session(answer) - run(br.resolve_repo_brain(session2, "/repo", "staging")) - check("cache hit costs nothing", session2.calls, []) - - -def test_a_cross_org_duplicate_is_not_settled_by_org_order(): - """Two DIFFERENT brains sharing this repo's room name, one per org. - - Stopping at the first org with a hit would route to whichever org came - first — and that order starts from the default org, which follows the last - org selected in the MemHub app. The target would change when a user merely - clicks around the UI, and two teammates would send the same repo's memory to - different brains. So every org is searched before deciding, and a genuine - ambiguity stays visible. - """ - print("cross-org duplicates") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - other = "99999999-8888-7777-6666-555555555555" - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - which = BID if args.get("org_id") == "org-default" else other - return _Result({"agent_brains": [ - {"name": NAME, "agent_brain_id": which}]}) - - session = _Session(answer) - room = run(br.resolve_repo_brain(session, "/repo", "staging")) - check("did not route", room, None) - check("looked in BOTH orgs before deciding", - session.calls.count("list_agent_brains"), 2) - check("cached nothing", rm.read_room("/repo", "staging"), None) - # Stays DUE, so merging the duplicates takes effect next flush. - check("still due", rm.resolve_due("/repo", "staging"), True) - - -def test_an_incomplete_search_does_not_brand_a_repo_room_less(): - """If list_orgs is unavailable, only the default org was searched. Writing - a miss then would send a repo whose room lives elsewhere to personal memory - for the whole TTL, on the strength of a look that never happened.""" - print("incomplete search") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _Result(None, is_error=True) - return _Result({"agent_brains": []}) # default org has nothing - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - check("no room", room, None) - check("nothing routed", rm.read_room("/repo", "staging"), None) - # Backed off, not branded: a short pause rather than a day of personal - # memory — and not a re-query on every single turn either, which is the - # storm the backoff exists to prevent. - check("backed off", rm.resolve_due("/repo", "staging"), False) - - data = json.loads(rm.ROOMS_PATH.read_text()) - entry = data["repos"][NAME]["staging"] - check("no 24h miss was written", "missed_at" in entry, False) - entry["probed_at"] = time.time() - rm.PROBE_BACKOFF_S - 1 - rm.ROOMS_PATH.write_text(json.dumps(data)) - check("due again in minutes, not a day", - rm.resolve_due("/repo", "staging"), True) - - -def test_one_org_failing_to_list_does_not_settle_a_duplicate(): - """The nastiest shape. Two brains share the name across two orgs, and the - org holding the second transiently fails to list. Exactly one match comes - back — indistinguishable from an unambiguous hit — and committing it would - cache an arbitrary pick for a day. A hole in the search has to beat a - confident-looking result.""" - print("duplicate hidden by an error") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-broken") - if args.get("org_id") == "org-broken": - return _Result(None, is_error=True) # the hidden duplicate - return _Result({"agent_brains": [ - {"name": NAME, "agent_brain_id": BID}]}) - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - check("did not commit the visible half", room, None) - check("cached no brain_id", rm.read_room("/repo", "staging"), None) - check("backed off rather than branding", - rm.resolve_due("/repo", "staging"), False) - - -def test_a_listing_that_ignored_our_org_scope_is_not_trusted(): - """The recorded org must be confirmed by the responder, not assumed from - the request. Brain rows carry no org, so the listing's echoed - ``scope.org_name`` is the only confirmation available — and a server that - returned the same brains regardless of scope would otherwise have its - contents attributed to whichever org we happened to ask about first. - """ - print("scope echo guard") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - # Ignores org_id: always answers as the DEFAULT org. - return _Result({ - "agent_brains": [{"name": NAME, "agent_brain_id": BID}], - "scope": {"org_name": "org-default"}, - }) - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - # The default-org listing is honoured and matches; the other org's - # listing came back scoped to org-default, so it is dropped rather - # than crediting org-other with a brain it may not hold. - check("routed on the honoured listing", (room or {}).get("brain_id"), None) - check("nothing cached from an unscoped listing", - rm.read_room("/repo", "staging"), None) - - -def test_a_listing_with_no_scope_echo_is_used_but_not_attributed(): - """A server that answers without echoing a scope: the brain is real, but - which org holds it is unconfirmed. - - Refusing to resolve would break every backend that does not echo a scope; - attributing it to the org we happened to ask about would record a tenancy - claim nothing confirmed. So the match is used and the org is left off — - the pre-org behaviour, plus a rate-limited upgrade later. - """ - print("no scope echo") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - brains = ([{"name": NAME, "agent_brain_id": BID}] - if args.get("org_id") == "org-default" else []) - return _Result({"agent_brains": brains}) # no scope key at all - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - check("still routes", (room or {}).get("brain_id"), BID) - check("but claims no org", (room or {}).get("org_id"), None) - check("cached without an org", - (rm.read_room("/repo", "staging") or {}).get("org_id"), None) - - -def test_a_confirmed_org_beats_an_unconfirmed_sighting(): - """A backend that echoes a scope for some listings but not others. - - The same brain is visible from both orgs; the DEFAULT org's listing carries - no echo, the other one does. Taking the first sighting would cache the - entry org-less and leave it due for a re-probe it does not need, throwing - away an org the server actually confirmed. - """ - print("confirmed org wins") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - rows = [{"name": NAME, "agent_brain_id": BID}] - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - if args.get("org_id") == "org-other": - return _listing("org-other", rows) # echoes its scope - return _Result({"agent_brains": rows}) # no echo - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - check("same brain either way", (room or {}).get("brain_id"), BID) - check("took the confirmed org", (room or {}).get("org_id"), "org-other") - check("and settled", rm.resolve_due("/repo", "staging"), False) - - -def test_a_scoped_listing_is_trusted(): - """The same shape, answering correctly per org, must still resolve.""" - print("scope echo honoured") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - org = args.get("org_id") - brains = ([{"name": NAME, "agent_brain_id": BID}] - if org == "org-default" else []) - return _Result({"agent_brains": brains, - "scope": {"org_name": org}}) - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - check("resolved", (room or {}).get("brain_id"), BID) - check("with its org", (room or {}).get("org_id"), "org-default") - - -def test_a_complete_search_still_records_a_miss(): - """The negative cache must survive the change above, or a genuinely - room-less repo re-queries on every single turn.""" - print("complete search records a miss") - with tempfile.TemporaryDirectory() as tmp: - _isolate(tmp) - - def answer(name_, args): - if name_ == "list_orgs": - return _orgs("org-default", "org-other") - return _Result({"agent_brains": []}) - - run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) - check("miss recorded", rm.resolve_due("/repo", "staging"), False) - - def test_a_room_cached_without_an_org_is_resolved_again(): - """Upgrade path. A cache written before rooms carried their org holds a - brain_id, so nothing would ever re-ask — and every capture would keep - resolving that brain in the wrong org, forever.""" + """Upgrade path: an org-less entry is re-asked on the TTL clock, and a + row that names its org then settles it.""" print("legacy cache upgrade") with tempfile.TemporaryDirectory() as tmp: _isolate(tmp) - # Exactly what an older plugin wrote. rm.write_room(BID, name=NAME, env="staging") entry = rm._load()["repos"][NAME]["staging"] entry.pop("org_id", None) @@ -532,50 +284,46 @@ def test_a_room_cached_without_an_org_is_resolved_again(): encoding="utf-8") check("due again", rm.resolve_due("/repo", "staging"), True) - - def answer(name, args): - if name == "list_orgs": - return _orgs("org-default", "org-with-room") - if args.get("org_id") == "org-with-room": - return _listing("org-with-room", - [{"name": NAME, "agent_brain_id": BID}]) - return _listing(args.get("org_id"), []) - - run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) + run(br.resolve_repo_brain(_Session(_Result({"agent_brains": [ + {"name": NAME, "agent_brain_id": BID, "org_id": "org-with-room"}]})), + "/repo", "staging")) check("upgraded in place", (rm.read_room("/repo", "staging") or {}).get("org_id"), "org-with-room") check("and settles", rm.resolve_due("/repo", "staging"), False) -def test_an_unknowable_org_does_not_re_resolve_every_turn(): - """If the backend cannot report orgs, the entry stays org-less. It must - still be rate-limited, or a silent failure is traded for a per-turn - round trip on every single flush.""" +def test_an_org_less_row_does_not_re_resolve_every_turn(): + """If the rows carry no org, the entry stays org-less. It still routes + (the server derives the org from the brain id) and is rate-limited, or a + per-save round trip would replace a cache hit.""" print("org-less rate limit") with tempfile.TemporaryDirectory() as tmp: _isolate(tmp) - - def answer(name, args): - if name == "list_orgs": - return _Result(None, is_error=True) - return _Result({"agent_brains": [ - {"name": NAME, "agent_brain_id": BID}]}) - - room = run(br.resolve_repo_brain(_Session(answer), "/repo", "staging")) + room = run(br.resolve_repo_brain(_Session(_Result({"agent_brains": [ + {"name": NAME, "agent_brain_id": BID}]})), "/repo", "staging")) check("still routes without an org", (room or {}).get("brain_id"), BID) check("no org recorded", (rm.read_room("/repo", "staging") or {}) .get("org_id"), None) check("not due again immediately", rm.resolve_due("/repo", "staging"), False) - - # ...but due again after the TTL, so a fixed backend is picked up. data = rm._load() data["repos"][NAME]["staging"]["resolved_at"] = 0 rm.ROOMS_PATH.write_text(json.dumps(data), encoding="utf-8") check("due again after the TTL", rm.resolve_due("/repo", "staging"), True) +def test_a_remote_less_room_is_asked_by_its_bare_name(): + print("no-remote room name") + with tempfile.TemporaryDirectory() as tmp: + _isolate(tmp) + br.room_name = lambda cwd=None: "Repo: scratch" + session = _Session(_Result({"agent_brains": []})) + run(br.resolve_repo_brain(session, "/repo", "staging")) + check("repo= carries the bare name", session.args[0][1], {"repo": "scratch"}) + br.room_name = lambda cwd=None: NAME + + def test_is_missing_brain(): """Which server error licenses forgetting a cached room. @@ -614,20 +362,13 @@ def test_is_missing_brain(): for fn in (test_resolves_and_caches, test_no_brain_is_remembered_as_a_miss, test_a_miss_never_clobbers_a_resolved_room, test_duplicate_room_names_are_not_guessed_between, + test_one_brain_listed_twice_is_not_a_duplicate, test_never_routes_on_a_fuzzy_or_broken_match, + test_a_listing_that_ignored_repo_is_still_name_matched, test_failures_degrade_to_no_room, test_result_shapes_are_tolerated, - test_finds_a_room_outside_the_default_org, - test_a_room_visible_from_several_orgs_is_not_a_duplicate, - test_a_cross_org_duplicate_is_not_settled_by_org_order, - test_an_incomplete_search_does_not_brand_a_repo_room_less, - test_one_org_failing_to_list_does_not_settle_a_duplicate, - test_a_listing_that_ignored_our_org_scope_is_not_trusted, - test_a_listing_with_no_scope_echo_is_used_but_not_attributed, - test_a_confirmed_org_beats_an_unconfirmed_sighting, - test_a_scoped_listing_is_trusted, - test_a_complete_search_still_records_a_miss, test_a_room_cached_without_an_org_is_resolved_again, - test_an_unknowable_org_does_not_re_resolve_every_turn, + test_an_org_less_row_does_not_re_resolve_every_turn, + test_a_remote_less_room_is_asked_by_its_bare_name, test_is_missing_brain): fn() print() diff --git a/tests/brief_identifiers_test.py b/tests/brief_identifiers_test.py index d3743291..8c22d4d5 100644 --- a/tests/brief_identifiers_test.py +++ b/tests/brief_identifiers_test.py @@ -1,9 +1,7 @@ -"""Self-test for the identifier extractor behind the brief and the prompt hook. +"""Self-test for the git identifier extractor behind the brief. -The contract under test: only IDENTIFIERS come out — paths, symbols that exist -in the repo, ``PR #N`` / ``ENG-N``, quoted error strings — and never words. A -prompt about "what we did yesterday" yields nothing, so the hook that consumes -this stays silent and free on it. +The contract under test: only IDENTIFIERS come out — paths and ``PR #N`` / +``ENG-N`` references — and never words. The git half runs against a throwaway repository built here, so the checks are hermetic: no dependence on this checkout's branch or history. @@ -36,66 +34,6 @@ def check(label: str, cond: bool) -> None: _failures.append(label) -REPO_TOKENS = { - "plugins/memhub/scripts/brain_brief.py", "brain_brief.py", "brain_brief", - "plugins/memhub/scripts/room_map.py", "room_map.py", "room_map", - "tests/brain_brief_test.py", "brain_brief_test.py", "brain_brief_test", - "plugins", "memhub", "scripts", "tests", "README.md", "readme", -} - - -def test_paths_symbols_refs_errors() -> None: - found = bi.from_prompt( - "Fix plugins/memhub/scripts/brain_brief.py so room_map.read_room copes with " - "PR #182 and ENG-1010; the error was \"Agent brain not found\" and " - "`ModuleNotFoundError: No module named mcp` showed up too.", - REPO_TOKENS, - ) - check("a repo-relative path is a path", - "plugins/memhub/scripts/brain_brief.py" in found["paths"]) - check("Module.name is a symbol when the module is in the repo", - "room_map.read_room" in found["symbols"]) - check("PR #N is a ref", "PR #182" in found["refs"]) - check("ENG-N is a ref", "ENG-1010" in found["refs"]) - check("a quoted failure message is an error string", - "Agent brain not found" in found["errors"]) - check("a backticked *Error line is an error string", - any(e.startswith("ModuleNotFoundError") for e in found["errors"])) - - -def test_bare_basename_needs_the_repo() -> None: - found = bi.from_prompt("look at brain_brief.py and at wibble.py", REPO_TOKENS) - check("a bare basename that IS a repo file is a path", "brain_brief.py" in found["paths"]) - check("a bare basename that is NOT a repo file is dropped", "wibble.py" not in found["paths"]) - found = bi.from_prompt("add sub/new_file.py under scripts/newdir", REPO_TOKENS) - check("a slashed token with an extension is a path", "sub/new_file.py" in found["paths"]) - check("a slashed token through a repo directory is a path", "scripts/newdir" in found["paths"]) - check("the path's own stem is not reported again as a symbol", - "brain_brief" not in found["symbols"]) - - -def test_snake_case_must_occur_in_the_repo() -> None: - found = bi.from_prompt("call brain_brief_test then update_config and do_it", REPO_TOKENS) - check("a snake_case token in the repo is a symbol", "brain_brief_test" in found["symbols"]) - check("a snake_case token absent from the repo is not", "update_config" not in found["symbols"]) - check("a short snake token is not", "do_it" not in found["symbols"]) - - -def test_negatives_yield_nothing() -> None: - for prompt in ( - "please summarise what we did yesterday", - "Why is the build slow? Think about it.", - "see https://github.com/XTraceAI/agent-plugins/pull/9999 for context", - "version 1.2.3 is out", - "\"this is a long quoted sentence with nothing wrong in it\"", - "support client/server mode and input/output formats, choose yes/no", - ): - found = bi.from_prompt(prompt, REPO_TOKENS) - check(f"nothing in: {prompt[:48]!r}", - not any(found[k] for k in ("paths", "symbols", "refs", "errors"))) - check("entities_for of nothing is nothing", bi.entities_for([], []) == []) - - def test_refs_canonical_forms() -> None: check("(#179) in a commit subject is PR #179", bi.refs_in("fix(md-capture): captured too (v0.49.1) (#179)") == ["PR #179"]) @@ -105,19 +43,6 @@ def test_refs_canonical_forms() -> None: bi.refs_in("PR #5 then #5 then #6") == ["PR #5", "PR #6"]) -def test_entities_are_relative_path_plus_basename() -> None: - ents = bi.entities_for(["plugins/memhub/scripts/brain_brief.py"], ["PR #1"], - ["room_map.read_room"], ["Agent brain not found"]) - check("full relative path is an entity", "plugins/memhub/scripts/brain_brief.py" in ents) - check("basename is an entity", "brain_brief.py" in ents) - check("refs, symbols and errors follow", ents[-3:] == ["PR #1", "room_map.read_room", - "Agent brain not found"]) - many = bi.entities_for([f"dir/f{i}.py" for i in range(400)], ["PR #7"], ["room_map"]) - check("the entity list is capped under the server's limit", len(many) <= bi.MAX_ENTITIES) - check("refs and symbols keep their slots when paths would fill the cap", - "PR #7" in many and "room_map" in many and "dir/f0.py" in many) - - def _git(repo: Path, *args: str) -> str: return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", @@ -149,9 +74,6 @@ def test_from_git_reads_branch_commits_and_diff() -> None: check("outside a repo everything is empty", bi.from_git(_TMP_HOME) == {"root": "", "branch": "", "head": "", "base": "", "paths": [], "refs": []}) - toks = bi.repo_files(repo) - check("repo_files carries paths, basenames and stems", - {"sub/b.py", "b.py", "a.py"} <= toks and "a" not in toks) if __name__ == "__main__": diff --git a/tests/claude_hook_guard_test.py b/tests/claude_hook_guard_test.py index 83e92349..be3e39b9 100644 --- a/tests/claude_hook_guard_test.py +++ b/tests/claude_hook_guard_test.py @@ -79,13 +79,18 @@ def test_every_claude_handler_is_guarded_and_only_boundaries_capture(): for group in groups: for handler in group["hooks"]: commands.append((event, handler["command"])) - assert len(commands) == 22 # + PreToolUse (add_memory_gate.py), + assert len(commands) == 20 # - the three retired directive-recall + # handlers (PreToolUse ×2, PostToolUse), + # + PreToolUse (add_memory_gate.py), # + UserPromptSubmit (brain_brief.py prompt, # and rulebook_hook.py prompt — the lane that # arms a prompt-armed obligation), # + Stop (harness_stop.py, a no-op unless - # MEMHUB_HARNESS_EXTRACT; it blocks the stop, - # so its prompt lane is gone), + # MEMHUB_HARNESS_EXTRACT; it continues the + # stop, so its prompt lane is gone), + # + SessionStart (harness_stop.py session, + # behind the same flag: the standing rule the + # person is not shown), # + PostToolUse (pr_link_trigger.py); SessionEnd # carries capture AND the fire flush in ONE # handler, because they must run in that order. diff --git a/tests/codex_hooks_parity_test.py b/tests/codex_hooks_parity_test.py index 59627b3a..bb7a8628 100644 --- a/tests/codex_hooks_parity_test.py +++ b/tests/codex_hooks_parity_test.py @@ -2,7 +2,7 @@ """codex-hooks.json must be exactly what the generator produces. The Codex hooks file is GENERATED from claude-hooks.json (scripts/ -gen_codex_hooks.py) so the directive/artifact hooks can never drift between +gen_codex_hooks.py) so the rulebook/artifact hooks can never drift between hosts. This pins the check-in to the generator's output — edit claude-hooks.json or the generator, re-run it, commit both. diff --git a/tests/codex_hooks_setup_test.py b/tests/codex_hooks_setup_test.py index 2697b53d..efa4d5fe 100644 --- a/tests/codex_hooks_setup_test.py +++ b/tests/codex_hooks_setup_test.py @@ -244,21 +244,28 @@ def test_runner_selects_latest_known_install(): print("PASS test_runner_selects_latest_known_install") -def test_runner_relays_directive_and_artifact_context(): +def _legacy_directive_scripts(scripts: Path) -> None: + """The scripts an older plugin shipped. Retired: nothing may run them.""" + for name in ("directive_prefilter.py", "reactive_prefilter.py"): + (scripts / name).write_text( + "import sys; sys.stdin.read(); raise SystemExit(0)\n", encoding="utf-8" + ) + (scripts / "directive_recall.py").write_text( + "import json; print(json.dumps({'hookSpecificOutput':{" + "'additionalContext':'SHOULD_NOT_RUN'}}))\n", + encoding="utf-8", + ) + + +def test_retired_directive_actions_are_silent_and_artifact_relays(): + """`directive-pre` / `directive-post` stay accepted (an older user-level + bridge may still invoke them) but do nothing and exit 0.""" with tempfile.TemporaryDirectory() as raw: plugin = Path(raw) scripts = plugin / "scripts" scripts.mkdir() (scripts / "codex_flush.py").write_text("", encoding="utf-8") - (scripts / "directive_prefilter.py").write_text( - "import sys; sys.stdin.read(); raise SystemExit(0)\n", encoding="utf-8" - ) - (scripts / "directive_recall.py").write_text( - "import json,sys; json.load(sys.stdin); " - "print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse'," - "'additionalContext':'proof'}}))\n", - encoding="utf-8", - ) + _legacy_directive_scripts(scripts) (scripts / "artifact_sync_reminder.py").write_text( "import json,sys; sys.stdin.read(); " "print(json.dumps({'hookSpecificOutput':{'hookEventName':'PostToolUse'," @@ -266,14 +273,20 @@ def test_runner_relays_directive_and_artifact_context(): encoding="utf-8", ) env = {**os.environ, "MEMHUB_PLUGIN_ROOT": str(plugin)} + for tool_name in ("Bash", "shell", "local_shell", "apply_patch"): + payload = json.dumps({ + "tool_name": tool_name, "tool_input": {"command": "touch x"}, + "tool_response": "error: failed", + }).encode() + for action in ("directive-pre", "directive-post"): + result = subprocess.run( + [sys.executable, str(SCRIPTS / "codex_hook_bridge.py"), action], + input=payload, capture_output=True, env=env, check=True, + ) + assert result.stdout == b"", (tool_name, action, result.stdout) payload = json.dumps({ "tool_name": "Bash", "tool_input": {"command": "touch x"} }).encode() - directive = subprocess.run( - [sys.executable, str(SCRIPTS / "codex_hook_bridge.py"), "directive-pre"], - input=payload, capture_output=True, env=env, check=True, - ) - assert json.loads(directive.stdout)["hookSpecificOutput"]["additionalContext"] == "proof" artifact = subprocess.run( [sys.executable, str(SCRIPTS / "codex_hook_bridge.py"), "artifact-sync"], input=payload, capture_output=True, env=env, check=True, @@ -283,22 +296,51 @@ def test_runner_relays_directive_and_artifact_context(): "hookEventName": "PostToolUse", "additionalContext": "version the linked artifact", } - print("PASS test_runner_relays_directive_and_artifact_context") + print("PASS test_retired_directive_actions_are_silent_and_artifact_relays") -def test_dispatch_combines_post_tool_contexts(): +def test_dispatch_pre_tool_runs_only_the_rulebook(): with tempfile.TemporaryDirectory() as raw: plugin = Path(raw) scripts = plugin / "scripts" scripts.mkdir() (scripts / "codex_flush.py").write_text("", encoding="utf-8") - (scripts / "reactive_prefilter.py").write_text( - "import sys; sys.stdin.read(); raise SystemExit(0)\n", encoding="utf-8" + _legacy_directive_scripts(scripts) + (scripts / "rulebook_hook.py").write_text( + "import json,sys; assert sys.argv[1:] == ['codex-pre', '--host', 'codex'], sys.argv; " + "json.load(sys.stdin); " + "print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse'," + "'additionalContext':'rule proof'}}))\n", + encoding="utf-8", ) - (scripts / "directive_recall.py").write_text( - "import json,sys; json.load(sys.stdin); " + env = {**os.environ, "MEMHUB_PLUGIN_ROOT": str(plugin)} + for tool_name in ("Bash", "shell", "local_shell", "apply_patch"): + payload = json.dumps({ + "hook_event_name": "PreToolUse", + "tool_name": tool_name, "tool_input": {"command": "touch x"}, + }).encode() + result = subprocess.run( + [sys.executable, str(SCRIPTS / "codex_hook_bridge.py"), + "dispatch", "PreToolUse"], + input=payload, capture_output=True, env=env, check=True, + ) + assert json.loads(result.stdout)["hookSpecificOutput"] == { + "hookEventName": "PreToolUse", "additionalContext": "rule proof", + }, tool_name + print("PASS test_dispatch_pre_tool_runs_only_the_rulebook") + + +def test_dispatch_combines_post_tool_contexts(): + with tempfile.TemporaryDirectory() as raw: + plugin = Path(raw) + scripts = plugin / "scripts" + scripts.mkdir() + (scripts / "codex_flush.py").write_text("", encoding="utf-8") + _legacy_directive_scripts(scripts) + (scripts / "rulebook_hook.py").write_text( + "import json,sys; assert sys.argv[1] == 'post', sys.argv; json.load(sys.stdin); " "print(json.dumps({'hookSpecificOutput':{'hookEventName':'PostToolUse'," - "'additionalContext':'reactive proof'}}))\n", + "'additionalContext':'rule proof'}}))\n", encoding="utf-8", ) (scripts / "artifact_sync_reminder.py").write_text( @@ -322,20 +364,18 @@ def test_dispatch_combines_post_tool_contexts(): output = json.loads(result.stdout)["hookSpecificOutput"] assert output == { "hookEventName": "PostToolUse", - "additionalContext": "reactive proof\n\nversion the artifact", + "additionalContext": "rule proof\n\nversion the artifact", } print("PASS test_dispatch_combines_post_tool_contexts") -def test_dispatch_keeps_artifact_context_when_recall_fails(): - original_directive = bridge._directive_result +def test_dispatch_keeps_artifact_context_when_rulebook_fails(): + original_rulebook = bridge._rulebook_result original_artifact = bridge._artifact_sync_result original_stdout = sys.stdout - def fail_directive(*_args, **_kwargs): - raise subprocess.TimeoutExpired( - "directive_recall.py", bridge._RECALL_TIMEOUT_S - ) + def fail_rulebook(*_args, **_kwargs): + raise subprocess.TimeoutExpired("rulebook_hook.py", 7) artifact = subprocess.CompletedProcess( args=[], returncode=0, stdout=json.dumps({ @@ -345,7 +385,7 @@ def fail_directive(*_args, **_kwargs): } }).encode(), stderr=b"", ) - bridge._directive_result = fail_directive + bridge._rulebook_result = fail_rulebook bridge._artifact_sync_result = lambda *_args, **_kwargs: artifact output = io.StringIO() sys.stdout = output @@ -355,12 +395,12 @@ def fail_directive(*_args, **_kwargs): ) finally: sys.stdout = original_stdout - bridge._directive_result = original_directive + bridge._rulebook_result = original_rulebook bridge._artifact_sync_result = original_artifact result = json.loads(output.getvalue())["hookSpecificOutput"] assert result["additionalContext"] == "artifact survived" - print("PASS test_dispatch_keeps_artifact_context_when_recall_fails") + print("PASS test_dispatch_keeps_artifact_context_when_rulebook_fails") def test_dispatch_session_start_merges_the_three_claude_scripts(): @@ -413,12 +453,8 @@ def test_dispatch_session_start_merges_the_three_claude_scripts(): def test_dispatch_subprocess_budgets_fit_hook_timeouts(): - assert bridge._GATE_TIMEOUT_S + bridge._RECALL_TIMEOUT_S < 8 assert max(bridge._SESSION_TIMEOUT_S, bridge._HEALTH_TIMEOUT_S) < 8 - assert max( - bridge._GATE_TIMEOUT_S + bridge._RECALL_TIMEOUT_S, - bridge._ARTIFACT_TIMEOUT_S, - ) < 16 + assert bridge._ARTIFACT_TIMEOUT_S < 16 print("PASS test_dispatch_subprocess_budgets_fit_hook_timeouts") @@ -454,34 +490,6 @@ def test_cli_names_only_the_memhub_handlers_for_review(): print("PASS test_cli_names_only_the_memhub_handlers_for_review") -def test_every_shell_alias_uses_the_directive_prefilter(): - with tempfile.TemporaryDirectory() as raw: - plugin = Path(raw) - scripts = plugin / "scripts" - scripts.mkdir() - (scripts / "codex_flush.py").write_text("", encoding="utf-8") - # Read-only shell calls must stop at this non-zero gate. If an alias - # bypasses it, directive_recall's proof output exposes the regression. - (scripts / "directive_prefilter.py").write_text( - "import sys; sys.stdin.read(); raise SystemExit(1)\n", encoding="utf-8" - ) - (scripts / "directive_recall.py").write_text( - "print('SHOULD_NOT_RUN')\n", encoding="utf-8" - ) - env = {**os.environ, "MEMHUB_PLUGIN_ROOT": str(plugin)} - for tool_name in ("Bash", "shell", "local_shell"): - payload = json.dumps({ - "tool_name": tool_name, "tool_input": {"command": "ls"} - }).encode() - result = subprocess.run( - [sys.executable, str(SCRIPTS / "codex_hook_bridge.py"), - "directive-pre"], - input=payload, capture_output=True, env=env, check=True, - ) - assert result.stdout == b"", tool_name - print("PASS test_every_shell_alias_uses_the_directive_prefilter") - - def test_rulebook_normalizes_shell_calls_without_losing_identity(): for name, inp in (("shell", {"command": ["/bin/bash", "-lc", "touch blocked"]}), ("exec_command", {"cmd": "touch blocked", "workdir": "/repo"}), @@ -494,14 +502,13 @@ def test_rulebook_normalizes_shell_calls_without_losing_identity(): assert result["session_id"] == "session" and result["cwd"] == "/repo" -def test_rulebook_denial_survives_context_merge_and_recall_failure(): +def test_rulebook_denial_survives_context_merge(): denied = subprocess.CompletedProcess([], 0, json.dumps({ "systemMessage": "Rule fired", "hookSpecificOutput": { "permissionDecision": "deny", "permissionDecisionReason": "blocked", "additionalContext": "rule evidence"}}).encode(), b"") out = io.StringIO() with patch.object(bridge, "_rulebook_result", return_value=denied), \ - patch.object(bridge, "_directive_result", side_effect=RuntimeError("recall failed")), \ contextlib.redirect_stdout(out): bridge._dispatch(Path("/unused"), b'{}', "PreToolUse") doc = json.loads(out.getvalue()) diff --git a/tests/compute_budget_test.py b/tests/compute_budget_test.py index cab351e8..0b56de74 100644 --- a/tests/compute_budget_test.py +++ b/tests/compute_budget_test.py @@ -9,6 +9,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from urllib.parse import urlsplit sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'plugins/memhub/scripts')) import mcp_http @@ -44,7 +45,7 @@ def test_session_backstop_stops_and_records_budget_cause(self): result = reply("Not saved: your organization has used its monthly compute budget") session = SimpleNamespace(call_tool=AsyncMock(return_value=result)) with patch.object(flush_session, '_breadcrumb') as crumb, patch.object(flush_session, '_log'): - ok, _ = asyncio.run(flush_session._send(session, {'conversation_id': 'test'}, None, None, None, 0, 1)) + ok = asyncio.run(flush_session._send(session, {'conversation_id': 'test'}, None, None, 0, 1)) self.assertFalse(ok) self.assertEqual(crumb.call_args.args[1], 'compute_budget_exhausted') @@ -67,8 +68,15 @@ def test_cursor_health_receives_installed_plugin_root(self): capture.capture_context(b'{"conversation_id":"test"}') argv = run.call_args.args[0] root = argv[argv.index('--plugin-root') + 1] + # The expected host is whatever THIS install declares, read straight + # from the root's own .mcp.json: internal ships the staging plugin and + # the release ships the production one, and the invariant under test is + # that health resolves the root it was handed, not an ambient default. + url = json.loads((Path(root) / '.mcp.json').read_text())['mcpServers']['memhub']['url'] + expected = urlsplit(url).hostname + self.assertIn('memhub.xtrace.ai', expected) with patch.object(capture_health, '_PLUGIN_ROOT_ARG', root), patch.dict('os.environ', {}, clear=True): - self.assertEqual(capture_health._env_host(), 'api.memhub.xtrace.ai') + self.assertEqual(capture_health._env_host(), expected) def test_cursor_failure_breadcrumb_reaches_health_and_success_clears_it(self): import cursor_flush as flush diff --git a/tests/directive_recall_test.py b/tests/directive_recall_test.py deleted file mode 100644 index 4361d8fd..00000000 --- a/tests/directive_recall_test.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Self-test for directive_recall's reactive path — against the REAL case. - -The measured motivator (2026-07-14): a dangling-$ref lesson anchored on -``openapi-typescript`` / ``app/memory/openapi.py`` never fired when -``npm run gen:types`` failed, because the command line only shows the npm -alias. These tests replay that exact case through the gate functions. - -Run: python3 directive_recall_test.py (stdlib only — the mcp import in -directive_recall is lazy, inside _recall). -""" -import json -import sys -from pathlib import Path - -# The tests live outside the plugin so they are not shipped to users; -# the code under test is still in the plugin's scripts dir. -SCRIPTS = Path(__file__).resolve().parents[1] / "plugins" / "memhub" / "scripts" -sys.path.insert(0, str(SCRIPTS)) -import directive_recall as dr # noqa: E402 - -F6_LESSON = { - "id": "f6", "type": "lesson", - "content": "When openapi-typescript fails on Memory.details oneOf, add the missing detail schema to app/memory/openapi.py.", - "triggers": ["openapi-typescript", "app/memory/openapi.py", "DirectiveDetails"], -} -GEN_TYPES_ARGS = {"command": "npm run gen:types"} -GEN_TYPES_ERROR = ( - "✨ openapi-typescript 7.13.0\n" - " ✘ Can't resolve $ref at #/components/schemas/Memory/properties/details/oneOf/3\n" - "Error: Can't resolve $ref at #/components/schemas/Memory/properties/details/oneOf/3" -) -CWD = "/Users/x/my-project" - - -def test_pretool_path_misses_the_alias(): - # The original miss, pinned: on the PreToolUse handle alone the lesson is - # (correctly) precision-dropped — its anchors aren't in "npm run gen:types". - assert dr._precision_filter([F6_LESSON], GEN_TYPES_ARGS, CWD) == [] - - -def test_reactive_haystack_fires_at_the_failure_site(): - kept = dr._precision_filter([F6_LESSON], GEN_TYPES_ARGS, CWD, GEN_TYPES_ERROR) - assert kept == [F6_LESSON] - - -def test_error_output_gates_success_and_extracts_tail(): - assert dr._error_output({"tool_response": "All checks passed!"}) is None - assert dr._error_output({}) is None - tail = dr._error_output({"tool_response": {"stdout": "x" * 5000 + GEN_TYPES_ERROR}}) - assert tail and "Can't resolve $ref" in tail and len(tail) <= dr._MAX_OUTPUT_CHARS - - -def test_error_regexes_stay_in_sync(): - import reactive_prefilter as rp - assert rp._ERROR_RE.pattern == dr._ERROR_RE.pattern - - -def _mk(i, triggers, seen=1): - return {"id": i, "type": "lesson", "content": f"lesson {i}", - "triggers": triggers, "seen": seen} - - -def test_generic_git_words_cannot_carry_a_match(): - # `origin` / `staging` word-tokens alone drove 1,461 audited injections: - # a directive anchored on the origin/staging PATH must not fire on a mere - # `git push origin ` … - d = _mk("w", ["origin/staging"]) - assert dr._precision_filter([d], {"command": "git push origin fm-feat/x"}, "") == [] - # … but must still fire where the full path is literally named — the - # audited in-place-rebase failure is exactly this call. - kept = dr._precision_filter([d], {"command": "git rebase origin/staging"}, "") - assert kept and kept[0]["_match"] == "origin/staging" - - -def test_rank_prefers_specific_match_then_seen_and_caps(): - specific = _mk("a", ["swerex/deployment/modal.py"]) - generic = _mk("b", ["modal"], seen=9) - unverified = _mk("c", None) - args = {"command": "uv run python swerex/deployment/modal.py --deploy"} - kept = dr._precision_filter([unverified, generic, specific], args, "") - top = dr._rank(kept)[:dr._MAX_DIRECTIVES] - assert [d["id"] for d in top] == ["a", "b"] # match beats unverified; specificity beats seen - assert dr._MAX_DIRECTIVES == 2 - - -def test_no_trigger_branch_clears_both_match_fields(): - """A dict reused across filter passes must not keep a stale `_match_len`, - which would misorder it against its unmatched peers in `_rank`.""" - d = _mk("a", ["swerex/deployment/modal.py"]) - dr._precision_filter([d], {"command": "vim swerex/deployment/modal.py"}, "") - assert d["_match_len"] > 0 - d["triggers"] = None # now unverifiable - dr._precision_filter([d], {"command": "vim other.py"}, "") - assert "_match" not in d and "_match_len" not in d - - -def test_render_shows_the_matched_trigger(): - d = _mk("a", ["swerex/deployment/modal.py", "boto3"], seen=3) - kept = dr._precision_filter([d], {"command": "vim swerex/deployment/modal.py"}, "") - out = dr._render(kept) - assert "fired on: swerex/deployment/modal.py" in out and "seen 3×" in out - - -def test_handle_cache_roundtrip(tmp_dir=None): - import tempfile - with tempfile.TemporaryDirectory() as td: - orig = dr._STATE_DIR - dr._STATE_DIR = Path(td) - try: - sid = "sess-abc" - key = dr._handle_key("Edit", {"file_path": "app/main.py"}, "/w/xmem") - assert key.startswith("/w/xmem:Edit:app/main.py:") - assert dr._load_handles(sid) == [] - dr._save_handles(sid, [key]) - assert dr._load_handles(sid) == [key] - # Bash commands normalize whitespace; non-handle args disable caching. - assert dr._handle_key("Bash", {"command": "git status\n"}, "/w") \ - == dr._handle_key("Bash", {"command": "git status"}, "/w") - assert dr._handle_key("Grep", {"pattern": "x"}, "/w") == "" - # Long commands differing only past the readable head must NOT - # collide — a false cache hit silently skips recall on the second. - long_a = "uv run python x.py " + "a" * 400 + " --flag-one" - long_b = "uv run python x.py " + "a" * 400 + " --flag-two" - assert (dr._handle_key("Bash", {"command": long_a}, "/w") - != dr._handle_key("Bash", {"command": long_b}, "/w")) - # Two WORKTREES of one repo share a remote basename but not a cwd: - # the same relative path in each must not share a cache slot. - assert (dr._handle_key("Edit", {"file_path": "app/main.py"}, "/w/xmem") - != dr._handle_key("Edit", {"file_path": "app/main.py"}, "/w/xmem-wt2")) - finally: - dr._STATE_DIR = orig - - -def _run_main(hook_input: dict, recall_result, td: str) -> tuple[int, str, list[str]]: - """Drive main() end-to-end with _recall stubbed; returns (rc, stdout, cache).""" - import io - import contextlib - orig_recall, orig_stdin, orig_dir = dr._recall, sys.stdin, dr._STATE_DIR - dr._STATE_DIR = Path(td) - - async def fake_recall(*a, **kw): - return recall_result - - dr._recall = fake_recall - sys.stdin = io.StringIO(json.dumps(hook_input)) - buf = io.StringIO() - try: - with contextlib.redirect_stdout(buf): - rc = dr.main() - return rc, buf.getvalue(), dr._load_handles(hook_input.get("session_id", "")) - finally: - dr._recall, sys.stdin, dr._STATE_DIR = orig_recall, orig_stdin, orig_dir - - -def test_failed_recall_does_not_poison_the_handle_cache(): - """A recall FAILURE must stay distinguishable from an empty result. - - ``_recall`` returns None on server error / unparseable payload and [] when - the server genuinely matched nothing. Caching a failure would suppress that - file/command for the whole session over a transient blip. - """ - import tempfile - hook = {"tool_name": "Edit", "tool_input": {"file_path": "app/main.py"}, - "cwd": "/tmp", "session_id": "cache-test"} - expected = dr._handle_key("Edit", {"file_path": "app/main.py"}, "/tmp") - with tempfile.TemporaryDirectory() as td: - rc, out, cache = _run_main(hook, None, td) # failure - assert rc == 0 and out == "" and cache == [] - with tempfile.TemporaryDirectory() as td: - rc, out, cache = _run_main(hook, [], td) # genuine empty → cache it - assert rc == 0 and out == "" and cache == [expected] - - -def test_cache_hit_costs_no_recall_and_no_git_subprocess(): - """A cached handle must short-circuit before ANY expensive work. - - `_repo_name` shells out to git; if it (or the recall) runs on a cache hit, - the cache stops being the latency win it exists to be. - """ - import io - import tempfile - hook = {"tool_name": "Edit", "tool_input": {"file_path": "app/main.py"}, - "cwd": "/tmp", "session_id": "hit-free"} - key = dr._handle_key("Edit", {"file_path": "app/main.py"}, "/tmp") - - def _boom(*a, **kw): - raise AssertionError("ran on a cache hit") - - git_calls = [] - saved = (dr._repo_name, dr._recall, sys.stdin, dr._STATE_DIR) - try: - with tempfile.TemporaryDirectory() as td: - dr._STATE_DIR = Path(td) - dr._save_handles("hit-free", [key]) - dr._repo_name = lambda cwd: git_calls.append(cwd) or "repo" - dr._recall = _boom - sys.stdin = io.StringIO(json.dumps(hook)) - assert dr.main() == 0 - assert git_calls == [], f"_repo_name spawned git on a cache hit: {git_calls}" - finally: - dr._repo_name, dr._recall, sys.stdin, dr._STATE_DIR = saved - - -def test_render_crash_is_contained_and_still_caches(): - """A post-recall crash must neither escape nor un-cache the handle. - - The emit is best-effort like the rest of the hook: it logs and moves on. The - handle is still recorded, because a DETERMINISTIC emit failure (broken - stdout, an unstringifiable payload) would otherwise re-buy a ~2.5s recall on - every later touch — the latency cliff the cache exists to prevent. Only a - failed RECALL stays uncached; that case is covered above. - """ - import io - import tempfile - hook = {"tool_name": "Edit", "tool_input": {"file_path": "app/main.py"}, - "cwd": "/tmp", "session_id": "render-boom"} - saved = (dr._render, dr._recall, sys.stdin, dr._STATE_DIR) - - async def fake_recall(*a, **kw): - return [_mk("a", ["app/main.py"])] - - def _boom(_items): - raise RuntimeError("render exploded") - - try: - with tempfile.TemporaryDirectory() as td: - dr._STATE_DIR = Path(td) - dr._recall, dr._render = fake_recall, _boom - sys.stdin = io.StringIO(json.dumps(hook)) - assert dr.main() == 0 # contained: still fails open, no traceback - expected = dr._handle_key("Edit", {"file_path": "app/main.py"}, "/tmp") - # …and recorded, so a deterministic crash can't re-buy the recall. - assert dr._load_handles("render-boom") == [expected] - finally: - dr._render, dr._recall, sys.stdin, dr._STATE_DIR = saved - - -def test_empty_shapes_are_answers_not_failures(): - """Only a MISSING payload is a failure; an odd empty shape is an answer. - - A server that spells "nothing matched" as `{"items": null}` (or omits the - key) must still cache the handle — classifying it as failure would re-buy - a ~2s recall on every later touch for the whole session. - """ - class _Res: - content = [] - - def __init__(self, sc, is_error=False): - self.structuredContent = sc - self.isError = is_error - - parse = dr._parse_recall_result - assert parse(_Res({"items": [{"id": "a"}]})) == [{"id": "a"}] - assert parse(_Res({"items": []})) == [] - assert parse(_Res({"items": None})) == [] # odd empty → answer - assert parse(_Res({"count": 0})) == [] # key absent → answer - assert parse(_Res({"result": {"items": []}})) == [] # FastMCP wrap - assert parse(_Res(None)) is None # no payload → failure - assert parse(_Res({"items": []}, is_error=True)) is None # isError → failure - - -def test_hit_injects_and_caches_the_handle(): - import tempfile - hook = {"tool_name": "Edit", "tool_input": {"file_path": "app/main.py"}, - "cwd": "/tmp", "session_id": "hit-test"} - d = _mk("z", ["app/main.py"]) - expected = dr._handle_key("Edit", {"file_path": "app/main.py"}, "/tmp") - with tempfile.TemporaryDirectory() as td: - rc, out, cache = _run_main(hook, [d], td) - assert rc == 0 and cache == [expected] - payload = json.loads(out)["hookSpecificOutput"] - assert payload["hookEventName"] == "PreToolUse" - assert "fired on: app/main.py" in payload["additionalContext"] - - -if __name__ == "__main__": - fails = 0 - for name, fn in sorted(globals().items()): - if name.startswith("test_") and callable(fn): - try: - fn() - print(f"PASS {name}") - except AssertionError as e: - fails += 1 - print(f"FAIL {name}: {e}") - raise SystemExit(1 if fails else 0) diff --git a/tests/documentation_test.py b/tests/documentation_test.py index cc1485c7..20710a72 100644 --- a/tests/documentation_test.py +++ b/tests/documentation_test.py @@ -137,7 +137,7 @@ def test_the_finder_maps_my_unlinked_prs() -> None: check(f"find-contributing-sessions says {text!r}", text in flat) frontmatter = skill.split("---", 2)[1] for spelling in ("mcp__plugin_memhub_memhub__list_my_unlinked_prs", - "mcp__plugin_memhub_memhub__list_my_unlinked_prs"): + "mcp__plugin_memhub-staging_memhub__list_my_unlinked_prs"): check(f"allowed-tools lists {spelling}", spelling in frontmatter) @@ -205,7 +205,15 @@ def test_create_rule_skill_keeps_its_authoring_gates() -> None: for text in ("### 4b.", "scratch worktree", "the mode the rule will ship with", "Never anchor a `command_rx` with `^`", "command position", - "worktree add -b", + # The sub-agent's worktree comes from the Agent tool, not from + # a `git worktree add` of our own: the claim is keyed on the + # hook payload's cwd, which an Agent-tool sub-agent inherits + # from the session — a hand-made worktree it merely "works + # inside" never becomes its cwd, and the private ledger ends + # the test with zero rows for every rule. + 'isolation: "worktree"', + ".claude/worktrees", + "0 rows for EVERY rule", # ENG-1107: the test arms its candidate in a PRIVATE base # claimed for its scratch worktree, never in the book every # other session on the machine reads. These three pin the @@ -231,6 +239,139 @@ def test_create_rule_skill_keeps_its_authoring_gates() -> None: check(f"create-rule keeps {text!r}", text in skill) + +def test_the_authoring_skills_send_the_rule_judge_its_four_fields() -> None: + """A rule filed without `when` / `do` / `why` arrives empty and the server's + rule judge falls back to its statement (rule-judge-spec §2). Both skills + author rules, so both must say the fields, where each is written, and that + they reach `create_rule` — on the person's path and on the silent one.""" + skills = ROOT / "plugins" / "memhub" / "skills" + create = (skills / "create-rule" / "SKILL.md").read_text(encoding="utf-8") + + def part(text: str, start: str, end: str) -> str: + check(f"the skill still has {start!r} … {end!r}", start in text and end in text[text.index(start):] + if start in text else False) + if start not in text: + return "" + return text[text.index(start):].split(end, 1)[0] + + pin = part(create, "### 1. Pin the rule sentence", "### 1b.") + for field, cap in (("`when`", "300 chars"), ("`do`", "400 chars"), ("`why`", "400 chars"), + ("`when_not`", "8 entries, 200 chars each")): + check(f"create-rule step 1 defines {field} with its cap ({cap})", + any(field in line and cap in line for line in pin.splitlines())) + check("…says `when` is a situation, never trigger vocabulary, with a good and a bad example", + "never trigger vocabulary" in pin and "Good:" in pin and "Bad:" in pin) + check("…says `when_not` is only for exclusions actually named, otherwise omitted", + "actually\n named" in pin and "Otherwise omit it" in pin) + check("…and that the statement and the fields say the same thing", + "nothing in one that the other lacks" in pin) + draft = part(create, "### 3. Draft the rule", "### 4. Prove it fires") + check("create-rule keeps the pattern broad and leaves the situation to `when` / `when_not`", + "The pattern stays broad; the judge decides fit" in draft and "wrong **shape**" in draft + and "wrong **situation**" in draft and "Do not\n narrow a pattern" in draft) + file_ = part(create, "### 5. Conflict check, confirm, then file", "### 6. Report") + check("create-rule shows the four fields in the preview the person approves", + all(label in file_ for label in ("When: ", "Do: ", "Why: None: + check("the surface this test pins is 34 tools", len(_MCP_SURFACE) == 34) + + +def test_shipped_files_name_no_removed_mcp_tool() -> None: + import re + plugin = ROOT / "plugins" / "memhub" + pat = re.compile(r"(? None: + import re + skills = ROOT / "plugins" / "memhub" / "skills" + for skill in sorted(skills.glob("*/SKILL.md")): + front = skill.read_text(encoding="utf-8").split("---", 2)[1] + line = next((ln for ln in front.splitlines() if ln.startswith("allowed-tools:")), "") + names = re.findall(r"mcp__plugin_(memhub|memhub-staging)_memhub__([a-z_]+)", line) + off = sorted({t for _, t in names if t not in _MCP_SURFACE}) + check(f"{skill.parent.name} allowed-tools name only real tools {off}", not off) + by_prefix = {p: {t for q, t in names if q == p} for p in ("memhub", "memhub-staging")} + check(f"{skill.parent.name} allowed-tools grant both prefixes alike", + by_prefix["memhub"] == by_prefix["memhub-staging"]) + + +def test_search_memory_skill_teaches_the_ladder() -> None: + skill = (ROOT / "plugins" / "memhub" / "skills" / "search-memory" + / "SKILL.md").read_text(encoding="utf-8") + flat = " ".join(skill.split()) + body = skill.split("---", 2)[2] + i_over, i_search, i_read = (body.find("`get_brain_overview(agent_brain_id)`"), + body.find("**Search for pointers.**"), + body.find("**Open the one you picked with `read_memory(id)`.**")) + check("search-memory: overview → search → read_memory, in that order", + -1 < i_over < i_search < i_read) + for text in ('`kind`: `"artifact"`', "section_id", "POINTERS only", + "**Episodes are never searched with `agent_brain_id`**", "all_brains=true", + "list_sessions"): + check(f"search-memory says {text!r}", text in flat) + check("search-memory offers no facts to search", '"facts"' not in skill and '"fact"' not in skill) + + if __name__ == "__main__": print("documentation") for name, fn in sorted(globals().copy().items()): diff --git a/tests/flush_session_test.py b/tests/flush_session_test.py index a8d5d4ce..caf97b7e 100644 --- a/tests/flush_session_test.py +++ b/tests/flush_session_test.py @@ -73,32 +73,22 @@ async def call_tool(self, name, arguments=None, timeout=None): "path": "agentic"}) -def send(result, room=None, title=None, namespace=None, index=1, total=1): +def send(result, title=None, namespace=None, index=1, total=1, budget=None): session = FakeSession(result) args = {"messages": [{"type": "user"}], "conversation_id": "s1", "source_platform": "claude"} - ok, _room = asyncio.run(fs._send(session, args, room, title, namespace, - index, total)) + ok = asyncio.run(fs._send(session, args, title, namespace, + index, total, budget=budget)) return ok, (session.sent[0][1] if session.sent else None) ok, args = send(OK) check("a bare send succeeds", ok is True) -check("no room means no brain id", "agent_brain_id" not in args) -check("no room means no org", "org_id" not in args) +# A session is captured into personal memory, never into a brain. +check("no brain id is ever sent", "agent_brain_id" not in args) +check("no org is ever sent", "org_id" not in args) check("nothing invents a title", "title" not in args) -# A brain resolves inside exactly ONE org. Sending the id without its org is -# how every capture into a non-default-org room failed with "Agent brain not -# found" — the bug this path had and the per-turn path had already fixed. -ok, args = send(OK, room={"brain_id": "b1", "org_id": "o1"}) -check("the room's brain id is sent", args.get("agent_brain_id") == "b1") -check("the org that OWNS the room rides along", args.get("org_id") == "o1") - -ok, args = send(OK, room={"brain_id": "b1"}) -check("an org-less room still routes", args.get("agent_brain_id") == "b1") -check("an org-less room sends no org", "org_id" not in args) - ok, args = send(OK, title="Fix the flush hook", namespace="memhub") check("the title is sent", args.get("title") == "Fix the flush hook") check("the namespace is sent", args.get("namespace") == "memhub") @@ -115,138 +105,21 @@ def send(result, room=None, title=None, namespace=None, index=1, total=1): check("an unrecognized body stops the run", ok is False) -# ── a room the backend does not have ────────────────────────────────── +# ── one call per slice ──────────────────────────────────────────────── # -# The hole this closes: routing degrades to long-term memory when NO room is -# cached, but that reads the CACHE, not the server. A cached id the backend has -# never heard of walked past that check and killed the whole backstop — which is -# exactly what a `production` entry holding a staging brain id did for days. - -class FakeSequenceSession: - """Replies with a different result per call, so a retry can be observed.""" - - def __init__(self, *results): - self.results = list(results) - self.sent = [] - self.timeouts = [] - - async def call_tool(self, name, arguments=None, timeout=None): - # Copy: `_send` mutates the caller's dict, so keeping the reference - # would make the first call's record show the SECOND call's arguments - # and the retry assertions below would pass vacuously. - self.sent.append((name, dict(arguments or {}))) - self.timeouts.append(timeout) - # Consume real wall clock, so "the retry is bounded by what is LEFT" - # stays observable where time.monotonic ticks coarsely (Windows on - # Python ≤3.12: GetTickCount64, 15.6ms) — a zero-cost reply would - # leave the remaining budget equal to the whole budget there. - await asyncio.sleep(0.05) - return self.results[min(len(self.sent), len(self.results)) - 1] - - -MISSING = FakeResult(texts=["Error executing tool import_conversation: " - "Agent brain not found"], is_error=True) - - -def send_seq(*results, room=None, budget=None, cwd="/repo"): - session = FakeSequenceSession(*results) - args = {"messages": [{"type": "user"}], "conversation_id": "s1", - "source_platform": "claude"} - forgot = [] - real, fs.forget_room = fs.forget_room, lambda cwd=None, env=None: ( - forgot.append((cwd, env)) or True) - try: - ok, room_after = asyncio.run( - fs._send(session, args, room, None, None, 1, 1, budget=budget, - cwd=cwd, env="production")) - finally: - fs.forget_room = real - return ok, room_after, session.sent, forgot, session.timeouts - - -ok, room_after, sent, forgot, timeouts = send_seq( - MISSING, OK, room={"brain_id": "b1", "org_id": "o1"}) -check("a missing brain does not fail the slice", ok is True) -check("a missing brain is retried", len(sent) == 2) -check("the retry drops the brain id", "agent_brain_id" not in sent[1][1]) -check("the retry drops the org too", "org_id" not in sent[1][1]) -check("the first attempt did carry the brain id", - sent[0][1].get("agent_brain_id") == "b1") -check("the dead room is forgotten", forgot == [("/repo", "production")]) -check("the caller is told the room is gone", room_after is None) - -# Only THIS error licenses forgetting a room. A rejected token or an unreachable -# server says nothing about whether the brain exists, and dropping a good cache -# entry over a transient outage would re-resolve — or mis-resolve — on the next -# turn for no reason. -ok, room_after, sent, forgot, timeouts = send_seq( - FakeResult(texts=["Not authenticated"], is_error=True), - OK, room={"brain_id": "b1"}) -check("an unrelated error is not retried", len(sent) == 1) -check("an unrelated error keeps the room", room_after == {"brain_id": "b1"}) -check("an unrelated error forgets nothing", forgot == []) -check("an unrelated error still stops the run", ok is False) - -# Both attempts failing is a real failure — the fallback is one retry, not a -# loop, and the caller must still stop rather than send later slices into a -# conversation whose first slice never landed. -ok, room_after, sent, forgot, timeouts = send_seq( - MISSING, FakeResult(texts=["still broken"], is_error=True), - room={"brain_id": "b1"}) -check("a failed fallback stops the run", ok is False) -check("a failed fallback still forgot the room", forgot != []) - -# The retry must be bounded by the time LEFT, not by the budget the slice -# started with. Closing over one `budget` value granted the second attempt the -# first attempt's time all over again — two sequential calls, each up to the -# full per-slice budget, overrunning the deadline that clamp exists to hold. -ok, room_after, sent, forgot, timeouts = send_seq( - MISSING, OK, room={"brain_id": "b1"}, budget=30.0) -check("the first attempt gets the whole budget", timeouts[0] == 30.0) -check("the retry is bounded by what is left", - timeouts[1] is not None and timeouts[1] < 30.0) - -# ...and below the floor a call cannot plausibly finish, so it is not made at -# all — the same doomed-call reasoning `_stop_before_slice` applies to slices. -# Skipping is cheap because the dead id is ALREADY forgotten: the next flush -# reaches long-term memory with a fresh budget. -ok, room_after, sent, forgot, timeouts = send_seq( - MISSING, OK, room={"brain_id": "b1"}, budget=0.0) -check("an exhausted budget makes no doomed retry", len(sent) == 1) -check("an exhausted budget still forgets the room", forgot != []) -check("an exhausted budget still reports the failure", ok is False) - -# ...and it reports the CLOCK, not the brain. Falling through to the isError -# branch would have breadcrumbed the original "Agent brain not found" — the one -# thing no longer true, since the id was just forgotten — and `capture_health` -# shows that breadcrumb to the user at SessionStart. They would go chasing a -# brain problem that had already fixed itself. -import json as _json # noqa: E402 — local to this assertion - -_crumb = _json.loads( - (Path(_TMP_HOME) / ".config" / "memhub-plugin" / "turnflush" - / "s1.sessionflush.json").read_text(encoding="utf-8")) -check("the breadcrumb names the budget, not the brain", - _crumb.get("last_error") == "budget_exhausted") -check("the detail does not blame the forgotten brain", - "Agent brain not found" not in (_crumb.get("last_error_detail") or "")) - -# An UNKNOWN origin must never touch the cache. `forget_room` keys on the repo -# at `cwd`, and `room_name(None)` falls back to the calling PROCESS's directory -# — a hook can fire from a different repo than the session's, so forgetting here -# could delete an unrelated repo's room. Unrouting still rescues the slice. -ok, room_after, sent, forgot, timeouts = send_seq( - MISSING, OK, room={"brain_id": "b1"}, cwd=None) -check("an unknown origin forgets nothing", forgot == []) -check("an unknown origin still re-sends unrouted", len(sent) == 2) -check("the unrouted re-send still drops the brain id", - "agent_brain_id" not in sent[1][1]) - -# An unbudgeted send (no deadline in play) must keep retrying as before. -ok, room_after, sent, forgot, timeouts = send_seq( - MISSING, OK, room={"brain_id": "b1"}) -check("an unbudgeted slice still retries", len(sent) == 2) -check("an unbudgeted retry stays unbounded", timeouts[1] is None) +# Nothing routes, so there is no "brain not found → re-send unrouted" retry: +# a rejected slice is one call and a stop, and the slice is bounded by the +# budget it was handed. + +import json as _json # noqa: E402 — used by the main() cases below + +_s = FakeSession(FakeResult(texts=["Error executing tool import_conversation: " + "Agent brain not found"], is_error=True)) +_ok = asyncio.run(fs._send(_s, {"messages": [], "conversation_id": "s1"}, + None, None, 1, 1, budget=30.0)) +check("a rejected slice is sent once", len(_s.sent) == 1) +check("a rejected slice stops the run", _ok is False) +check("the slice is bounded by its budget", _s.timeouts == [30.0]) ok, _ = send(FakeResult(texts=['{"conversation_id": "c1"}'])) check("a JSON text body is understood", ok is True) diff --git a/tests/flush_turn_test.py b/tests/flush_turn_test.py index 7dc5fea1..85f910ba 100644 --- a/tests/flush_turn_test.py +++ b/tests/flush_turn_test.py @@ -347,8 +347,6 @@ def test_pr_url_queue_survives_auth_failure_and_clears_on_ack(): "state_dir": ft.STATE_DIR, "namespace": ft._namespace, "resolve_bearer": ft.resolve_bearer, - "resolve_repo_brain": ft.resolve_repo_brain, - "env_for_url": ft.env_for_url, "session": ft.mcp_http.Session, "log": ft._log, } @@ -379,8 +377,6 @@ async def call_tool(self, _name, arguments): isError=False, ) - async def no_room(_session, _cwd, _env): - return None with tempfile.TemporaryDirectory() as tmp: transcript = Path(tmp) / "session.jsonl" @@ -398,8 +394,6 @@ async def no_room(_session, _cwd, _env): ]) ft.STATE_DIR = Path(tmp) / "state" ft._namespace = lambda _records: ("/repo", "agent-plugins") - ft.resolve_repo_brain = no_room - ft.env_for_url = lambda _url: "staging" ft._log = lambda _message: None try: ft.resolve_bearer = lambda: ("https://example.test/mcp", None) @@ -445,8 +439,6 @@ async def no_room(_session, _cwd, _env): ft.STATE_DIR = originals["state_dir"] ft._namespace = originals["namespace"] ft.resolve_bearer = originals["resolve_bearer"] - ft.resolve_repo_brain = originals["resolve_repo_brain"] - ft.env_for_url = originals["env_for_url"] ft.mcp_http.Session = originals["session"] ft._log = originals["log"] diff --git a/tests/harness_extract_test.py b/tests/harness_extract_test.py index 9fee5947..f6cd0bb6 100644 --- a/tests/harness_extract_test.py +++ b/tests/harness_extract_test.py @@ -1,18 +1,14 @@ #!/usr/bin/env python3 """Tests for the harness-tied memory client half (`harness_extract.py`). -What these protect: the router tells a person's turn from the harness's own -text; the window is redacted before it is sent; the classifier gets one -bounded call and every failure is "no signal" with a reason; a signal becomes -a stamped moment in a private file; nothing writes a rule. No test reaches a -server: `_api` is substituted. +What these protect: the transcript reader tells a person's turn from the +harness's own text; the window is redacted before it is sent; the classifier +gets one bounded call and every failure is "no signal" with a reason; nothing +writes a rule. No test reaches a server: `_api` is substituted. """ from __future__ import annotations import json -import os -import stat -import subprocess import sys import tempfile from pathlib import Path @@ -37,12 +33,9 @@ def _result(name, target, error, text): return {"tool": name, "target": target, "error": error, "text": text} -class _Trace(list): - def __call__(self, msg): - self.append(msg) -# ------------------------------------------------------------------ router +# -------------------------------------------------------------- transcript def test_harness_text_is_not_a_persons_turn_but_pasted_markup_is(): assert hx.is_harness_text("This session is being continued from a previous " "conversation that ran out of context. i mean staging") @@ -50,6 +43,9 @@ def test_harness_text_is_not_a_persons_turn_but_pasted_markup_is(): assert hx.is_harness_text("Base directory for this skill: /tmp/x") assert hx.is_harness_text("Caveat") assert hx.is_harness_text("[Request interrupted by user for tool use]") + assert hx.is_harness_text("Another Claude session sent a message:\n" + "[Subagent hand-back] The text below is the final report") + assert hx.is_harness_text("MemHub harness fork: 3 filed, 1 none, 0 failed") assert hx.is_harness_text("") # a person's prompt may begin with a heading or pasted markup assert not hx.is_harness_text("# Plan\nfix the flag test") @@ -76,50 +72,43 @@ def test_a_markdown_prompt_keeps_its_own_actions(): print("PASS test_a_markdown_prompt_keeps_its_own_actions") -def test_router_kinds_fire_on_their_shape(): - assert "reuse_correction" in dict(hx.route(_turn(user="don't we already have a helper?"), None)) - assert "retraction" in dict(hx.route(_turn(asst="I was wrong — it never merged."), None)) - with_receipt = _turn(asst="Fixed and all tests pass.", tools=[_tool("Bash", "uv run pytest")]) - assert "claim_no_receipt" not in dict(hx.route(with_receipt, None)) - without = _turn(asst="Fixed and all tests pass.", tools=[_tool("Bash", "echo hi")]) - hits = hx.route(without, None) - assert "claim_no_receipt" in dict(hits) and hx.router_hint(hits) == "" - both = hx.route(_turn(user="i mean staging", asst="Fixed.", tools=[_tool("Bash", "ls")]), None) - assert hx.router_hint(both) == "wrong_target" - print("PASS test_router_kinds_fire_on_their_shape") -def test_error_arcs_close_and_a_costly_one_routes(): +def test_error_arcs_close_on_a_later_success(): unclosed = _turn(results=[_result("Bash", "pytest x", True, "boom")]) assert hx.error_arcs(unclosed) == [] closed = _turn(results=[_result("Bash", "pytest x", True, "ModuleNotFoundError: No module named 'y'"), _result("Bash", "pytest x", False, "ok")]) arcs = hx.error_arcs(closed) assert len(arcs) == 1 and arcs[0]["cost"] == 1 - assert dict(hx.route(closed, None))["error_arc"] == "missing-module" - quiet = _turn(results=[_result("Bash", "make x", True, "new error"), - _result("Bash", "make x", False, "ok")]) - assert "error_arc" not in dict(hx.route(quiet, None)) - live = [{"signature": "new error", "target": "make x", "fix": "make x", "cost": 6}] - hits = hx.route(quiet, None, arcs=live) - assert dict(hits)["error_arc"] == "cost-6" - assert sum(1 for k, _ in hits if k == "error_arc") == 1, "one arc, one hit" - print("PASS test_error_arcs_close_and_a_costly_one_routes") + assert arcs[0]["signature"].startswith("ModuleNotFoundError") + print("PASS test_error_arcs_close_on_a_later_success") # ------------------------------------------------------------------ window def test_the_window_carries_its_slots_and_is_redacted(): - prev = _turn(1, user="do the thing", asst="done", tools=[_tool("Bash", f"cmd{i}") for i in range(6)]) + prev = _turn(1, user="do the thing", asst="done", + tools=[dict(_tool("Bash", f"cmd{i}"), id=f"p{i}") for i in range(14)], + results=[dict(_result("Bash", "cmd13", False, "13 passed\nline2\nline3\nline4"), id="p13")]) cur = _turn(2, user="no, on staging — see /Users/colleague/dev/x and mail x@y.io", asst="ran it with --token=abcdef123456 done", - tools=[_tool("Bash", f"new{i}") for i in range(9)], - results=[_result("Bash", "new0", True, "user_email=dana@example.com home=/home/dana"), - _result("Bash", "new0", False, "ok")]) - win = hx.build_window(cur, prev, {"repo": "R"}) + tools=[dict(_tool("Bash", f"new{i}"), id=f"n{i}") for i in range(14)], + results=[dict(_result("Bash", "new0", True, "user_email=dana@example.com home=/home/dana"), id="n0"), + dict(_result("Bash", "new0", False, "ok"), id="n1")]) + older = [_turn(-1, user="first ask"), _turn(0, user="second ask")] + win = hx.build_window(cur, prev, {"repo": "R"}, earlier=older, following="ok, merge it") + assert "EARLIER USER MESSAGES" in win and "first ask" in win and "second ask" in win assert "PREVIOUS USER MESSAGE: do the thing" in win - assert "cmd2" in win and "cmd1" not in win - assert "new5" in win and "new6" not in win + # the previous turn keeps its LAST 12 actions, this turn its FIRST 12 + assert "Bash: cmd2\n" in win and "Bash: cmd13\n" in win + assert "Bash: cmd0\n" not in win and "Bash: cmd1\n" not in win + assert "new11" in win and "new12" not in win + # a result rides with the call it answers, matched by id: success, error, first 3 lines only + assert "-> ok: 13 passed" in win and "line3" in win and "line4" not in win + assert "-> ERROR: user_email" in win assert "closed error arc" in win and "STATE:" in win + assert win.rstrip().endswith("USER'S FOLLOWING MESSAGE (what the person said next): ok, merge it") + assert "FOLLOWING MESSAGE" not in hx.build_window(cur, prev, {"repo": "R"}) red = hx.redact_window(win) for leak in ("colleague", "x@y.io", "dana@example.com", "/home/dana", "abcdef123456"): assert leak not in red, leak @@ -128,6 +117,22 @@ def test_the_window_carries_its_slots_and_is_redacted(): print("PASS test_the_window_carries_its_slots_and_is_redacted") +def test_the_largest_window_fits_the_server_limit(): + """Every slot at its cap still fits: the client cuts from the end, and the + end is the following message.""" + big = "x" * 5000 + def turn(n, tag): + return _turn(n, user=big, asst=big, + tools=[{"tool": "Bash", "brief": "Bash: " + "y" * 300, "target": f"t{i}", "id": f"{tag}{i}"} + for i in range(30)], + results=[{"tool": "Bash", "target": f"t{i}", "error": True, "text": big, "id": f"{tag}{i}"} + for i in range(30)]) + win = hx.build_window(turn(2, "c"), turn(1, "p"), {"repo": "R" * 200}, + earlier=[turn(-1, "a"), turn(0, "b")], following=big) + assert len(win) < hx.WINDOW_MAX_CHARS, len(win) + print("PASS test_the_largest_window_fits_the_server_limit") + + # -------------------------------------------------------------- classifier class _Reply: def __init__(self, data, status=200): @@ -155,14 +160,14 @@ def test_the_classifier_gets_one_bounded_post_and_never_raises(): http = _Http(answer=_Reply({"signal": False, "reason": "classified"})) real = _with_api(http) try: - reply, _ = hx.server_classify("W", hint="wrong_target", repo="R", timeout=7) + reply, _ = hx.server_classify("W", repo="R", timeout=7) finally: hx._api = real assert reply["signal"] is False and reply["reason"] == "classified" assert len(http.calls) == 1 call = http.calls[0] assert call["method"] == "POST" and call["url"] == "https://h/v1/team/rulebook/harness/classify" - assert call["body"] == {"window": "W", "hint": "wrong_target", "repo": "R"} + assert call["body"] == {"window": "W", "repo": "R"} assert call["timeout"] == 7 for http, expected in ( (_Http(raise_exc=RuntimeError("503")), "transport_error"), @@ -192,57 +197,10 @@ def test_the_classifier_gets_one_bounded_post_and_never_raises(): print("PASS test_the_classifier_gets_one_bounded_post_and_never_raises") -def _run(td, turn, prev, http): - out = Path(td) / "m.jsonl" - stats, trace = hx.new_stats(), _Trace() - real = _with_api(http) - try: - got = hx.extract_turn(turn, prev, session="sess", cwd="", repo="R", env_name="staging", - stats=stats, trace=trace, out_path=out, hook_version="0.54.0") - finally: - hx._api = real - return got, stats, trace, out -def test_a_signal_becomes_a_private_stamped_moment(): - with tempfile.TemporaryDirectory() as td: - http = _Http(answer=_Reply({"signal": True, "reason": "classified", - "kind": "correction", "derivable": True})) - prev = _turn(1, user="do it", asst="done") - turn = _turn(2, user="no, i mean staging — ping dana@example.com", asst="ok") - got, stats, trace, out = _run(td, turn, prev, http) - assert got and stats["moments"] == 1 and stats["turns_sent"] == 1 - assert http.calls[0]["body"]["hint"] == "wrong_target" - assert "dana@example.com" not in http.calls[0]["body"]["window"] - rows = hx.read_jsonl(out) - assert len(rows) == 1 - m = rows[0] - assert (m["turn"], m["source_ref"], m["kind"], m["hint"], m["derivable"]) == ( - 2, "sess#2", "correction", "wrong_target", True) - for key in ("repo", "session_id", "turn", "hook_version", "at"): - assert m["state"].get(key) not in (None, ""), key - assert m["state"]["repo"] == "R" and m["state"]["session_id"] == "sess" - assert "window" not in m, "the moment does not keep a copy of the session" - if os.name != "nt": - assert stat.S_IMODE(out.stat().st_mode) == 0o600 - # the trace names steps, never the prompt - assert not any("staging" in line or "dana" in line for line in trace) - print("PASS test_a_signal_becomes_a_private_stamped_moment") - - -def test_no_signal_an_outage_and_a_bare_claim_write_nothing(): - with tempfile.TemporaryDirectory() as td: - quiet = _Http(answer=_Reply({"signal": False, "reason": "classified"})) - got, stats, _, out = _run(td, _turn(1, user="thanks"), None, quiet) - assert got is None and stats["reason"] == "classified" and not out.exists() - down = _Http(raise_exc=OSError("connection refused")) - got, stats, _, out = _run(td, _turn(1, user="i mean staging"), None, down) - assert got is None and stats["transport_errors"] == 1 and not out.exists() - claim_only = _Http(answer=_Reply({"signal": True, "reason": "classified", "kind": "x"})) - got, stats, _, out = _run(td, _turn(1, user="ok", asst="Deployed.", - tools=[_tool("Bash", "ls")]), None, claim_only) - assert got is None and stats["turns_spared"] == 1 and claim_only.calls == [] - print("PASS test_no_signal_an_outage_and_a_bare_claim_write_nothing") + + # ------------------------------------------------------------------- stamp @@ -264,22 +222,6 @@ def test_the_stamp_never_guesses_a_repo_and_names_a_cross_repo_turn(): # ------------------------------------------------------------------- files -def test_session_files_are_confined_and_broken_lines_are_skipped(): - with tempfile.TemporaryDirectory() as td: - os.environ["MEMHUB_HARNESS_DIR"] = td - try: - assert hx.session_file("abc", ".meta.json") == Path(td) / "abc.meta.json" - assert hx.session_file("../../etc", ".x").parent == Path(td) - target = hx.session_file("abc", ".moments.jsonl") - hx.append_jsonl(target, {"turn": 1}) - with target.open("a") as fh: - fh.write("{broken\n") - hx.append_jsonl(target, {"turn": 2}) - assert [r["turn"] for r in hx.read_jsonl(target)] == [1, 2] - assert hx.read_jsonl(Path(td) / "missing.jsonl") == [] - finally: - del os.environ["MEMHUB_HARNESS_DIR"] - print("PASS test_session_files_are_confined_and_broken_lines_are_skipped") def test_the_flag_is_off_by_default_and_only_an_on_spelling_starts_it(): @@ -300,10 +242,10 @@ def test_the_flag_is_off_by_default_and_only_an_on_spelling_starts_it(): def test_an_authoring_child_is_never_sensed_whatever_the_flag_says(): - """`run_author` sets EXTRACT=0 for its child, but Claude Code applies a - settings.json `env` over what a process inherits. So an install that opted - in with EXTRACT=1 there re-armed the sensor in every child, and forks - spawned forks. The child flag is the switch that setting cannot reach.""" + """The case judge starts `claude` with MEMHUB_HARNESS_CHILD=1, and Claude + Code applies a settings.json `env` over what a process inherits — so an + install that opted in with EXTRACT=1 there would sense the judge's own + session. The child flag is the switch that setting cannot reach.""" for flag in ("1", "true", "yes"): for extract in ("", "1", "on"): env = {"MEMHUB_HARNESS_CHILD": flag, "MEMHUB_HARNESS_EXTRACT": extract} @@ -313,24 +255,6 @@ def test_an_authoring_child_is_never_sensed_whatever_the_flag_says(): print("PASS test_an_authoring_child_is_never_sensed_whatever_the_flag_says") -def test_spawn_detaches_and_returns(): - seen = {} - real = hx.subprocess.Popen - hx.subprocess.Popen = lambda args, **kw: seen.update(args=args, kw=kw) - try: - with tempfile.TemporaryDirectory() as td: - os.environ["MEMHUB_HARNESS_DIR"] = td - try: - assert hx.spawn_detached(["extract", "--session", "s"], - script=Path("/x/harness_stop.py"), log_name="stop.log") == 0 - finally: - del os.environ["MEMHUB_HARNESS_DIR"] - finally: - hx.subprocess.Popen = real - assert seen["args"][1:] == ["/x/harness_stop.py", "extract", "--session", "s"] - if os.name != "nt": - assert seen["kw"]["start_new_session"] is True - print("PASS test_spawn_detaches_and_returns") def test_nothing_in_the_client_writes_a_rule(): diff --git a/tests/harness_stop_test.py b/tests/harness_stop_test.py index 6ada37bf..2279dc0f 100644 --- a/tests/harness_stop_test.py +++ b/tests/harness_stop_test.py @@ -1,28 +1,28 @@ #!/usr/bin/env python3 """Tests for the harness-tied memory sensor (`harness_stop.py`). -What these protect: with the flag off nothing happens at all; with it on the -Stop hook returns at once and takes the turn's error arcs at the boundary; a -detached child classifies each turn exactly once; a failure and its fix are -one moment; the child classifies the turn that stopped even when the next -prompt has already landed; a flagged moment BLOCKS a later Stop of the main -agent once, with its stamp, the continuation's own Stop passes, and a moment -the child appends meanwhile is never lost; the recorded block never becomes a -turn; the proposal is scoped to the repository the turn worked in; subagents -never take a moment; nothing in the sensor sends `activate`. No test reaches a -server: `server_classify` is substituted. +What these protect (harness-tied-memory-spec §4.0–4.3.1): with the flag off +nothing happens at all; the Stop of turn N+1 judges turn N — never the turn +that just stopped, never a turn twice — and on a signal continues the agent +ONCE with one short line naming the `handoff` command, which prints a +launch prompt naming that one moment, its stamp command and turn N+1; a Stop +that is a launch's continuation, a subagent's, or the end of a harness prompt +(a task notification, a loop wakeup) judges nothing; an interrupted turn and +its verbatim resend are judged once; a classifier failure blocks nothing; the +`stamp` command prints the stamp `create_rule` needs; nothing is written but +`stop.log`; nothing in the sensor sends `activate`. No test reaches a server: +`server_classify` is substituted. """ from __future__ import annotations +import contextlib import importlib import io -import pathlib import json import os import subprocess import sys import tempfile -import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -48,16 +48,12 @@ def __enter__(self): os.environ["MEMHUB_HARNESS_EXTRACT"] = "1" os.environ["MEMHUB_RULEBOOK_FETCH"] = "0" os.environ["MEMHUB_RULEBOOK_RECALL"] = "0" - # the hook reads MEMHUB_RULEBOOK_BASE at import if "rulebook_hook" in sys.modules: importlib.reload(sys.modules["rulebook_hook"]) hx._HOOK_MODULE.clear() return self def __exit__(self, *exc): - for fh in hs._HELD: # never leak a lock into the next test - fh.close() - hs._HELD.clear() for k, v in self.saved.items(): if v is None: os.environ.pop(k, None) @@ -67,9 +63,15 @@ def __exit__(self, *exc): self.td.cleanup() -def _transcript(path: Path, turns): +def _records(path: Path, turns): + """Transcript records for (user, asst, tools) turns. `user` may also be a + dict: a raw record to append as it is (a notification, an isMeta body).""" recs, n = [], 0 - for user, asst, tools in turns: + for item in turns: + if isinstance(item, dict): + recs.append(item) + continue + user, asst, tools = item n += 1 recs.append({"type": "user", "uuid": f"u{n}", "cwd": str(path.parent), "message": {"content": user}}) @@ -80,661 +82,279 @@ def _transcript(path: Path, turns): recs.append({"type": "user", "message": {"content": [ {"type": "tool_result", "tool_use_id": tid, "content": out, "is_error": err}]}}) recs.append({"type": "assistant", "message": {"content": [{"type": "text", "text": asst}]}}) - path.write_text("\n".join(json.dumps(r) for r in recs) + "\n", encoding="utf-8") + return recs -def _git_repo(td: Path, name: str = "repo") -> Path: - repo = td / name - repo.mkdir() - env = dict(os.environ, GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@x", GIT_COMMITTER_NAME="t", - GIT_COMMITTER_EMAIL="t@x", HOME=str(td), GIT_CONFIG_NOSYSTEM="1") - for args in (["init", "-q"], ["commit", "-q", "--allow-empty", "-m", "x"]): - subprocess.run(["git", "-C", str(repo), *args], check=True, env=env, capture_output=True) - return repo +def _transcript(path: Path, turns): + path.write_text("\n".join(json.dumps(r) for r in _records(path, turns)) + "\n", + encoding="utf-8") -def _classify(calls, reply): - return lambda w, hint="", repo="", timeout=0: ( - calls.append({"window": w, "hint": hint}), (reply, 0.1))[1] +def _moment(turn: int, **extra) -> dict: + m = {"source_ref": f"sess#{turn}", "turn": turn, "kind": "correction", + "derivable": False, "state": {"repo": "repo"}, + "transcript": "/t/sess.jsonl", "cwd": "/w"} + m.update(extra) + return m -def _spawns(fn): - seen = [] - real = hx.subprocess.Popen - hx.subprocess.Popen = lambda args, **kw: seen.append((args, kw)) +@contextlib.contextmanager +def _classifier(reply): + """Substitute the one server call; yields the windows it was sent.""" + sent = [] + real = hx.server_classify + hx.server_classify = lambda window, repo="", timeout=0: (sent.append(window), (reply, 0.1))[1] try: - fn() + yield sent finally: - hx.subprocess.Popen = real - return seen + hx.server_classify = real + + +def _stop(env, tp: Path, **payload) -> dict | None: + """Run cmd_stop in-process; the hook output it printed, or None.""" + body = {"session_id": "sess", "transcript_path": str(tp), "cwd": str(env.base)} + body.update(payload) + out = io.StringIO() + with contextlib.redirect_stdout(out): + assert hs.cmd_stop(body) == 0 + text = out.getvalue().strip() + return json.loads(text) if text else None + + +SIGNAL = {"signal": True, "reason": "classified", "kind": "correction", "derivable": False} # ---------------------------------------------------------------- the flag def test_with_the_flag_off_nothing_happens(): with _Env() as env: - os.environ["MEMHUB_HARNESS_EXTRACT"] = "0" tp = env.base / "s.jsonl" - _transcript(tp, [("i mean staging", "ok", [])]) - for mode in ("stop", "extract"): - proc = subprocess.run( - [sys.executable, str(SCRIPTS / "harness_stop.py"), mode, - "--session", "s", "--transcript", str(tp)], - input=json.dumps({"session_id": "s", "transcript_path": str(tp), "cwd": str(ROOT)}), - capture_output=True, text=True, timeout=30, - env=dict(os.environ, MEMHUB_HARNESS_EXTRACT="0")) - assert proc.returncode == 0 and proc.stdout == "", (mode, proc.stdout) + _transcript(tp, [("i mean staging", "ok", []), ("thanks", "ok", [])]) + proc = subprocess.run( + [sys.executable, str(SCRIPTS / "harness_stop.py"), "stop"], + input=json.dumps({"session_id": "s", "transcript_path": str(tp), "cwd": str(ROOT)}), + capture_output=True, text=True, timeout=30, + env=dict(os.environ, MEMHUB_HARNESS_EXTRACT="0")) + assert proc.returncode == 0 and proc.stdout == "", proc.stdout assert not (env.base / "harness").exists() print("PASS test_with_the_flag_off_nothing_happens") -# --------------------------------------------------------------- stop lane -def test_stop_spawns_one_detached_child_and_returns(): +# ---------------------------------------------------------- one turn late +def test_the_stop_after_a_turn_judges_that_turn_and_blocks_once(): with _Env() as env: - tp = env.base / "s.jsonl" - tp.write_text("", encoding="utf-8") - t0 = time.time() - seen = _spawns(lambda: hs.cmd_stop({"session_id": "sess", "transcript_path": str(tp), - "cwd": str(ROOT)})) - assert time.time() - t0 < 0.5 - assert len(seen) == 1 - args, kw = seen[0] - assert args[1].endswith("harness_stop.py") and args[2] == "extract" - assert args[args.index("--session") + 1] == "sess" - assert args[args.index("--upto") + 1] == "0", "the boundary is the size at Stop" - assert "--arcs" not in args, "no arcs recorded, none passed" - if hasattr(os, "setsid"): - assert kw["start_new_session"] is True - # a re-entered Stop, a subagent's Stop, and a missing transcript: nothing - for payload in ({"session_id": "sess", "transcript_path": str(tp), "stop_hook_active": True}, - {"session_id": "sess", "transcript_path": str(tp), "agent_id": "agent-7f"}, - {"session_id": "sess", "transcript_path": "/nope"}, - {"session_id": "", "transcript_path": str(tp)}): - assert _spawns(lambda: hs.cmd_stop(payload)) == [], payload - print("PASS test_stop_spawns_one_detached_child_and_returns") - - -def _post(repo: Path, session: str, cmd: str, resp: dict, agent_id: str = ""): - payload = {"session_id": session, "cwd": str(repo), "tool_name": "Bash", - "hook_event_name": "PostToolUse", "tool_input": {"command": cmd}, - "tool_response": resp} - if agent_id: - payload["agent_id"] = agent_id - proc = subprocess.run([sys.executable, str(SCRIPTS / "rulebook_hook.py"), "post"], - input=json.dumps(payload), capture_output=True, text=True, - timeout=30, env=dict(os.environ)) - assert proc.returncode == 0, proc.stderr - - -def test_a_failure_and_its_fix_are_one_moment_taken_at_the_boundary(): + tp = env.base / "sess.jsonl" + _transcript(tp, [("deploy it", "deployed to prod", []), + ("no, i mean staging", "redeployed to staging", []), + ("thanks, now the tests", "tests pass", [])]) + with _classifier(SIGNAL) as sent: + block = _stop(env, tp) + assert len(sent) == 1, "one classifier call per Stop" + assert "USER'S NEW MESSAGE: no, i mean staging" in sent[0], sent[0] + assert "PREVIOUS USER MESSAGE: deploy it" in sent[0], "turn N-1 is the window's context" + # turn N+1 is not judged yet: its message rides along only as what the + # person said next, and its reply is not in the window at all + assert sent[0].rstrip().endswith( + "USER'S FOLLOWING MESSAGE (what the person said next): thanks, now the tests"), sent[0] + assert sent[0].count("thanks, now the tests") == 1 and "tests pass" not in sent[0] + # model-only context, never a block: a block's reason is printed to the person + assert "decision" not in block and "reason" not in block, block + out = block["hookSpecificOutput"] + assert out["hookEventName"] == "Stop", out + # Claude Code prints a Stop hook's context to the person in full, so it + # is ONE line written for them and nothing else: no command, no + # instruction, no prompt. What the agent does with it travels at + # SessionStart, where it is not shown. + line = out["additionalContext"] + assert line == f"{hs.HANDOFF_PREFIX} 2, drafting it in the background.", line + # the verdict the line leaves out is in the log, where `handoff` reads it + log = (env.base / "harness" / "stop.log").read_text() + assert "launch sess t2: fork requested (correction) derivable=0" in log, log + # nothing is stored but the log + assert sorted(p.name for p in (env.base / "harness").iterdir()) == ["stop.log"] + print("PASS test_the_stop_after_a_turn_judges_that_turn_and_blocks_once") + + +def test_no_signal_or_a_failed_call_blocks_nothing(): with _Env() as env: - repo = _git_repo(env.base) - import rulebook_hook as rh # noqa: PLC0415 - _post(repo, "arc", "pytest tests/x.py", - {"stdout": "", "stderr": "ModuleNotFoundError: No module named 'y'", "exit_code": 1}) - _post(repo, "arc", "uv pip install y", {"stdout": "ok", "exit_code": 0}) - _post(repo, "arc", "pytest tests/x.py", {"stdout": "1 passed", "exit_code": 0}) - _post(repo, "arc", "make x", {"stdout": "", "stderr": "boom", "exit_code": 1}) - if os.name != "nt": - import stat # noqa: PLC0415 - mode = stat.S_IMODE(os.stat(rh.arcs_path("arc")).st_mode) - assert mode == 0o600, f"a failed command's text is private, got {oct(mode)}" - # the session state the rulebook merges by delta is not where arcs live - assert "arcs" not in json.dumps(rh.load_state(rh.state_path("arc"))) - tp = env.base / "s.jsonl" - tp.write_text("", encoding="utf-8") - seen = _spawns(lambda: hs.cmd_stop({"session_id": "arc", "transcript_path": str(tp)})) - args = seen[0][0] - arcs_file = Path(args[args.index("--arcs") + 1]) - arcs = json.loads(arcs_file.read_text()) - assert len(arcs) == 1 and arcs[0]["target"] == "pytest tests/x.py" - assert "ModuleNotFoundError" in arcs[0]["signature"] and arcs[0]["cost"] == 2 - # taken once: the open failure is cleared with it, nothing pairs across turns - assert rh.take_error_arcs("arc") == [] - _post(repo, "arc", "make x", {"stdout": "ok", "exit_code": 0}) - assert rh.take_error_arcs("arc") == [] - # a subagent's failure and fix are its own: its Stop is ignored, so the - # main agent's next Stop must not take them - _post(repo, "arc", "pytest tests/y.py", {"stdout": "", "stderr": "E boom", "exit_code": 1}, - agent_id="agent-7f") - _post(repo, "arc", "pytest tests/y.py", {"stdout": "1 passed", "exit_code": 0}, agent_id="agent-7f") - assert rh.take_error_arcs("arc") == [], "subagent arcs never reach the main turn" - # the router sees the arc on a turn whose transcript shows no error - hits = hx.route({"n": 1, "user": "ok", "asst": "done", "tools": [], "results": []}, None, arcs=arcs) - assert dict(hits)["error_arc"] == "missing-module" - # a blocked continuation's arcs are drained at its own Stop, which spawns - # nothing: the next ordinary turn must not inherit them (Codex, #230) - _post(repo, "arc", "make y", {"stdout": "", "stderr": "E boom", "exit_code": 1}) - _post(repo, "arc", "make y", {"stdout": "ok", "exit_code": 0}) - assert _spawns(lambda: hs.cmd_stop({"session_id": "arc", "transcript_path": str(tp), - "stop_hook_active": True})) == [] - assert rh.take_error_arcs("arc") == [], "the continuation's arcs were drained" - # with the flag off the hook records nothing - os.environ["MEMHUB_HARNESS_EXTRACT"] = "0" - _post(repo, "off", "pytest", {"stdout": "", "stderr": "E", "exit_code": 1}) - assert not os.path.exists(rh.arcs_path("off")) - print("PASS test_a_failure_and_its_fix_are_one_moment_taken_at_the_boundary") - - -def test_extract_classifies_the_last_turn_once_and_consumes_its_arcs(): + tp = env.base / "sess.jsonl" + _transcript(tp, [("a", "b", []), ("c", "d", [])]) + for reply in ({"signal": False, "reason": "classified"}, + {"signal": False, "reason": "transport_error"}): + with _classifier(reply) as sent: + assert _stop(env, tp) is None + assert len(sent) == 1 + log = (env.base / "harness" / "stop.log").read_text() + assert "reason=transport_error" in log, "an outage is logged, never read as quiet" + # the window's shape is logged with each verdict: size, results, next message + assert " results=0 next=yes" in log and " window=" in log, log + print("PASS test_no_signal_or_a_failed_call_blocks_nothing") + + +def test_stops_that_do_not_end_a_persons_turn_judge_nothing(): with _Env() as env: - repo = _git_repo(env.base) - tp = env.base / "s.jsonl" - _transcript(tp, [("do the thing", "done", [("Bash", {"command": "ls"}, "a", False)]), - ("no, i mean on staging", "right", [("Bash", {"command": "git status"}, "clean", False)])]) - arcs_file = hx.session_file("sess", ".arcs-1.json") - arcs_file.parent.mkdir(parents=True, exist_ok=True) - arcs_file.write_text(json.dumps([{"signature": "boom", "target": "make", "fix": "make", "cost": 6}])) - calls = [] - real = hx.server_classify - hx.server_classify = _classify(calls, {"signal": True, "reason": "classified", - "kind": "correction", "derivable": True}) - try: - assert hs.cmd_extract("sess", str(tp), str(repo), str(arcs_file)) == 0 - # a second Stop for the same turn, or a racing child: no second call - assert hs.cmd_extract("sess", str(tp), str(repo)) == 0 - finally: - hx.server_classify = real - assert len(calls) == 1 and calls[0]["hint"] == "wrong_target" - assert "closed error arc on 'make'" in calls[0]["window"] - assert not arcs_file.exists(), "the arcs file is consumed" - moments = hx.read_jsonl(hs.moments_path("sess")) - assert len(moments) == 1 and moments[0]["source_ref"] == "sess#2" - assert moments[0]["derivable"] is True - state = moments[0]["state"] - assert state["repo"] == "repo" and state["turn"] == 2 and state["branch"] and state["head_sha"] - assert hs.load_meta("sess")["last_turn"] == 2 - # the next turn is its own claim; an outage records nothing - _transcript(tp, [("do the thing", "done", []), ("no, i mean on staging", "right", []), - ("thanks", "np", [])]) - hx.server_classify = _classify(calls, {"signal": False, "reason": "judge_failed"}) - try: - hs.cmd_extract("sess", str(tp), str(repo)) - finally: - hx.server_classify = real - assert len(calls) == 2 and len(hx.read_jsonl(hs.moments_path("sess"))) == 1 - claims = list(hx.harness_dir().glob("sess.turn-*.claim")) - assert len(claims) == 1, "older claims are cleared" - print("PASS test_extract_classifies_the_last_turn_once_and_consumes_its_arcs") - - -def test_extract_takes_the_turn_that_stopped_not_the_prompt_queued_after_it(): + tp = env.base / "sess.jsonl" + two = [("fix it", "fixed", []), ("i mean the other file", "done", [])] + with _classifier(SIGNAL) as sent: + _transcript(tp, two[:1]) + assert _stop(env, tp) is None, "one turn has no turn before it" + _transcript(tp, two) + assert _stop(env, tp, stop_hook_active=True) is None, "the launch's continuation" + assert _stop(env, tp, agent_id="a1") is None, "a subagent's Stop" + # the fork's completion notice, then the agent's reply to it: a new + # Stop whose prompt is the harness's, so turn 1 is NOT judged again + _transcript(tp, two + [ + {"type": "user", "uuid": "n1", "message": {"content": + "MemHub harness fork: filed T"}}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "noted"}]}}]) + assert _stop(env, tp) is None + assert sent == [], sent + print("PASS test_stops_that_do_not_end_a_persons_turn_judge_nothing") + + +def test_an_interrupted_turn_and_its_resend_are_judged_once(): with _Env() as env: - repo = _git_repo(env.base) - tp = env.base / "s.jsonl" - _transcript(tp, [("do the thing", "done", []), - ("no, i mean on staging", "right", [("Bash", {"command": "git status"}, "clean", False)])]) - upto = tp.stat().st_size # Stop fired here - with tp.open("a", encoding="utf-8") as fh: - # the stopped turn's last words flush late, then a queued prompt lands, - # all before the detached child opens the file - fh.write(json.dumps({"type": "assistant", "message": {"content": [ - {"type": "text", "text": "late reply"}]}}) + "\n") - fh.write(json.dumps({"type": "user", "uuid": "u3", "cwd": str(repo), - "message": {"content": "now deploy it"}}) + "\n") - calls = [] - real = hx.server_classify - hx.server_classify = _classify(calls, {"signal": True, "reason": "classified", - "kind": "correction"}) - try: - hs.cmd_extract("sess", str(tp), str(repo), "", upto) - assert len(calls) == 1 and "i mean on staging" in calls[0]["window"] - assert "late reply" in calls[0]["window"] and "now deploy it" not in calls[0]["window"] - assert [m["turn"] for m in hx.read_jsonl(hs.moments_path("sess"))] == [2] - # the next turn's own Stop is not blocked by a claim taken for it early - with tp.open("a", encoding="utf-8") as fh: - fh.write(json.dumps({"type": "assistant", "message": {"content": [ - {"type": "text", "text": "starting on it"}]}}) + "\n") - hs.cmd_extract("sess", str(tp), str(repo), "", tp.stat().st_size) - assert len(calls) == 2 and "now deploy it" in calls[1]["window"] - finally: - hx.server_classify = real - assert hs.load_meta("sess")["last_turn"] == 3 - print("PASS test_extract_takes_the_turn_that_stopped_not_the_prompt_queued_after_it") - - -# ------------------------------------------------------------- the handoff -def _ago(days: float) -> str: - """A stamp `days` before now. A fixed date ages past `MOMENT_TTL_S` and - `pending()` silently drops the moment — the fixture broke CI on 2026-09-24.""" - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - days * 86400)) - - -def _moment(n, kind="correction", hint="wrong_target", session="sess"): - return {"turn": n, "source_ref": f"{session}#{n}", "hint": hint, "kind": kind, - "state": {"repo": "repo", "session_id": session, "turn": n, "hook_version": "0.54.0", - "at": _ago(1), "branch": "b", "head_sha": "abc", - "pr_number": None, "env": "staging"}} - - -def _stop(**payload): - """The Stop hook end to end through `main`, the child's spawn substituted.""" - tp = hx.harness_dir().parent / "stop.jsonl" - if not tp.exists(): - tp.write_text("", encoding="utf-8") - payload = dict({"session_id": "sess", "transcript_path": str(tp)}, **payload) - out, real_stdout, real_stdin = io.StringIO(), sys.stdout, sys.stdin - sys.stdout, sys.stdin = out, io.StringIO(json.dumps(payload)) - real_popen = hx.subprocess.Popen - hx.subprocess.Popen = lambda args, **kw: None - try: - rc = hs.main(["stop"]) - finally: - sys.stdout, sys.stdin = real_stdout, real_stdin - hx.subprocess.Popen = real_popen - return rc, out.getvalue() - - -def _authored(**payload): - """The refs a Stop handed to a detached author pass, in order.""" - seen = [] - real = hx.spawn_detached - hx.spawn_detached = lambda argv, **kw: seen.append(list(argv)) - try: - out = _stop(**payload)[1] - finally: - hx.spawn_detached = real - for argv in seen: - if argv and argv[0] == "author": - refs = argv[argv.index("--refs") + 1] - return [r for r in refs.split(",") if r], out - return [], out - - -def _reason(out: str) -> str: - doc = json.loads(out) - assert doc["decision"] == "block", doc # top-level, verified live on Claude Code 2.1.270 - return doc["reason"] - - -def test_a_later_stop_authors_the_moment_once(): - with _Env(): - hs.save_meta("sess", repo="repo", last_turn=2) - hx.append_jsonl(hs.moments_path("sess"), _moment(2)) - # a subagent's Stop, carrying the parent session id, takes nothing; nor - # does the continuation's own Stop - assert _authored(agent_id="agent-7f")[0] == [] - assert _authored(stop_hook_active=True)[0] == [] - assert len(hx.read_jsonl(hs.moments_path("sess"))) == 1, "nothing taken" - - refs, out = _authored() - assert refs == ["sess#2"], refs - assert out == "", "a drain says nothing to the person; only an outcome does" - - # the claim is what makes it once: a second Stop while the pass holds it - # spawns nothing, and the pass picks up anything parked meanwhile - assert _authored()[0] == [], "one drain per session at a time" - _drain_finished("sess") - assert _authored()[0] == ["sess#2"], "still waiting: nothing DECIDED it yet" - print("PASS test_a_later_stop_authors_the_moment_once") - -def test_a_fast_child_never_makes_the_drain_take_the_stopping_turn(): - """Selection happens before the extract child is spawned. A child that wins - the race and appends THIS turn's moment at once must not be authored by the - Stop that is still running (Codex, #230) — it is the next Stop's to take.""" - with _Env(): - hs.save_meta("sess", repo="repo", last_turn=2) - hx.append_jsonl(hs.moments_path("sess"), _moment(2)) - tp = hx.harness_dir().parent / "race.jsonl" - tp.write_text("", encoding="utf-8") - seen, real = [], hx.spawn_detached - - def spawn(argv, **kw): - seen.append(list(argv)) - if argv and argv[0] != "author": - hx.append_jsonl(hs.moments_path("sess"), _moment(3)) # the race - - hx.spawn_detached = spawn - try: - hs.cmd_stop({"session_id": "sess", "transcript_path": str(tp)}) - finally: - hx.spawn_detached = real - authored = [a for a in seen if a and a[0] == "author"] - assert authored, seen - assert authored[0][authored[0].index("--refs") + 1] == "sess#2" - print("PASS test_a_fast_child_never_makes_the_drain_take_the_stopping_turn") - - -def _drain_finished(session): - """What cmd_author's `finally` does: release the session claim AND the - machine-wide slot. A test that frees only the claim leaves the slot held - and trips the breaker on its third spawn.""" - hs.hx.session_file(session, ".drain.claim").unlink(missing_ok=True) - for slot in hx.harness_dir().glob("drain.slot-*"): - slot.unlink(missing_ok=True) - -def test_the_ttl_and_the_repo_scope_bound_what_a_drain_takes(): - """The per-session cap and the 3-turn window are GONE with the block: they - rationed interruptions, and a detached pass interrupts nobody. What bounds - a drain now is the reviewer's budget, the TTL, and the repo.""" - with _Env(): - assert not hasattr(hs, "HANDOFF_CAP_PER_SESSION") - assert not hasattr(hs, "HANDOFF_MAX_AGE_TURNS") - hs.save_meta("sess", repo="repo", last_turn=99) - # an old moment is no longer skipped for being old in TURNS - hx.append_jsonl(hs.moments_path("sess"), _moment(2)) - assert _authored()[0] == ["sess#2"], "turn age no longer strands a moment" - _drain_finished("sess") - - stale = _moment(3) - stale["state"] = dict(stale["state"], at="2020-01-01T00:00:00Z") - hx.append_jsonl(hs.moments_path("sess"), stale) - elsewhere = _moment(4) - elsewhere["state"] = dict(elsewhere["state"], repo="another-repo") - hx.append_jsonl(hs.moments_path("sess"), elsewhere) - refs, _ = _authored() - assert "sess#3" not in refs and "sess#4" not in refs, refs - - # more than the reviewer's budget: taken in batches, never dropped - _drain_finished("sess") - for turn in range(10, 10 + hs.FILING_BUDGET_PER_PASS + 3): - hx.append_jsonl(hs.moments_path("sess"), _moment(turn)) - refs, _ = _authored() - assert len(refs) == hs.FILING_BUDGET_PER_PASS, refs - - assert _authored(session_id="")[0] == [] - assert _authored(session_id="nobody")[0] == [], "no meta, no repo, no drain" - print("PASS test_the_ttl_and_the_repo_scope_bound_what_a_drain_takes") - -def test_a_moment_the_child_appends_while_a_drain_is_chosen_is_kept(): - with _Env(): - path = hs.moments_path("sess") - hs.save_meta("sess", repo="repo", last_turn=2) - hx.append_jsonl(path, _moment(2)) - real = hx.read_jsonl - - def read_then_the_child_appends(p): - rows = real(p) - hx.append_jsonl(p, _moment(3)) # the extract child lands in the gap - return rows - - hx.read_jsonl = read_then_the_child_appends - try: - _authored() - finally: - hx.read_jsonl = real - keys = [r.get("source_ref") for r in real(path) if r.get("turn") is not None] - assert "sess#3" in keys, "the moment that landed in the gap is still there" - print("PASS test_a_moment_the_child_appends_while_a_drain_is_chosen_is_kept") - -def test_the_line_names_where_the_turn_worked_and_leaves_the_scope_to_the_author(): + tp = env.base / "sess.jsonl" + turns = [("setup", "ok", []), ("stop patching, fix it properly", "", []), + ("stop patching, fix it properly", "fixed properly", [])] + _transcript(tp, turns) + with _classifier(SIGNAL) as sent: + assert _stop(env, tp) is None, "turn 2 is resent as turn 3" + assert sent == [] + _transcript(tp, turns + [("thanks", "ok", [])]) + with _classifier(SIGNAL) as sent: + block = _stop(env, tp) + assert block and f"{hs.HANDOFF_PREFIX} 3," in block["hookSpecificOutput"]["additionalContext"], block + print("PASS test_an_interrupted_turn_and_its_resend_are_judged_once") + + +def test_a_skill_body_is_neither_a_turn_nor_a_prompt(): + """Claude Code records a skill's body as an isMeta user record after the + Skill call. Read as the person's message it was 3% of all flags in the + 2026-09-29 replay; read as a prompt, it would stop the next Stop judging.""" with _Env() as env: - alpha, beta = _git_repo(env.base, "alpha"), _git_repo(env.base, "beta") - kw = {"session": "s", "cwd": str(alpha), "hook_version": "0.54.0", "env_name": "staging"} - # a session rooted in alpha whose turn worked only in beta - only_beta = {"n": 1, "tools": [{"tool": "Bash", "target": f"cd {beta} && make test"}]} - state = hx.stamp_state(turn=only_beta, **kw) - assert state["repo"] == "beta" and "touched_repos" not in state, state - line = hs.block_reason("s", {"turn": 1, "state": state}, "alpha") - assert 'worked in ["beta"]' in line, line - # evidence, not the scope: the author chooses scope_repos from the lesson - assert "scope_repos=" not in line and "choose scope_repos from the lesson" in line, line - # `git -C ` points a command at another repository as a leading `cd` does - for target in (f"git -C {beta} status", f"cd {alpha} && git -C ../beta log -1", - f"GIT_PAGER=cat git -c core.pager=cat -C {beta} diff"): - got = hx.stamp_state(turn={"n": 4, "tools": [{"tool": "Bash", "target": target}]}, **kw) - assert got["repo"] == "beta", (target, got) - # a turn that worked in both names both - both = {"n": 2, "tools": [{"tool": "Bash", "target": f"cd {beta} && make test"}, - {"tool": "Edit", "target": str(alpha / "x.py")}]} - state = hx.stamp_state(turn=both, **kw) - assert state["repo"] == "alpha" and state["touched_repos"] == ["beta", "alpha"], state - line = hs.block_reason("s", {"turn": 2, "state": state}, "alpha") - assert 'worked in ["beta", "alpha"]' in line and "scope_repos=" not in line, line - # an action that addresses nothing leaves the session's own repo - idle = {"n": 3, "tools": [{"tool": "TodoWrite", "target": ""}]} - assert hx.stamp_state(turn=idle, **kw)["repo"] == "alpha" - assert hs.block_reason("s", {"turn": 3, "state": {}}, "alpha").count('worked in ["alpha"]') == 1 - print("PASS test_the_line_names_where_the_turn_worked_and_leaves_the_scope_to_the_author") - - -def test_each_stop_reads_from_the_cursor_not_from_byte_zero(): + tp = env.base / "sess.jsonl" + _transcript(tp, [("use the skill", "running", []), + {"type": "user", "isMeta": True, "uuid": "m1", + "message": {"content": "## Page contract \u2014 read before your first publish"}}, + ("no, the other one", "ok", [])]) + turns, last = hx.read_transcript(tp) + assert [t["user"] for t in turns] == ["use the skill", "no, the other one"], turns + assert last == turns[-1]["uuid"] + with _classifier(SIGNAL) as sent: + assert _stop(env, tp) is not None + assert "USER'S NEW MESSAGE: use the skill" in sent[0] + print("PASS test_a_skill_body_is_neither_a_turn_nor_a_prompt") + + +def test_the_handoff_command_prints_the_fork_instruction(): + """The command the Stop's one line names. It finds the session's transcript + under the host's config dir, rebuilds the moment, and prints the launch + instruction; it is the agent's tool, so it runs with the flag off and is + loud when it cannot answer.""" with _Env() as env: - repo = _git_repo(env.base) - tp = env.base / "s.jsonl" - steps = [(f"step {i}", f"did {i}", [("Bash", {"command": f"echo {i}"}, "ok", False)]) - for i in range(1, 5)] - calls, starts = [], [] - real_classify, real_read = hx.server_classify, hx.turns_from_transcript - - def spy(path, start=0, before=0): - starts.append(start) - return real_read(path, start=start, before=before) - - hx.server_classify = _classify(calls, {"signal": False, "reason": "classified"}) - hx.turns_from_transcript = spy - try: - _transcript(tp, steps[:3]) - hs.cmd_extract("sess", str(tp), str(repo), "", tp.stat().st_size) - assert starts == [0], starts - assert hs.load_meta("sess")["scan"]["uuid"] == "u2" - _transcript(tp, steps[:4]) - hs.cmd_extract("sess", str(tp), str(repo), "", tp.stat().st_size) - assert len(starts) == 2 and starts[1] > 0, "the second Stop resumes from the cursor" - assert "PREVIOUS USER MESSAGE: step 3" in calls[-1]["window"] - assert "USER'S NEW MESSAGE: step 4" in calls[-1]["window"] - assert hs.load_meta("sess")["last_turn"] == 4, "numbered as a full read would" - # a transcript replaced under the cursor falls back to a full read - _transcript(tp, [("other", "x", [])] + steps[:4]) - hs.cmd_extract("sess", str(tp), str(repo), "", tp.stat().st_size) - assert starts[-1] == 0 and hs.load_meta("sess")["last_turn"] == 5, starts - finally: - hx.server_classify, hx.turns_from_transcript = real_classify, real_read - print("PASS test_each_stop_reads_from_the_cursor_not_from_byte_zero") - - -def test_the_newest_moment_is_authored_first_whatever_order_children_finished_in(): - with _Env(): - hs.save_meta("sess", repo="repo", last_turn=5) - newer = _moment(5) # turn 5's classifier answered first - newer["state"] = dict(newer["state"], at=_ago(1)) - hx.append_jsonl(hs.moments_path("sess"), newer) - older = _moment(4) - older["state"] = dict(older["state"], at=_ago(2)) - hx.append_jsonl(hs.moments_path("sess"), older) - refs, _ = _authored() - assert refs == ["sess#5", "sess#4"], refs - print("PASS test_the_newest_moment_is_authored_first_whatever_order_children_finished_in") - -def test_an_older_turns_child_never_moves_the_meta_back(): - with _Env(): - hs.save_meta("sess", advance_turn=5, repo="new") - hs.save_meta("sess", advance_turn=4, repo="old") # the older child finishes last - meta = hs.load_meta("sess") - assert meta["last_turn"] == 5 and meta["repo"] == "new", meta - # the read and the write are one critical section: another child cannot enter it - real, tried = hs.load_meta, [] - - def load_while_another_child_tries(session): - if not tried: - tried.append(hs._meta_lock(session)) - return real(session) - - hs.load_meta = load_while_another_child_tries - try: - hs.save_meta("sess", advance_turn=6) - finally: - hs.load_meta = real - assert tried == [None], "the lock is held across the read-merge-write" - lock = hs._meta_lock("sess") - assert lock is not None, "and released after it" - hs._release(lock) - assert hs.load_meta("sess")["last_turn"] == 6 - print("PASS test_an_older_turns_child_never_moves_the_meta_back") - - -def test_the_block_reason(): - line = hs.block_reason("sess", _moment(2), "repo") - assert line.startswith(hs.BLOCK_PREFIX) - # the privacy guards stay on the line itself: it reaches both the model and - # the person. "Never pass activate" moved into the skill (Codex, #244). - assert "/Users/" not in line and "@" not in line - # HOW to file lives in skills/create-rule/SKILL.md. The prompt-lane line - # restated it and drew five of six Codex findings on #222 (dropped whatever - # it did not copy, drifted from whatever it did), so none of it is here. - assert "create-rule skill" in line - for restated in ("list_rulebooks", "include_retired", "supersedes_rule_id", - "anchor_recall", "--fires", "mode"): - assert restated not in line, restated - # The line is a POINTER now. Claude Code renders a blocking Stop reason to - # the person as "Stop hook feedback:" and there is no Stop channel that - # reaches only the model (Codex, #244), so every instruction that does not - # have to be here lives in skills/create-rule/SKILL.md instead. What stays - # is what only this line knows: which turn, and where the skill is. - assert "create-rule skill" in line - assert 'source_ref="sess#2"' in line and "choose scope_repos" in line - # The verdict lane is gone. Filed-per-block is already `handed` rows here - # against `session_draft` rules on the server, and whether a rule HELPS is - # the fire-event fold's question. Recording non-events cost seven of the - # fifteen findings on #244 and bought a ratio that was already free. - assert "verdict" not in line and "--why" not in line and "<<" not in line - assert "say nothing to the person about this turn" in line - # the manual moved out and must NOT be restated here - for moved in ("not already a RULE", "restating the docs", "without asking", - "activate", "state=", '"hook_version"'): - assert moved not in line, moved - # 1130 -> 347. A pointer, not a manual. - assert len(line) < 400, len(line) - # ...and the skill must actually carry what the line dropped, or the two - # halves diverge silently and the agent gets neither. - skill = (pathlib.Path(__file__).resolve().parent.parent / "plugins" / "memhub" - / "skills" / "create-rule" / "SKILL.md").read_text(encoding="utf-8") - # markdown wraps; the check is about content, not where the lines break, - # and reflowing prose to satisfy a substring test is the wrong direction - skill = " ".join(skill.replace("*", "").replace("`", "").split()) - assert "conflict is still reported to the person" not in skill - assert "memhub-verdict" not in skill and "--why-file" not in skill - for owed in ("not already a RULE", "does NOT disqualify it", - "restating the docs", "ask the person nothing at all", - "Never pass activate", "Step 4b.6", "Step 0 (which rulebook)", - # the scope is the author's, chosen from the lesson and proven - # against the repo it names, not copied from where the turn ran - "scope_repos is yours to choose", "Prove a repo scope before filing", - 'source="session_draft"', - # each exception the harness path takes must be stated, or the - # agent hits a mandatory step it cannot satisfy (Codex, #244) - - # nothing on a non-filing path reaches the person (Codex, #244) - "hears about the turn only when a rule was filed", - "File nothing, say nothing, stop", "same_matcher", - # one invariant beats enumerating every step that talks: five - # rounds of this PR were the next unexempted one (Codex, #244) - "nothing on this path reaches the person", - "including ones added after this was written", - "A live verification that runs and fails is terminal", - # The silence invariant must NOT swallow machine state this - # skill changed — state the person cannot fix unseen (Codex, - # #244, the only P1). The INSTANCE moved in ENG-1107: the - # forward test arms its candidate in a private base now, so - # "a restore that fails" and "a $BOOK.pretest-* from an - # INTERRUPTED earlier run" cannot arise — nothing shared is - # written, so there is nothing to restore and nothing to - # recover. What replaces them is the one way the isolation can - # fail open: the claim not taking, which must abort the run. - "The claim did not take", - "abort before", - "safety bug wearing the costume of quiet", - # an idempotent re-import ended WITH a rule (Codex, #244) - "unchanged: true is a filing, not a blocker", - "source_ref is passed EXACTLY as the harness line gives it"): - assert owed in skill, owed - assert "may already be written down" in hs.block_reason("sess", dict(_moment(2), derivable=True), "repo") - assert "may already be written down" not in line - print("PASS test_the_block_reason") - - - - - -def test_the_person_hears_about_a_filed_rule_and_about_a_pass_that_could_not_run(): - """The only two things this lane says. A pass that RAN and found no lesson - is silent — that is the quiet the design is for. A pass that could not run - is not: a broken pipeline that looks exactly like a quiet one is the defect - this lane keeps rediscovering. - - The filed line names the rule and says what it catches. It does NOT say the - rule helped: a proposed rule is served to no agent, so it has helped nobody, - and claiming otherwise is an artifact asserting behaviour the system does - not have. Whether a rule helps is the fire ledger's question.""" - with _Env(): - hs.save_meta("sess", repo="repo", last_turn=2) - path = hs.moments_path("sess") - hx.append_jsonl(path, {"outcome": "none", "detail": "already an active rule", - "ref": "sess#1", "at": 1.0}) - assert hs.report_outcomes("sess") == "", "a considered nothing is silent" - - # the shape a real drain wrote on 2026-09-19 - hx.append_jsonl(path, {"outcome": "filed", "ref": "sess#2", "at": 2.0, - "env": "staging", - "rule_id": "b43d6914-4cb3-4a91-84ad-cadbeb6dcfe4", - "title": "Pin the MCP server when spawning claude -p", - "catches": "a `claude -p` without --strict-mcp-config"}) - said = hs.report_outcomes("sess") - assert "Pin the MCP server when spawning claude -p" in said, said - assert "--strict-mcp-config" in said, "it says what the rule catches" - assert "b43d6914-4cb3-4a91-84ad-cadbeb6dcfe4" in said and "staging" in said - assert "fires for nobody until someone activates it" in said - for claim in ("helped", "saved", "prevented", "improved"): - assert claim not in said.lower(), f"a proposed rule has not {claim} anything" - - # a child that returned only an id is still a FILING; the line degrades - hx.append_jsonl(path, {"outcome": "filed", "ref": "sess#3", "at": 3.0, - "env": "staging", "rule_id": "bare-id"}) - bare = hs.report_outcomes("sess") - assert "rule bare-id" in bare, bare - - # the scope the author chose rides the result line to the person - outcome, fields = hs.parse_result( - f"{hs.RESULT_PREFIX} filed rid | A pipe makes $? the pipe's exit code | " - "`cmd | tail; echo $?` | []") - assert outcome == "filed" and fields["scope"] == "[]", fields - assert fields["catches"] == "`cmd | tail; echo $?`", "a pipe in catches survives" - # an older child's three-field line still parses, with no scope - _, old = hs.parse_result(f"{hs.RESULT_PREFIX} filed rid | T | a `a | b` pipe") - assert old["catches"] == "a `a | b` pipe" and old["scope"] == "", old - hx.append_jsonl(path, {"outcome": "filed", "ref": "sess#5", "at": 5.0, - "env": "staging", **fields}) - assert "scope_repos: []" in hs.report_outcomes("sess") - - hx.append_jsonl(path, {"outcome": "failed", "detail": "no rulebook server", - "ref": "sess#4", "at": 4.0, "env": "staging"}) - failed = hs.report_outcomes("sess") - assert "no rulebook server" in failed and "staging" in failed - assert hs.report_outcomes("sess") == "", "each outcome is said once" - print("PASS test_the_person_hears_about_a_filed_rule_and_about_a_pass_that_could_not_run") - -def test_a_recorded_block_is_not_a_turn(): - """Claude Code records the block's reason as an isMeta `user` record - (`Stop hook feedback:\\n`, seen on 2.1.270). Read as a human - message it would renumber every later turn and hand the classifier the - harness talking to itself.""" + proj = env.base / "claude" / "projects" / "-w" + proj.mkdir(parents=True) + tp = proj / "sess.jsonl" + _transcript(tp, [("deploy it", "deployed to prod", []), + ("no, i mean staging", "redeployed to staging", []), + ("thanks", "ok", [])]) + run = lambda *args: subprocess.run( # noqa: E731 + [sys.executable, str(SCRIPTS / "harness_stop.py"), "handoff", *args], + capture_output=True, text=True, timeout=30, + env=dict(os.environ, MEMHUB_HARNESS_EXTRACT="0", CLAUDE_CONFIG_DIR=str(env.base / "claude"))) + ok = run("--moment", "sess#2", "--kind", "correction") + assert ok.returncode == 0, ok.stderr + reason = ok.stdout + assert reason.startswith(hs.BLOCK_PREFIX), reason + assert f"{hs.FORK_MARK}, moment sess#2" in reason, reason + assert "flagged as correction" in reason and "turn 3, is what happened after it" in reason, reason + assert f"stamp --transcript \"{tp}\" --turn 2" in reason, reason + assert 'says "none" or "failed", say nothing' in reason, "an unfiled rule is not news" + assert "author" not in reason.lower(), "how to file lives in the skill" + assert "may already be written down" in run("--moment", "sess#2", "--derivable").stdout + # with no flags — how the session rule calls it — the label comes from + # the `launch` line the Stop logged; with none, the prompt says "a signal" + assert "flagged as a signal" in run("--moment", "sess#2").stdout + hx.log_path("stop.log").parent.mkdir(parents=True, exist_ok=True) + hx.log_path("stop.log").write_text( + "2026-09-30 18:00:00 launch sess t2: fork requested (claim_challenge) derivable=1\n") + logged = run("--moment", "sess#2").stdout + assert "flagged as claim_challenge" in logged and "may already be written down" in logged, logged + for args, why in ((("--moment", "sess#9"), "no turn 9"), + (("--moment", "nosuch#1"), "no transcript"), + (("--moment", "sess#2; rm -rf x"), "not a moment ref")): + bad = run(*args) + assert bad.returncode == 2 and why in bad.stderr and bad.stdout == "", (args, bad) + # a kind that is not a plain label never reaches the prompt + assert "`touch x`" not in run("--moment", "sess#2", "--kind", "`touch x`").stdout + print("PASS test_the_handoff_command_prints_the_fork_instruction") + + +def test_session_start_carries_the_standing_rule(): + """The rule that keeps the agent from narrating a launch goes out at + SessionStart, a channel the person is not shown; with the flag off, or for + a subagent, nothing is said.""" + run = lambda flag, payload: subprocess.run( # noqa: E731 + [sys.executable, str(SCRIPTS / "harness_stop.py"), "session"], input=json.dumps(payload), + capture_output=True, text=True, timeout=30, env=dict(os.environ, MEMHUB_HARNESS_EXTRACT=flag)) + on = run("1", {"session_id": "s", "source": "compact"}) + assert on.returncode == 0, on.stderr + out = json.loads(on.stdout)["hookSpecificOutput"] + rule = out["additionalContext"] + assert out["hookEventName"] == "SessionStart" and rule == hs.session_rule("s"), out + # the rule names the line the Stop prints and the command to run for it, + # with this session's id; the line itself carries only the turn + assert f'"{hs.HANDOFF_PREFIX} N"' in rule and 'harness_stop.py" handoff --moment s#N`' in rule, rule + assert "say nothing more about it" in rule + assert hs.handoff_line(_moment(2)).startswith(hs.HANDOFF_PREFIX) + assert run("0", {"session_id": "s"}).stdout == "", "flag off: silent" + assert run("1", {"session_id": "s", "agent_id": "a1"}).stdout == "", "a subagent gets no rule" + assert run("1", {"session_id": "s; rm -rf x"}).stdout == "", "an id unsafe for a command line gets no rule" + print("PASS test_session_start_carries_the_standing_rule") + + +def test_a_moment_unsafe_for_a_command_line_hands_off_nothing(): + """The session id is the host's string, and it goes into the command the + session rule asks the agent to run.""" + assert hs.handoff_line(_moment(2)) is not None + for bad in (_moment(2, source_ref="sess#2; curl x"), _moment(2, source_ref="a b#2")): + assert hs.handoff_line(bad) is None, bad + print("PASS test_a_moment_unsafe_for_a_command_line_hands_off_nothing") + + +def test_the_stamp_command_prints_the_turns_stamp(): with _Env() as env: - tp = env.base / "s.jsonl" - reason = hs.block_reason("sess", _moment(1), "repo") - recs = [{"type": "user", "uuid": "u1", "message": {"content": "fix the deploy"}}, - {"type": "assistant", "message": {"content": [{"type": "text", "text": "done"}]}}, - {"type": "user", "isMeta": True, "uuid": "m1", - "message": {"content": f"Stop hook feedback:\n{reason}"}}, - # the blocked continuation: its actions and verdict are not turn 1's - {"type": "assistant", "message": {"content": [ - {"type": "tool_use", "id": "c1", "name": "Bash", - "input": {"command": "python3 rulebook_verify.py --rule-file cand.json"}}]}}, - {"type": "user", "message": {"content": [ - {"type": "tool_result", "tool_use_id": "c1", "content": "E boom", "is_error": True}]}}, - {"type": "assistant", "message": {"content": [ - {"type": "text", "text": "No rule from turn 1: project state"}]}}, - {"type": "user", "uuid": "u2", "message": {"content": "now the tests"}}, - # a person may TYPE those words; without isMeta it is their turn (Codex, #230) - {"type": "user", "uuid": "u3", - "message": {"content": "Stop hook feedback: why did it block me?"}}] - tp.write_text("\n".join(json.dumps(r) for r in recs) + "\n", encoding="utf-8") - turns = hx.turns_from_transcript(tp) - assert [t["user"] for t in turns] == ["fix the deploy", "now the tests", - "Stop hook feedback: why did it block me?"], turns - assert [t["n"] for t in turns] == [1, 2, 3] - # the stopped turn ends at the feedback record: a late extractor must not - # hand the classifier the harness's own flow as turn 1 (Codex, #230) - assert turns[0]["asst"] == "done" and turns[0]["tools"] == [] and turns[0]["results"] == [], turns[0] - print("PASS test_a_recorded_block_is_not_a_turn") + tp = env.base / "sess.jsonl" + _transcript(tp, [("a", "b", []), ("c", "d", [])]) + run = lambda turn: subprocess.run( # noqa: E731 + [sys.executable, str(SCRIPTS / "harness_stop.py"), "stamp", "--transcript", str(tp), + "--turn", str(turn), "--cwd", str(env.base)], + capture_output=True, text=True, timeout=30, env=dict(os.environ, MEMHUB_HARNESS_EXTRACT="0")) + ok = run(2) + assert ok.returncode == 0, ok.stderr + stamp = json.loads(ok.stdout) + assert stamp["session_id"] == "sess" and stamp["turn"] == 2, stamp + for key in ("repo", "hook_version", "at", "env"): + assert key in stamp, (key, stamp) + missing = run(9) + assert missing.returncode == 2 and "no turn 9" in missing.stderr, missing + print("PASS test_the_stamp_command_prints_the_turns_stamp") + + +def test_a_filed_rule_links_to_its_page_in_the_env_it_was_filed_in(): + """`?open=` is the rule modal, not `?rule=`, which filters fire history. + Only when the plugin points at the same MemHub the rule was filed in.""" + import _memhub_auth + real = _memhub_auth.default_url + rid = "846c4331-1b1d-4295-afe9-18156f44f1df" + try: + _memhub_auth.default_url = lambda: "https://api.staging.memhub.xtrace.ai" + assert hs.rule_url(rid, "staging") == f"https://staging.mem.xtrace.ai/studio/rulebook?open={rid}" + _memhub_auth.default_url = lambda: "https://api.memhub.xtrace.ai" + assert hs.rule_url(rid, "staging") == "" + finally: + _memhub_auth.default_url = real + print("PASS test_a_filed_rule_links_to_its_page_in_the_env_it_was_filed_in") def test_the_hooks_are_wired_behind_the_guard(): @@ -745,10 +365,14 @@ def test_the_hooks_are_wired_behind_the_guard(): for handler in group["hooks"]: if "harness_stop.py" in handler["command"]: wired[event] = handler - # one lane: the prompt lane handed 19 moments for 0 proposals and is gone - assert set(wired) == {"Stop"}, set(wired) - # synchronous: the transcript size and the error arcs it takes ARE the - # boundary, and an async hook's stdout could not block the stop + # Stop judges and hands off; SessionStart carries the standing rule the + # person does not see. The prompt lane handed 19 moments for 0 proposals + # and is gone. + assert set(wired) == {"Stop", "SessionStart"}, set(wired) + assert wired["SessionStart"]["command"].rstrip("; fi").endswith('harness_stop.py" session') + assert "MEMHUB_HARNESS_EXTRACT" in wired["SessionStart"]["command"], "the same flag gates both" + # synchronous: the classifier's verdict decides whether this Stop blocks, + # and an async hook's stdout could not block the stop assert not wired["Stop"].get("async") and wired["Stop"]["timeout"] <= 10 assert wired["Stop"]["command"].rstrip("; fi").endswith('harness_stop.py" stop') for h in wired.values(): @@ -763,7 +387,7 @@ def test_the_hook_command_starts_nothing_unless_the_flag_is_on(): doc = json.loads((ROOT / "plugins" / "memhub" / "hooks" / "claude-hooks.json").read_text(encoding="utf-8")) commands = [h["command"] for groups in doc["hooks"].values() for g in groups for h in g["hooks"] if "harness_stop.py" in h["command"]] - assert len(commands) == 1 + assert len(commands) == 2, "the Stop lane and the SessionStart lane" with tempfile.TemporaryDirectory() as td: root, ran = Path(td) / "plugin", Path(td) / "ran" (root / "scripts").mkdir(parents=True) @@ -785,7 +409,7 @@ def test_the_hook_command_starts_nothing_unless_the_flag_is_on(): capture_output=True, env=env, timeout=10) assert proc.returncode == 0, proc.stderr got = sorted(ran.read_text().split()) if ran.exists() else [] - assert got == (["stop"] if runs else []), (value, got) + assert got == (["session", "stop"] if runs else []), (value, got) if ran.exists(): ran.unlink() print("PASS test_the_hook_command_starts_nothing_unless_the_flag_is_on") @@ -799,6 +423,8 @@ def test_the_hook_command_starts_nothing_unless_the_flag_is_on(): # the shell gate matches bytes, str.strip() also drops U+00A0 and friends, and # a flag padded with those still reads off at the hook — the safe direction. _ASCII_WS = "".join(chr(i) for i in range(128) if chr(i).isspace()) + + EXTRACT_FLAG_TABLE = ( [("1", True), (" 1 ", True), ("1\n", True), ("on", True), ("ON ", True), ("true", True), ("yes", True), ("Yes", True), ("TRUE", True), @@ -812,21 +438,20 @@ def test_the_hook_command_starts_nothing_unless_the_flag_is_on(): def test_every_extract_gate_reads_the_flag_the_same_way(): - """`extract_enabled`, `harness_extract_on` and the Stop hook's shell `case` - are one switch. Each value in the table goes to all three — the shell gate + """`extract_enabled` and the Stop hook's shell `case` are one switch. Each value in the table goes to all three — the shell gate run as the REAL command string from claude-hooks.json, under every POSIX shell on the box, with a stub plugin root that records whether it got past the gate — and all of them must give the table's answer. Only claude-hooks.json carries a shell copy: the Codex file is generated without the harness lane and Cursor never wires it (asserted here).""" - import rulebook_hook as rh # noqa: PLC0415 hooks = ROOT / "plugins" / "memhub" / "hooks" for other in ("codex-hooks.json", "cursor-hooks.json"): assert "HARNESS" not in (hooks / other).read_text(encoding="utf-8"), other doc = json.loads((hooks / "claude-hooks.json").read_text(encoding="utf-8")) commands = [h["command"] for groups in doc["hooks"].values() for g in groups for h in g["hooks"] if "MEMHUB_HARNESS_EXTRACT" in h["command"]] - assert len(commands) == 1, commands + # the Stop lane and the SessionStart lane: one switch, two copies of the gate + assert len(commands) == 2, commands shells = [] if os.name == "nt" else [ s for s in ("/bin/sh", "/bin/dash", "/usr/bin/dash", "/bin/bash") if os.path.exists(s)] wrong = [] @@ -841,15 +466,15 @@ def test_every_extract_gate_reads_the_flag_the_same_way(): base["CLAUDE_PLUGIN_ROOT"] = str(root) for value, want in EXTRACT_FLAG_TABLE: env = dict(base) if value is None else dict(base, MEMHUB_HARNESS_EXTRACT=value) - got = {"extract_enabled": hx.extract_enabled(env), - "harness_extract_on": bool(rh.harness_extract_on(env))} + got = {"extract_enabled": hx.extract_enabled(env)} for shell in shells: - proc = subprocess.run([shell, "-c", commands[0]], input="{}", text=True, - capture_output=True, env=env, timeout=10) - assert proc.returncode == 0, (shell, value, proc.stderr) - got["hook under " + shell] = ran.exists() - if ran.exists(): - ran.unlink() + for n, command in enumerate(commands): + proc = subprocess.run([shell, "-c", command], input="{}", text=True, + capture_output=True, env=env, timeout=10) + assert proc.returncode == 0, (shell, value, proc.stderr) + got["hook %d under %s" % (n, shell)] = ran.exists() + if ran.exists(): + ran.unlink() wrong += [(value, gate, answer) for gate, answer in got.items() if answer != want] assert not wrong, "%d answers disagree with the table (value, gate, answer):\n%s" % ( len(wrong), "\n".join(map(repr, wrong))) @@ -857,106 +482,6 @@ def test_every_extract_gate_reads_the_flag_the_same_way(): % (len(EXTRACT_FLAG_TABLE), ", ".join(shells) or "none")) -def test_an_authoring_child_neither_senses_nor_drains_when_settings_re_arm_the_flag(): - """Live on staging: an install with MEMHUB_HARNESS_EXTRACT=1 in settings.json - `env` saw that value override the child's EXTRACT=0. The fork's own Stop - classified its turns and drained the repo's moments, which spawned more - forks. Under the child flag the Stop does nothing and the arc sensor records - nothing. The same Stop without the flag must still run.""" - with _Env(): - ran = [] - real = hs.cmd_stop - hs.cmd_stop = lambda payload: ran.append(payload.get("session_id")) or 0 - saved = os.environ.pop("MEMHUB_HARNESS_CHILD", None) - try: - os.environ["MEMHUB_HARNESS_CHILD"] = "1" # EXTRACT is "1" in _Env - assert _stop(session_id="fork-1")[0] == 0 - assert ran == [], ran - os.environ.pop("MEMHUB_HARNESS_CHILD") - _stop(session_id="owner-1") - assert ran == ["owner-1"], ran - finally: - hs.cmd_stop = real - os.environ.pop("MEMHUB_HARNESS_CHILD", None) - if saved is not None: - os.environ["MEMHUB_HARNESS_CHILD"] = saved - import rulebook_hook as rh # noqa: PLC0415 - assert not rh.harness_extract_on({"MEMHUB_HARNESS_CHILD": "1", "MEMHUB_HARNESS_EXTRACT": "1"}) - assert rh.harness_extract_on({"MEMHUB_HARNESS_EXTRACT": "1"}) - print("PASS test_an_authoring_child_neither_senses_nor_drains_when_settings_re_arm_the_flag") - - -def test_the_author_child_is_launched_as_a_harness_child(): - """The flag the capture and sensor lanes read is set by `run_author`.""" - seen = {} - - def fake_run(argv, **kw): - seen.update(argv=argv, env=kw.get("env") or {}) - return subprocess.CompletedProcess(argv, 0, stdout="HARNESS-RESULT: none | x\n", stderr="") - - real = hs.subprocess.run - hs.subprocess.run = fake_run - try: - with _Env(): # the children list lands in the temp dir - hs.run_author("owner", {"state": {"session_id": "owner"}, "turn": 1, - "source_ref": "owner#1", "kind": "error_arc"}, - "repo", Path("/nonexistent/mcp.json")) - finally: - hs.subprocess.run = real - assert "--fork-session" in seen["argv"] - assert seen["env"].get("MEMHUB_HARNESS_CHILD") == "1" - print("PASS test_the_author_child_is_launched_as_a_harness_child") - - -def test_a_child_that_exits_non_zero_says_why(): - """stderr first; `claude -p` prints some refusals on stdout instead.""" - moment = {"state": {"session_id": "owner"}, "turn": 1, "source_ref": "owner#1"} - real = hs.subprocess.run - try: - for out, err, want in (("", "boom\nError: no auth\n", "the child exited 1: Error: no auth"), - ("No conversation found with session ID: owner\n", "", - "the child exited 1: No conversation found with session ID: owner"), - ("", "", "the child exited 1")): - hs.subprocess.run = lambda argv, **kw: subprocess.CompletedProcess(argv, 1, stdout=out, - stderr=err) - with _Env(): # the children list lands in the temp dir - outcome, fields = hs.run_author("owner", moment, "repo", - Path("/nonexistent/mcp.json")) - assert outcome == "failed" and fields.get("detail") == want, fields - assert fields.get("child"), "a failed pass still names its child" - finally: - hs.subprocess.run = real - print("PASS test_a_child_that_exits_non_zero_says_why") - - -def test_a_decided_moment_is_recorded_and_logged_not_crashed_on(): - """The pass's own log line once named a variable that no longer existed: - every drain died after its child decided, the moment was never handed, and - stop.log said only `could not start`. Drive `cmd_author` end to end.""" - with _Env(): - hs.save_meta("sess", repo="repo", last_turn=2) - hx.append_jsonl(hs.moments_path("sess"), _moment(2)) - real = hs.run_author, hs.write_child_mcp_config - hs.run_author = lambda *a, **k: ("none", {"detail": "nothing worth filing"}) - # the real one resolves a credential through the MCP SDK, which the - # bare-Python CI lane does not have; the loop is what is under test - hs.write_child_mcp_config = lambda d: (d / "mcp.json", "https://mcp.test") - try: - assert hs.cmd_author("sess", ["sess#2"]) == 0 - finally: - hs.run_author, hs.write_child_mcp_config = real - rows = hx.read_jsonl(hs.moments_path("sess")) - assert rows[-1].get("handed") == "sess#2", rows - assert rows[-1].get("outcome") == "none", rows - log = hx.log_path("stop.log").read_text(encoding="utf-8") - assert "sess#2 -> none (nothing worth filing)" in log, log - assert "could not start" not in log, log - freed = hs.take_drain_claim("sess") - assert freed is not None, "the pass left its session claim held" - freed.close() - print("PASS test_a_decided_moment_is_recorded_and_logged_not_crashed_on") - - def test_the_sensor_never_sends_activate(): import re # noqa: PLC0415 src = (SCRIPTS / "harness_stop.py").read_text(encoding="utf-8") @@ -966,65 +491,79 @@ def test_the_sensor_never_sends_activate(): print("PASS test_the_sensor_never_sends_activate") -def _run_author(stdout): - """`run_author` against a fake claude, files under a temp harness dir.""" - seen = {} - - def fake_run(argv, **kw): - seen.update(argv=argv, env=kw.get("env") or {}) - return subprocess.CompletedProcess(argv, 0, stdout=stdout, stderr="") - - real_run = hs.subprocess.run - hs.subprocess.run = fake_run - try: - with _Env(): - got = hs.run_author("sess", {"state": {"session_id": "owner"}, "turn": 2, - "source_ref": "owner#2", "kind": "error_arc"}, - "repo", Path("/nonexistent/mcp.json")) - finally: - hs.subprocess.run = real_run - return got, seen - +def test_a_recorded_block_is_not_a_turn(): + """Claude Code records the block's reason as an isMeta `user` record + (`Stop hook feedback:\\n`, seen on 2.1.270). Read as a human + message it would renumber every later turn and hand the classifier the + harness talking to itself.""" + with _Env() as env: + tp = env.base / "s.jsonl" + reason = hs.fork_reason(_moment(1), "staging", "repo") + recs = [{"type": "user", "uuid": "u1", "message": {"content": "fix the deploy"}}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "done"}]}}, + {"type": "user", "isMeta": True, "uuid": "m1", + "message": {"content": f"Stop hook feedback:\n{reason}"}}, + # the blocked continuation: its actions and verdict are not turn 1's + {"type": "assistant", "message": {"content": [ + {"type": "tool_use", "id": "c1", "name": "Bash", + "input": {"command": "python3 rulebook_verify.py --rule-file cand.json"}}]}}, + {"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "E boom", "is_error": True}]}}, + {"type": "assistant", "message": {"content": [ + {"type": "text", "text": "No rule from turn 1: project state"}]}}, + {"type": "user", "uuid": "u2", "message": {"content": "now the tests"}}, + # a person may TYPE those words; without isMeta it is their turn (Codex, #230) + {"type": "user", "uuid": "u3", + "message": {"content": "Stop hook feedback: why did it block me?"}}] + tp.write_text("\n".join(json.dumps(r) for r in recs) + "\n", encoding="utf-8") + turns = hx.turns_from_transcript(tp) + assert [t["user"] for t in turns] == ["fix the deploy", "now the tests", + "Stop hook feedback: why did it block me?"], turns + assert [t["n"] for t in turns] == [1, 2, 3] + # the stopped turn ends at the feedback record: the next Stop must not + # hand the classifier the harness's own flow as turn 1 (Codex, #230) + assert turns[0]["asst"] == "done" and turns[0]["tools"] == [] and turns[0]["results"] == [], turns[0] + print("PASS test_a_recorded_block_is_not_a_turn") -def test_what_a_pass_spent_is_read_from_the_childs_json(): - body = json.dumps({"type": "result", "result": "thinking…\nHARNESS-RESULT: filed r1 | T | c", - "usage": {"input_tokens": 2, "cache_creation_input_tokens": 300, - "cache_read_input_tokens": 4000, "output_tokens": 50}, - "total_cost_usd": 0.12, "num_turns": 6}) - (outcome, fields), _ = _run_author(body) - assert outcome == "filed" and fields["rule_id"] == "r1" and fields["title"] == "T" - assert fields["spent"]["cache_read"] == 4000 and fields["spent"]["calls"] == 6 - reply, spent = hs.read_child("HARNESS-RESULT: none plain text") - assert reply.startswith("HARNESS-RESULT") and spent == {} - print("PASS test_what_a_pass_spent_is_read_from_the_childs_json") +def test_a_recorded_stop_context_ends_the_turn(): + """The hand-off is the Stop hook's `additionalContext`, which Claude Code + records as an `attachment` record (hook_additional_context, hookEvent Stop; + probed on 2.1.286), not a user record. It ends the stopped turn exactly as + a block's reason did: the fork launch that follows is not the person's + turn. Other events' context (PreToolUse, SessionStart) lands mid-turn and + ends nothing.""" + with _Env() as env: + tp = env.base / "s.jsonl" + ctx = hs.fork_reason(_moment(1), "staging", "repo") + def att(event, text): + return {"type": "attachment", "attachment": { + "type": "hook_additional_context", "content": [text], + "hookName": event, "hookEvent": event}} -def test_the_author_loop_survives_its_own_log_line(): - """It named `detail` after the variable became `fields`: a NameError after - the FIRST moment of every drain, caught by the handler that reports "drain - could not start" — so a drain of eight authored one and told the person - the pass had failed.""" - with tempfile.TemporaryDirectory() as td: - old = {k: os.environ.get(k) for k in ("MEMHUB_HARNESS_DIR",)} - os.environ["MEMHUB_HARNESS_DIR"] = td - hx.append_jsonl(hs.moments_path("sess"), _moment(1)) - hx.append_jsonl(hs.moments_path("sess"), _moment(2)) - hs._publish(hs.meta_path("sess"), json.dumps({"repo": "repo"})) - real_run, real_cfg = hs.run_author, hs.write_child_mcp_config - hs.run_author = lambda *a, **k: ("none", {"detail": "not a lesson"}) - hs.write_child_mcp_config = lambda d: (Path(d) / "mcp.json", "http://x") - try: - hs.cmd_author("sess", ["sess#1", "sess#2"]) - rows = hx.read_jsonl(hs.moments_path("sess")) # while the dir is still td - finally: - hs.run_author, hs.write_child_mcp_config = real_run, real_cfg - for k, v in old.items(): - os.environ.pop(k, None) if v is None else os.environ.__setitem__(k, v) - outcomes = [r for r in rows if r.get("outcome")] - assert [r["outcome"] for r in outcomes] == ["none", "none"], outcomes - assert {r["handed"] for r in outcomes} == {"sess#1", "sess#2"} - print("PASS test_the_author_loop_survives_its_own_log_line") + recs = [{"type": "user", "uuid": "u1", "message": {"content": "fix the deploy"}}, + att("PreToolUse", "## XTrace Rulebook (team rules)"), + {"type": "assistant", "message": {"content": [ + {"type": "tool_use", "id": "c0", "name": "Bash", "input": {"command": "make deploy"}}]}}, + {"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": "c0", "content": "ok"}]}}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "done"}]}}, + att("Stop", ctx), + # the continuation it asked for: the fork launch is not turn 1's + {"type": "assistant", "message": {"content": [ + {"type": "tool_use", "id": "c1", "name": "Agent", + "input": {"subagent_type": "fork", "prompt": "MemHub harness fork, moment s#1"}}]}}, + {"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "launched"}]}}, + {"type": "user", "uuid": "u2", "message": {"content": "now the tests"}}] + tp.write_text("\n".join(json.dumps(r) for r in recs) + "\n", encoding="utf-8") + turns = hx.turns_from_transcript(tp) + assert [t["user"] for t in turns] == ["fix the deploy", "now the tests"], turns + assert [t["tools"][0]["tool"] if t["tools"] else None for t in turns][:1] == ["Bash"], turns[0] + assert all(x["tool"] != "Agent" for x in turns[0]["tools"]), "the launch is not turn 1's" + assert turns[0]["asst"] == "done", turns[0] + print("PASS test_a_recorded_stop_context_ends_the_turn") if __name__ == "__main__": diff --git a/tests/md_capture_test.py b/tests/md_capture_test.py index df65f76f..a3dff124 100644 --- a/tests/md_capture_test.py +++ b/tests/md_capture_test.py @@ -400,34 +400,65 @@ async def fake_resolve(session, cwd, env): asyncio.run(f.flush(sid7)) check(seen_args and seen_args[-1].get("org_id") == "ORG-2", "the room's org_id is sent with its brain id") - # Git specs have one authored source; auto-capture must not publish another. - sid8 = "sess-md-flush-linked" + # A cached room the backend disowns is evicted — session capture used + # to do that as a side effect, and sessions never name a brain now. + forgot: list = [] + f.forget_room = lambda cwd=None, env=None: forgot.append(cwd) or True + async def refuse_missing(session, call_args): + raise f.SaveRejected("tool error: Agent brain not found") + async def refuse_other(session, call_args): + raise f.SaveRejected("tool error: tags are required") + f.read_room = lambda *a, **k: {"brain_id": "B-DEAD", "name": "Repo: x/y"} + f._save = refuse_missing + r7.write_text("# R7\n" + "d" * 7000, encoding="utf-8") + mc.save_state(sid7, {"dirty": [str(r7)], "saved": {}, "attempts": {}}) + asyncio.run(f.flush(sid7)) + check(forgot == [r7.parent], f"'Agent brain not found' for a cached room → forgotten: {forgot}") + check(mc.load_state(sid7)["dirty"] == [str(r7)], "the file stays dirty for the next Stop") + forgot.clear() + f._save = refuse_other + asyncio.run(f.flush(sid7)) + check(forgot == [], "any other refusal keeps the cached room") + f._save = capture_save + + # Spec files are captured like any markdown; the SERVER decides whether + # to store one (it answers "mirror_owns_file" when the brain mirrors the + # file from git). A mirror-owned answer is handled, not failed: recorded + # with its digest, never retried until the content changes. + sid8 = "sess-md-flush-specs" (root / "docs/specs").mkdir(parents=True, exist_ok=True) owned = root / "docs/specs/owned-spec.md" owned.write_text("---\nspec: owned\nowns: [app/a.py]\n---\n# Owned\n" + "o" * 7000, encoding="utf-8") - free = root / "free-spec.md"; free.write_text("# Free\n" + "f" * 7000, encoding="utf-8") - mc.save_state(sid8, {"dirty": [str(owned), str(free)], "saved": {}, "attempts": {}}) + unmirrored = root / "docs/specs/fresh-spec.md" + unmirrored.write_text("# Fresh\n" + "f" * 7000, encoding="utf-8") + mc.save_state(sid8, {"dirty": [str(owned), str(unmirrored)], "saved": {}, "attempts": {}}) seen_args.clear() f.read_room = lambda *a, **k: None + + async def mirror_owns_owned(session, call_args): + seen_args.append(call_args) + if call_args["name"] == "Owned (docs/specs/owned-spec.md)": + return {"id": "mirror-row", "name": "Spec: owned", "action": "mirror_owns_file"} + return {"id": "new-row", "name": call_args["name"], "action": "created"} + + f._save = mirror_owns_owned asyncio.run(f.flush(sid8)) + f._save = capture_save st = mc.load_state(sid8) - check([a["name"] for a in seen_args] == ["Free (free-spec.md)"], f"linked file skipped, unlinked file saved: {[a['name'] for a in seen_args]}") - check(st["dirty"] == [] and str(owned) not in st["saved"], f"linked file leaves dirty without a digest (not retried): {st}") - owned.write_text("---\nspec: owned\nowns: [broken\n" + "x" * 7000, encoding="utf-8") - check(f._hand_saved(root, owned), "malformed git spec remains excluded") - owned.write_text("x" * 600000, encoding="utf-8") - check(f._hand_saved(root, owned), "oversized git spec remains excluded") - custom = root / "custom/specs/nested/broken.md" - custom.parent.mkdir(parents=True, exist_ok=True) - custom.write_text("broken", encoding="utf-8") - previous_dir = os.environ.get("MEMHUB_SPEC_DIR") - os.environ["MEMHUB_SPEC_DIR"] = "custom/specs/" - check(f._hand_saved(root, custom), "custom nested malformed spec remains excluded") - if previous_dir is None: - os.environ.pop("MEMHUB_SPEC_DIR", None) - else: - os.environ["MEMHUB_SPEC_DIR"] = previous_dir + check(sorted(a["name"] for a in seen_args) == ["Fresh (docs/specs/fresh-spec.md)", + "Owned (docs/specs/owned-spec.md)"], + f"both spec files are offered to the server: {[a['name'] for a in seen_args]}") + check(st["dirty"] == [] and str(owned) in st["saved"] and str(unmirrored) in st["saved"], + f"a mirror-owned answer is recorded like a save, not retried: {st}") + check(not (st.get("attempts") or {}), f"no retry budget spent on a mirror-owned file: {st}") + seen_args.clear() + mc.save_state(sid8, {**st, "dirty": [str(owned)]}) + f._save = mirror_owns_owned + asyncio.run(f.flush(sid8)) + f._save = capture_save + check(seen_args == [], f"an unchanged mirror-owned file is not offered again: {seen_args}") owned.unlink() + unmirrored.unlink() # ---- git sweep: markdown written through Bash never enters `dirty` ---- # A harness that prefers shell edits (heredoc, sed -i) never fires the diff --git a/tests/platform_routing_test.py b/tests/platform_routing_test.py index 1c97702d..98ab6db8 100644 --- a/tests/platform_routing_test.py +++ b/tests/platform_routing_test.py @@ -77,8 +77,8 @@ def fake_run(command): return types.SimpleNamespace(returncode=0) args = types.SimpleNamespace( - host=None, session="session-1", title=None, agent_brain_id=None, - no_room=True, namespace=None, url=None, conversation_id=None, + host=None, session="session-1", title=None, + namespace=None, url=None, conversation_id=None, dry_run=False) try: capture.subprocess.run = fake_run @@ -95,7 +95,9 @@ def fake_run(command): for host, command in zip(("claude", "codex", "cursor"), captured): index = command.index("--source-platform") assert command[index + 1] == host, command - assert command.count("--no-room") == 1, command + # Imports are personal-only: no brain flag exists to forward. + assert "--no-room" not in command, command + assert "--agent-brain-id" not in command, command print("PASS test_capture_passes_reader_host_to_import_session") @@ -120,8 +122,8 @@ def fail_run(_command): raise AssertionError("Claude dry-run launched the importer") args = types.SimpleNamespace( - host="auto", session="session-1", title=None, agent_brain_id=None, - no_room=False, namespace=None, url=None, conversation_id=None, + host="auto", session="session-1", title=None, + namespace=None, url=None, conversation_id=None, dry_run=True) try: capture._resolve = lambda _args: ( @@ -296,6 +298,112 @@ def fake_run(command, **kwargs): print("PASS test_import_namespace_probe_hardens_transcript_cwd") +def test_capture_passes_org_id_to_import_session() -> None: + captured: list[list[str]] = [] + original_resolve = capture._resolve + original_run = capture.subprocess.run + + class Reader: + HOST = "claude" + + def fake_run(command): + captured.append(command) + return types.SimpleNamespace(returncode=0) + + try: + capture.subprocess.run = fake_run + capture._resolve = lambda _args: ( + Reader(), Path("/tmp/session.jsonl"), "") + for org in ("org-1", None): + args = types.SimpleNamespace( + host=None, session="s", title=None, + namespace=None, url=None, org_id=org, + conversation_id=None, dry_run=False) + assert capture.cmd_import(args) == 0 + finally: + capture._resolve = original_resolve + capture.subprocess.run = original_run + + with_org, without_org = captured + assert with_org[with_org.index("--org-id") + 1] == "org-1", with_org + assert "--org-id" not in without_org, without_org + print("PASS test_capture_passes_org_id_to_import_session") + + +def test_import_session_names_no_brain() -> None: + """Imports land in personal memory like capture: the brain flags are gone + and nothing reads the room cache.""" + source = Path(import_session.__file__).read_text(encoding="utf-8") + assert "add_argument(" in source # the scan below inspects the real parser + assert '"--agent-brain-id"' not in source + assert '"--no-room"' not in source + assert "agent_brain_id" not in source + assert not hasattr(import_session, "apply_cached_room") + assert not hasattr(import_session, "read_room") + print("PASS test_import_session_names_no_brain") + + +def test_gist_lookup_browses_this_sessions_episodes() -> None: + """The gist poll is a browse of THIS session's episodes (kind + session_id, + no query, no memory_type), asks for the body, and never names a brain.""" + import asyncio + import hashlib + + calls: list[dict] = [] + gist = "## GOAL\nship it" + + class Session: + async def call_tool(self, name, arguments): + calls.append({"_tool": name, **arguments}) + return object() + + original = import_session.unwrap + try: + # Pointer hits carry `content` only when include_content is set. A + # task episode comes first; only the gist counts. + import_session.unwrap = lambda _res: {"items": [ + {"id": "t1", "kind": "episode", "title": "task", **( + {"content": "did a thing"} if calls[-1].get("include_content") else {})}, + {"id": "e1", "kind": "episode", "abstract": "x", **( + {"content": gist} if calls[-1].get("include_content") else {})}]} + got = asyncio.run(import_session._gist_hash(Session(), "sess-1", "o")) + finally: + import_session.unwrap = original + c = calls[0] + assert c["_tool"] == "search_memory", calls + assert c["kind"] == "episode" and c["session_id"] == "sess-1", calls + assert "query" not in c and "memory_type" not in c, calls + assert c["include_content"] is True, calls + assert c["org_id"] == "o" and "agent_brain_id" not in c + assert got == hashlib.sha256(gist.encode()).hexdigest() + print("PASS test_gist_lookup_browses_this_sessions_episodes") + + +def test_gist_is_recognised_by_its_pointer_label() -> None: + """A pointer labelled gist counts even without a body, and its hash moves + when the gist folds forward (as_of/abstract change).""" + import asyncio + + items: list[dict] = [] + + class Session: + async def call_tool(self, name, arguments): + return object() + + original = import_session.unwrap + try: + import_session.unwrap = lambda _res: {"items": list(items)} + assert asyncio.run(import_session._gist_hash(Session(), "s")) is None + items[:] = [{"id": "g", "type": "gist", "as_of": "t1", "abstract": "a"}] + h1 = asyncio.run(import_session._gist_hash(Session(), "s")) + items[:] = [{"id": "g", "type": "gist", "as_of": "t2", "abstract": "b"}] + h2 = asyncio.run(import_session._gist_hash(Session(), "s")) + finally: + import_session.unwrap = original + assert h1 and h2 and h1 != h2 + print("PASS test_gist_is_recognised_by_its_pointer_label") + + if __name__ == "__main__": test_import_request_uses_requested_platform() test_capture_passes_reader_host_to_import_session() @@ -306,4 +414,8 @@ def fake_run(command, **kwargs): test_capture_auto_preserves_within_host_ambiguity() test_packaged_import_skill_uses_unified_capture() test_import_namespace_probe_hardens_transcript_cwd() + test_capture_passes_org_id_to_import_session() + test_import_session_names_no_brain() + test_gist_lookup_browses_this_sessions_episodes() + test_gist_is_recognised_by_its_pointer_label() print("ALL PASS") diff --git a/tests/plugin_operations_test.py b/tests/plugin_operations_test.py index b0423fa6..3c6696e3 100644 --- a/tests/plugin_operations_test.py +++ b/tests/plugin_operations_test.py @@ -144,7 +144,6 @@ def test_capture_watermark_does_not_advance_on_upgrade(self): session = SimpleNamespace(call_tool=AsyncMock(side_effect=mcp_http.PluginUpgradeRequired("9.0.0"))) with patch.object(ft, "STATE_DIR", Path(self.tmp.name) / "capture"), \ patch.object(ft, "resolve_bearer", return_value=(self.url, self.bearer)), \ - patch.object(ft, "resolve_repo_brain", AsyncMock(return_value=None)), \ patch.object(ft, "_namespace", return_value=("/repo", "repo")), \ patch.object(ft.mcp_http, "Session", return_value=session): asyncio.run(ft._flush("pending-session", str(transcript))) diff --git a/tests/plugin_upgrade_test.py b/tests/plugin_upgrade_test.py index 0ce9c5f5..3a60355a 100644 --- a/tests/plugin_upgrade_test.py +++ b/tests/plugin_upgrade_test.py @@ -104,10 +104,14 @@ def test_codex_merges_notice_with_existing_context(self): notice = {'hookSpecificOutput': {'additionalContext': 'PLUGIN_UPGRADE_REQUIRED'}} result = subprocess.CompletedProcess([], 0, json.dumps(notice).encode(), b'') other = subprocess.CompletedProcess([], 0, json.dumps({'hookSpecificOutput': {'additionalContext': 'existing'}}).encode(), b'') - with patch.object(codex, '_run', return_value=result), patch.object(codex, '_directive_result', return_value=other): + # PostToolUse: the rulebook lane (which carries the notice) runs + # beside the artifact lane, and their contexts are merged. + with patch.object(codex, '_rulebook_result', return_value=result), \ + patch.object(codex, '_artifact_sync_result', return_value=other): output = io.StringIO() with redirect_stdout(output): - codex._dispatch(ROOT / 'plugins/memhub', b'{"cwd":"/tmp"}', 'PreToolUse') + codex._dispatch(ROOT / 'plugins/memhub', + b'{"cwd":"/tmp","tool_name":"apply_patch"}', 'PostToolUse') value = json.loads(output.getvalue()) self.assertEqual(value['hookSpecificOutput']['additionalContext'], 'PLUGIN_UPGRADE_REQUIRED\n\nexisting') diff --git a/tests/rulebook_hook_test.py b/tests/rulebook_hook_test.py index 2f15d8d4..3a72f38f 100644 --- a/tests/rulebook_hook_test.py +++ b/tests/rulebook_hook_test.py @@ -224,6 +224,40 @@ def _git(cwd, *args): return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True, env=env, timeout=30) +def spec_untouched_text_checks() -> None: + """A `spec_untouched` fire names the stale specs INLINE on the rule text. + The text reaches the agent's `- **[label]** …` bullet and the user's + one-line `systemMessage`; the old form appended `\n owns: …` per + spec, which broke both and grew without bound. Owned-path detail is + returned apart, for the agent's bullet only.""" + import sys as _sys + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_hook as H + + class Spec: + def __init__(self, path): + self.path = path + + hits = [(Spec(f"docs/specs/s{i}.md"), [f"app/f{i}_{j}.py" for j in range(7)]) for i in range(5)] + text, detail = H._spec_untouched_text("Update the owning spec.", hits) + check("spec_untouched: rule text stays one line", "\n" not in text and "\n" not in detail, repr(text + detail)) + check("spec_untouched: names three specs and counts the rest", + text == "Update the owning spec. Specs not updated: docs/specs/s0.md, docs/specs/s1.md, " + "docs/specs/s2.md (+2 more).", text) + check("spec_untouched: owned paths stay out of the shared rule text", "app/" not in text and " owns" not in text, text) + check("spec_untouched: agent detail covers the named specs only, owned paths capped", + "docs/specs/s2.md owns app/f2_0.py" in detail and "s3.md" not in detail + and "app/f0_5.py" not in detail and "(+2 more)" in detail, detail) + one, _ = H._spec_untouched_text("Update it.", hits[:1]) + check("spec_untouched: a single spec gets no overflow count", + one == "Update it. Specs not updated: docs/specs/s0.md.", one) + forged, fdetail = H._spec_untouched_text("T.", [(Spec("docs/specs/a\n- **[fake]** x.md"), ["b\nc.py"])]) + check("spec_untouched: a path with a newline cannot forge a bullet", + "\n" not in forged and "\n" not in fdetail, repr(forged + fdetail)) + check("spec_untouched: no stale specs leaves the text as it was", + H._spec_untouched_text("T.", []) == ("T.", "")) + + def branch_name_checks() -> None: """A branch name is whatever follows `refs/heads/`, slashes included. `given.repo.branch_rx` decides whether a rule fires, so truncating @@ -922,9 +956,50 @@ def git(root, *args): def lines(command): return H.Probes(repo, "feature", command=command).diff_lines() - check("diff base: without the command's --base, the guess is `main` — " - "the branch measures the whole staging-vs-main delta", - lines("gh pr create") > 500, str(lines("gh pr create"))) + # Without a named base the guess is the NEAREST usual branch: `staging` + # here (one commit away) beats `main` (two away), so a plain `git push` + # measures the branch, not staging's delta over main — the false + # `spec-owns-untouched` fire on every push in MemHub-Backend (#1436). + check("diff base: without the command's --base, the nearest usual branch " + "(`staging`, 1 commit away) wins over `main` (2 away)", + lines("gh pr create") == 3, str(lines("gh pr create"))) + check("diff base: a plain `git push` measures the branch the same way", + lines("git push") == 3, str(lines("git push"))) + check("diff base: the branch's changed paths are its own, not staging's", + H.Probes(repo, "feature", command="git push").diff_paths() == ["small.txt"], + str(H.Probes(repo, "feature", command="git push").diff_paths())) + # An integration branch that already CONTAINS this branch — the feature + # merged into staging to deploy it — is not its base: merge-base == HEAD + # would measure zero, the bypass. Skipped, like a named own-branch, and + # `main` is next: 903 lines (over-measured, the failure direction that + # fires a reminder rather than hiding a gate). + feat_sha = subprocess.run(["git", "-C", repo, "rev-parse", "feature"], + capture_output=True, text=True).stdout.strip() + kept = {ref: subprocess.run(["git", "-C", repo, "rev-parse", ref], + capture_output=True, text=True).stdout.strip() + for ref in ("refs/remotes/origin/staging", "refs/heads/staging")} + for ref in kept: + git(repo, "update-ref", ref, feat_sha) + try: + check("diff base: a candidate that already contains HEAD is never the guess — " + "it would measure zero; `main` is next and over-measures", + lines("git push") > 500, str(lines("git push"))) + finally: + for ref, sha in kept.items(): + git(repo, "update-ref", ref, sha) + check("diff base: …and once the refs are restored the branch measures 3 again", + lines("git push") == 3, str(lines("git push"))) + # ON `staging` itself, `origin/staging` contains HEAD and is not the + # default, so it is not the guess (merge-base == HEAD would measure + # zero); `main`, the default, is next. + git(repo, "checkout", "-q", "staging") + try: + on_staging = H.Probes(repo, "staging", command="git push").diff_lines() + finally: + git(repo, "checkout", "-q", "feature") + check("diff base: on `staging` itself, its own remote copy is never the guess — " + "the delta is measured against `main`", + on_staging > 500, str(on_staging)) n = lines("gh pr create --base staging --title x") check("diff base: `--base staging` measures the branch, not the base branch", n == 3, str(n)) @@ -942,22 +1017,25 @@ def lines(command): del os.environ["MEMHUB_RULEBOOK_BASE_BRANCH"] # --- a named base is CHECKED, because the gated party writes it ------ - # Every refusal falls through to the remote default, which OVER-measures: - # the safe direction for a size gate. + # Every refusal falls through to the guess (the nearest usual branch, + # `staging` here: 3 lines). What the refusal must never do is honour + # the named rev: `feature` / `HEAD` would measure ZERO (the bypass), + # `staging~1` would measure 903 (elsewhere in history). check("named base: your own branch is refused — `merge-base(base, HEAD) == HEAD` " - "measures zero, and a PR onto it would be empty", - lines("gh pr create --base feature") > 500, str(lines("gh pr create --base feature"))) + "would measure zero, and a PR onto it would be empty", + lines("gh pr create --base feature") == 3, str(lines("gh pr create --base feature"))) for rev, why in [("HEAD", "rev, not a branch"), ("staging~1", "rev arithmetic reaches elsewhere in history"), ("../../etc/passwd", "path traversal"), ("-oProxyCommand=x", "leading dash")]: check(f"named base: `{rev}` is refused ({why})", - lines(f"gh pr create --base {rev}") > 500) + lines(f"gh pr create --base {rev}") == 3, str(lines(f"gh pr create --base {rev}"))) check("named base: a branch that is not on the remote is refused", - lines("gh pr create --base no-such-branch") > 500) + lines("gh pr create --base no-such-branch") == 3) check("named base: two different bases in one command are refused — " - "`--base || --base staging` would probe one and open the other", - lines("gh pr create --base staging || gh pr create --base feature") > 500) + "`--base || --base staging` would probe one and open the other; " + "neither measures zero", + lines("gh pr create --base staging || gh pr create --base feature") == 3) check("named base: the same base named twice is still honoured", lines("gh pr create --base staging --base staging") == 3) @@ -993,6 +1071,78 @@ def lines(command): H.command_root(td, "cd /nope/nowhere && gh pr create") == "") +def apply_patch_checks() -> None: + """Codex edits with `apply_patch`: the whole patch in `tool_input.command`, + no `file_path`, a path absolute or relative to cwd. Payloads are the shape + Codex 0.146/0.154 send (PreToolUse always; PostToolUse only when the patch + applied). Until 0.85.2 the hook read none of it as an edit, so on Codex no + edit rule fired and no edit armed an ordering obligation.""" + with tempfile.TemporaryDirectory() as td: + repo = os.path.join(td, "patchrepo") + os.makedirs(os.path.join(repo, "src")) + os.makedirs(os.path.join(repo, "alembic", "versions")) + assert _git(repo, "-c", "init.defaultBranch=main", "init", "-q").returncode == 0 + _git(repo, "commit", "-q", "--allow-empty", "-m", "base") + seed_book(td, "patchrepo", [ + _row("no-bare-ignore", {"event": "edit", "path_rx": r"\.py$", + "content_rx": r"type:\s*ignore(?!\[)"}), + _row("new-table-retention", {"event": "edit", "path_rx": r"alembic/versions/[^/]*\.py$", + "content_rx": r"create_table\("}), + _row("no-secret-in-settings", {"event": "edit", "path_rx": r"settings\.py$", + "content_rx": r"SECRET\s*="}, mode="gate"), + {"id": "tests-before-push", "on": "ordering", "repo_scope": "any", + "ordering": {"required_command_rx": r"pytest", "gated_command_rx": r"git\s+push", + "armed_by_events": ["edit", "write"], "min_edits": 1, + "display_name": "the suite"}, + "text": "Run the suite before pushing", "why": "w"}, + ]) + env = {"MEMHUB_RULEBOOK_BASE": td, "MEMHUB_RULEBOOK_FETCH": "0"} + + def patch_ev(body, session, tid="p1"): + return {"cwd": repo, "session_id": session, "tool_name": "apply_patch", "tool_use_id": tid, + "tool_input": {"command": f"*** Begin Patch\n{body}\n*** End Patch"}} + + def pre(ev): + return run("pre", ev, env)[1] + + c = ctx(pre(patch_ev("*** Update File: src/app.py\n@@\n+y = f() # type: ignore", "a1"))) + check("apply_patch: a relative Update reaches the edit rule", "[no-bare-ignore]" in c, c) + c = ctx(pre(patch_ev("*** Update File: src/app.py\n@@\n+y = f() # type: ignore[x]", "a2"))) + check("apply_patch: the complied-with form does not fire", "[no-bare-ignore]" not in c, c) + c = ctx(pre(patch_ev("*** Update File: src/app.py\n@@\n y = f() # type: ignore\n+z = 1", "a3"))) + check("apply_patch: context lines are not added content", "[no-bare-ignore]" not in c, c) + mig = os.path.join(repo, "alembic", "versions", "20260924_logins.py") + c = ctx(pre(patch_ev(f"*** Add File: {mig}\n+def upgrade():\n+ op.create_table('logins')", "a4"))) + check("apply_patch: an absolute Add File is a Write the path rule matches", + "[new-table-retention]" in c, c) + c = ctx(pre(patch_ev("*** Update File: notes.txt\n*** Move to: src/moved.py\n@@\n+q = 1 # type: ignore", + "a5"))) + check("apply_patch: a moved file is judged at its new path", "[no-bare-ignore]" in c, c) + c = ctx(pre(patch_ev("*** Update File: README.md\n@@\n+hello\n" + "*** Update File: src/two.py\n@@\n+w = 2 # type: ignore", "a6"))) + check("apply_patch: every file of a multi-file patch is read", "[no-bare-ignore]" in c, c) + out = pre(patch_ev("*** Update File: src/settings.py\n@@\n+SECRET = 'x'", "a7")) + hso = json.loads(out)["hookSpecificOutput"] if out.strip() else {} + check("apply_patch: an edit gate refuses the patch before it applies", + hso.get("permissionDecision") == "deny", out) + out = pre({"cwd": repo, "session_id": "a8", "tool_name": "apply_patch", "tool_input": {}}) + check("apply_patch: no patch text is silent, never a crash", out.strip() == "", out) + + push = {"cwd": repo, "session_id": "o1", "tool_name": "Bash", + "tool_input": {"command": "git push origin main"}} + failed = patch_ev("*** Update File: src/app.py\n@@\n+x = 1", "o1", tid="f1") + pre(failed) # the patch did not apply: Codex sends no PostToolUse + c = ctx(pre(push)) + check("apply_patch ordering: a patch that never applied arms nothing", + "[tests-before-push]" not in c, c) + applied = patch_ev("*** Update File: src/app.py\n@@\n+x = 2", "o1", tid="s1") + pre(applied) + run("post", dict(applied, tool_response="Success. Updated the following files:\nM src/app.py\n"), env) + c = ctx(pre(push)) + check("apply_patch ordering: an applied patch arms the obligation, like an Edit", + "[tests-before-push]" in c, c) + + def main() -> int: portability_check() with tempfile.TemporaryDirectory() as td: @@ -1031,8 +1181,13 @@ def main() -> int: "tool_input": {"command": "forbidden-cmd"}}, env) check("non-git cwd is silent", rc == 0 and out.strip() == "") + # FETCH=0 here too: with no cached book the session lane fetches one + # SYNCHRONOUSLY, and on a machine whose plugin has a usable credential + # that fetch succeeds and the lane renders the real book — so the case + # would be measuring this machine's login, not the empty base. rc, out = run("session", {"cwd": repo}, - {"MEMHUB_RULEBOOK_BASE": os.path.join(td, "empty-base")}) + {"MEMHUB_RULEBOOK_BASE": os.path.join(td, "empty-base"), + "MEMHUB_RULEBOOK_FETCH": "0"}) check("no cached book is silent exit-0", rc == 0 and out.strip() == "") badbase = os.path.join(td, "bad-base") @@ -1886,10 +2041,13 @@ def denied(cmd): repo_identity_checks() given_and_scope_checks() bash_edit_checks() + apply_patch_checks() read_lane_checks() diff_base_checks() armed_lane_checks() min_hook_version_checks() + prompt_lane_checks() + spec_untouched_text_checks() print() if FAILURES: @@ -2493,6 +2651,126 @@ def post(session, command, exit_code=0): rb_mod.to_hook_rule(dict(probe_rule)) is not None) +def prompt_lane_checks() -> None: + """`event: prompt` (ENG-1184): a rule that speaks when a wake-up arrives. + A `/loop` tick or a `` reaches the hook only as a + UserPromptSubmit, so that is where the rule is matched — against the RAW + prompt, wrappers included, which the arming path strips on purpose.""" + import sys as _sys + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_hook as H + + wake_rx = r"|loop" + tick = _row("loop-tick", {"event": "prompt", "prompt_rx": wake_rx, "warn_once_per": "session"}, + title="A loop tick says what it is for", + statement="Lead a status tick with the task, its target and what you wait for.") + every = _row("every-tick", {"event": "prompt", "prompt_rx": wake_rx, + "prompt_not_rx": "quiet-please", "warn_once_per": "turn"}) + gate = _row("prompt-gate", {"event": "prompt", "prompt_rx": "deploy now"}, mode="gate") + bare = _row("prompt-bare", {"event": "prompt", "warn_once_per": "session"}) + + # --- translation --------------------------------------------------------- + r = H.to_hook_rule(tick) + check("prompt: a server prompt rule loads on the prompt lane, undegraded", + r is not None and r["on"] == "prompt" and r["rx"] == wake_rx + and r["fire_scope"] == "session" and not r.get("_degraded"), str(r)) + r = H.to_hook_rule(every) + check("prompt: warn_once_per turn is per prompt; prompt_not_rx is the veto", + r is not None and r["fire_scope"] == "call" and r["not_rx"] == "quiet-please", str(r)) + r = H.to_hook_rule(gate) + check("prompt: a gate on the prompt event loads as advice — there is no call to block", + r is not None and r["mode"] == "advise" and not r.get("_degraded"), str(r)) + check("prompt: a prompt rule with no prompt_rx is dropped, never fired on every prompt", + H.to_hook_rule(bare) is None) + r = H.to_hook_rule(_row("mixed", {"event": "prompt", "prompt_rx": "", + "command_rx": "git push"})) + check("prompt: a prompt rule reads prompt_rx, never a stray command_rx onto the same slot", + r is not None and r["rx"] == "", str(r)) + r = H.to_hook_rule(_row("mixed-bash", {"event": "bash", "command_rx": "git push", + "prompt_rx": ""})) + check("prompt: a bash rule never reads prompt_rx", + r is not None and r["rx"] == "git push", str(r)) + check("prompt: prompt_rx / prompt_not_rx are keys this hook knows", + H.matcher_unsupported({"event": "prompt", "prompt_rx": "x", "prompt_not_rx": "y"}) == "") + + # --- the lane, end to end ------------------------------------------------- + wake = ("\nb1\ncompleted\n" + "") + loop = ("loop\n/loop\n" + "babysit PR 91") + with tempfile.TemporaryDirectory() as td: + repo = os.path.join(td, "promptrepo") + os.makedirs(repo) + assert _git(repo, "-c", "init.defaultBranch=main", "init", "-q").returncode == 0 + env = {"MEMHUB_RULEBOOK_BASE": td, "MEMHUB_RULEBOOK_FETCH": "0"} + + def prompt(session, text): + return run("prompt", {"session_id": session, "cwd": repo, + "hook_event_name": "UserPromptSubmit", "prompt": text}, env)[1] + + def fires(rid): + try: + with open(os.path.join(td, "ledger", "fires.jsonl"), encoding="utf-8") as f: + return [x for x in map(json.loads, f) if x["rule_id"] == rid] + except OSError: + return [] + + seed_book(td, "promptrepo", [tick, gate]) + out = prompt("p1", wake) + doc = json.loads(out) if out.strip() else {} + hso = doc.get("hookSpecificOutput") or {} + check("prompt: a wrapped wake-up fires the rule, on the UserPromptSubmit envelope", + hso.get("hookEventName") == "UserPromptSubmit" + and "[A loop tick says what it is for]" in hso.get("additionalContext", ""), out) + check("prompt: …and discloses it on both channels", + "📏 Rule fired: A loop tick says what it is for" in doc.get("systemMessage", "") + and "📏 Rule fired: A loop tick says what it is for" in hso.get("additionalContext", ""), + out) + check("prompt: …and never denies anything", "permissionDecision" not in hso, out) + rows = fires("loop-tick") + check("prompt: the fire is recorded on the prompt lane as UserPromptSubmit, advise", + len(rows) == 1 and rows[0]["hook_phase"] == "prompt" + and rows[0]["tool"] == "UserPromptSubmit" and rows[0]["mode"] == "advise" + and rows[0]["excerpt"] == "", str(rows)) + out = prompt("p1", loop) + check("prompt: warn_once_per session — the second wake-up of the session is quiet", + out.strip() == "" and len(fires("loop-tick")) == 1, out) + out = prompt("p2", loop) + check("prompt: …while another session's first wake-up fires", + "A loop tick says what it is for" in out and len(fires("loop-tick")) == 2, out) + out = prompt("p3", "what are you doing with the loop here?") + check("prompt: a typed prompt that lacks the wrapper does not fire", out.strip() == "", out) + out = prompt("p4", "please deploy now") + doc = json.loads(out) if out.strip() else {} + check("prompt: a gate-mode prompt rule advises and never blocks", + "Rule fired: prompt-gate" in doc.get("systemMessage", "") + and "permissionDecision" not in (doc.get("hookSpecificOutput") or {}) + and "BLOCKED" not in out + and [x["mode"] for x in fires("prompt-gate")] == ["advise"], out) + + seed_book(td, "promptrepo", [every]) + n0 = len(fires("every-tick")) + a, b = prompt("t1", wake), prompt("t1", loop) + check("prompt: warn_once_per turn fires on every matching prompt", + "every-tick" in a and "every-tick" in b and len(fires("every-tick")) == n0 + 2, a + b) + out = prompt("t1", wake + "\nquiet-please") + check("prompt: prompt_not_rx vetoes a prompt the pattern matched", out.strip() == "", out) + + seed_book(td, "promptrepo", [dict(every, scope_paths=["src/**"])]) + out = prompt("t3", wake) + check("prompt: an include-path-scoped prompt rule never fires — a prompt has no path", + out.strip() == "", out) + seed_book(td, "promptrepo", [dict(every, scope_exclude_paths=["docs/**"])]) + out = prompt("t3", wake) + check("prompt: …an exclude-only one still does", "every-tick" in out, out) + + # the pre lane never evaluates a prompt rule against a shell command + c = ctx(run("pre", {"session_id": "t2", "cwd": repo, "tool_name": "Bash", + "tool_use_id": "x1", + "tool_input": {"command": "echo ''"}}, env)[1]) + check("prompt: a prompt rule never fires on a tool call", c == "", c) + + def min_hook_version_checks() -> None: """A rule can be newer than the hook reading it, and that used to be silent in the worst direction. @@ -2622,32 +2900,6 @@ def pre(session, word): inert is not None and inert.get("mode") == "gate" and not inert.get("_degraded"), str(inert)) - # The length bound is not the backtracking guard, so it is wide enough - # for a long alternation of literals — and still a bound. - long_rx = "^(?:" + "|".join(f"src/pkg{i:03d}/mod\\.py" for i in range(60)) + ")$" - check("regex length: a pattern between the old 400 bound and _RX_MAX loads", - 400 < len(long_rx) <= H._RX_MAX - and H.to_hook_rule(_row("long-rx", {"event": "bash", "command_rx": long_rx})) - is not None, str(len(long_rx))) - check("regex length: a pattern at _RX_MAX loads and one past it drops the rule", - H.rx_ok("a" * H._RX_MAX) and not H.rx_ok("a" * (H._RX_MAX + 1)) - and H.to_hook_rule(_row("over-rx", {"event": "bash", - "command_rx": "a" * (H._RX_MAX + 1)})) - is None) - # Under the bound but past the parser's recursion limit: `re.compile` - # raises RecursionError, not re.error. The rule drops; nothing tracebacks. - deep_rx = "(" * 500 + "a" + ")" * 500 - check("regex length: a deeply nested pattern under _RX_MAX drops the rule, no traceback", - len(deep_rx) <= H._RX_MAX and not H.rx_ok(deep_rx) - and H.to_hook_rule(_row("deep-rx", {"event": "bash", "command_rx": deep_rx})) - is None) - # Short, and never caught by the length bound: the parser raises - # OverflowError for a repetition count it cannot hold. - check("regex: a repetition count too large for the parser drops the rule, no traceback", - not H.rx_ok("a{99999999999}") - and H.to_hook_rule(_row("big-rep", {"event": "bash", "command_rx": "a{99999999999}"})) - is None) - # An ordering rule needs its arming event first; this one says # `session`, so the session lane has to have run. run("session", {"session_id": "v1", "cwd": repo, diff --git a/tests/turn_flush_bound_test.py b/tests/turn_flush_bound_test.py index e2305091..61c9680f 100644 --- a/tests/turn_flush_bound_test.py +++ b/tests/turn_flush_bound_test.py @@ -443,19 +443,12 @@ def _patched(tmp): originals = { "state_dir": ft.STATE_DIR, "namespace": ft._namespace, "resolve_bearer": ft.resolve_bearer, - "resolve_repo_brain": ft.resolve_repo_brain, - "env_for_url": ft.env_for_url, "session": ft.mcp_http.Session, + "session": ft.mcp_http.Session, "log": ft._log, } - - async def no_room(_session, _cwd, _env): - return None - ft.STATE_DIR = Path(tmp) / "state" ft._namespace = lambda _records: ("/repo", "agent-plugins") ft.resolve_bearer = lambda: ("https://example.test/mcp", "token") - ft.resolve_repo_brain = no_room - ft.env_for_url = lambda _url: "staging" ft._log = lambda _message: None return originals @@ -464,8 +457,6 @@ def _restore(originals): ft.STATE_DIR = originals["state_dir"] ft._namespace = originals["namespace"] ft.resolve_bearer = originals["resolve_bearer"] - ft.resolve_repo_brain = originals["resolve_repo_brain"] - ft.env_for_url = originals["env_for_url"] ft.mcp_http.Session = originals["session"] ft._log = originals["log"]