Files
mempalace--mempalace/tests/test_antigravity_hooks_shell.py
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

1296 lines
47 KiB
Python

"""End-to-end shell tests for the Antigravity hook scripts.
Invokes the bash scripts directly via subprocess with synthetic stdin
JSON and asserts on their stdout / exit code / state-dir side effects.
The two scripts under test are:
* `hooks/antigravity/mempal_save_hook_antigravity.sh` — Stop event
* `hooks/antigravity/mempal_wake_hook_antigravity.sh` — PreInvocation event
Test isolation:
* Each test runs in its own temp dir.
* `MEMPAL_STATE_DIR` is overridden to point at the temp dir, so no
test ever touches the real `~/.mempalace/hook_state/`.
* `HOME` is overridden to a temp dir as well so the kill-switch
palace-existence check sees a hermetic state.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
HOOKS_DIR = REPO_ROOT / "hooks" / "antigravity"
SAVE_HOOK = HOOKS_DIR / "mempal_save_hook_antigravity.sh"
WAKE_HOOK = HOOKS_DIR / "mempal_wake_hook_antigravity.sh"
COMMON_LIB = HOOKS_DIR / "lib" / "common.sh"
# Skip the entire module on Windows — bash 3.2+ is required.
pytestmark = pytest.mark.skipif(
os.name == "nt",
reason="Antigravity shell hooks require bash; Windows uses a separate code path.",
)
def _run_hook(
script: Path,
stdin_json: dict | str,
state_dir: Path,
home: Path,
extra_env: dict[str, str] | None = None,
timeout: float = 10.0,
) -> subprocess.CompletedProcess:
"""Run a hook script with isolated env and synthetic stdin."""
if isinstance(stdin_json, dict):
stdin = json.dumps(stdin_json)
else:
stdin = stdin_json
env = os.environ.copy()
# Hermetic env: HOME and state dir point at the test temp.
home.mkdir(parents=True, exist_ok=True)
state_dir.mkdir(parents=True, exist_ok=True)
env["HOME"] = str(home)
env["MEMPAL_STATE_DIR"] = str(state_dir)
# Drop any leftover kill-switch envs from the user's environment so
# the test exercises the gate it intends to.
for k in ("MEMPAL_DISABLE_HOOK", "MEMPALACE_HOOKS_AUTO_SAVE", "MEMPAL_SAVE_INTERVAL"):
env.pop(k, None)
if extra_env:
env.update(extra_env)
return subprocess.run(
["bash", str(script)],
input=stdin,
capture_output=True,
text=True,
env=env,
timeout=timeout,
)
def _ensure_palace(home: Path) -> None:
"""Create $HOME/.mempalace/ so the palace-nuke kill switch passes."""
(home / ".mempalace").mkdir(parents=True, exist_ok=True)
def _poll_log_contains(log_path: Path, needle: str, timeout: float = 5.0) -> bool:
"""Poll a log file until it contains ``needle`` or the timeout elapses.
The save hook now writes mine/probe outcome lines from a detached
background subshell, so the foreground returns before those lines
are flushed. Callers that assert on background-written log lines
must poll rather than read once.
"""
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if log_path.is_file():
body = log_path.read_text(errors="replace")
if needle in body:
return True
time.sleep(0.05)
return False
def _stop_payload(**overrides) -> dict:
base = {
"executionNum": 1,
"terminationReason": "model_stop",
"error": "",
"fullyIdle": True,
"conversationId": "test-conv-001",
"workspacePaths": ["/tmp/test-workspace"],
"transcriptPath": "/tmp/test-transcript.jsonl",
"artifactDirectoryPath": "/tmp/test-artifacts/",
}
base.update(overrides)
return base
def _wake_payload(**overrides) -> dict:
base = {
"invocationNum": 1,
"initialNumSteps": 0,
"conversationId": "test-conv-001",
"workspacePaths": ["/tmp/test-workspace"],
"transcriptPath": "/tmp/test-transcript.jsonl",
"artifactDirectoryPath": "/tmp/test-artifacts/",
}
base.update(overrides)
return base
# ── Syntax (bash -n) ──────────────────────────────────────────────────
@pytest.mark.parametrize("script", [SAVE_HOOK, WAKE_HOOK, COMMON_LIB], ids=lambda p: p.name)
def test_bash_n_clean(script: Path) -> None:
"""All shell files parse cleanly under bash 3.2+."""
result = subprocess.run(
["bash", "-n", str(script)],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0, f"bash -n {script.name} failed:\n{result.stderr}"
# ── Save hook ─────────────────────────────────────────────────────────
def test_save_hook_emits_empty_object_on_kill_switch_env(tmp_path: Path) -> None:
"""MEMPAL_DISABLE_HOOK=1 should silently emit `{}` and exit 0."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}", result.stdout
def test_save_hook_emits_empty_object_on_auto_save_false(tmp_path: Path) -> None:
"""MEMPALACE_HOOKS_AUTO_SAVE=false short-circuits."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPALACE_HOOKS_AUTO_SAVE": "false"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_when_palace_dir_missing(tmp_path: Path) -> None:
"""Removing $HOME/.mempalace acts as the strongest kill switch."""
state = tmp_path / "state"
home = tmp_path / "home"
home.mkdir()
# Deliberately do NOT create ~/.mempalace
result = _run_hook(SAVE_HOOK, _stop_payload(), state_dir=state, home=home)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_when_config_disables(tmp_path: Path) -> None:
"""~/.mempalace/config.json `hooks.auto_save: false` short-circuits."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
(home / ".mempalace" / "config.json").write_text(
json.dumps({"hooks": {"auto_save": False}}),
encoding="utf-8",
)
result = _run_hook(SAVE_HOOK, _stop_payload(), state_dir=state, home=home)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_when_fully_idle_false(tmp_path: Path) -> None:
"""fullyIdle=False defers the save; nothing should write to state."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(fullyIdle=False),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
# The counter file must NOT exist — we deferred before incrementing.
counter = state / "antigravity_save_count_test-conv-001"
assert not counter.exists(), f"counter advanced despite fullyIdle=false: {counter}"
def test_save_hook_emits_empty_object_on_error_termination(tmp_path: Path) -> None:
"""terminationReason=error skips the save."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(terminationReason="error", error="model crashed"),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_on_malformed_stdin(tmp_path: Path) -> None:
"""Malformed JSON must not crash the hook — fail-open behaviour."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
"{not even close to json{",
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_on_empty_stdin(tmp_path: Path) -> None:
"""Empty stdin must not crash the hook."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(SAVE_HOOK, "", state_dir=state, home=home)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_never_emits_decision_continue(tmp_path: Path) -> None:
"""The save hook must NEVER emit `{"decision":"continue"}`.
That output would force Antigravity into an infinite agent
re-execution loop. Hard rule, separately tested.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(SAVE_HOOK, _stop_payload(), state_dir=state, home=home)
assert result.returncode == 0
# Parse the output so we don't false-match on substring of
# "Continue thread of work" or similar prose.
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
pytest.fail(f"save hook emitted non-JSON: {result.stdout!r}")
assert payload.get("decision") != "continue", (
f"save hook emitted decision=continue, which would force an infinite "
f"agent loop. payload={payload!r}"
)
def test_save_hook_counter_increments_per_fire(tmp_path: Path) -> None:
"""Counter advances on each Stop fire."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
counter_path = state / "antigravity_save_count_test-conv-001"
for expected in (1, 2, 3):
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "999"}, # high interval -> never trigger save
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
assert counter_path.is_file()
assert counter_path.read_text().strip() == str(expected)
def test_save_hook_floors_zero_save_interval_to_avoid_div_by_zero(tmp_path: Path) -> None:
"""MEMPAL_SAVE_INTERVAL=0 must be floored, never cause `count % 0`."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "0"},
)
# Must NOT crash with bash arithmetic divide-by-zero.
assert result.returncode == 0, (
f"save hook crashed on MEMPAL_SAVE_INTERVAL=0:\n"
f"stdout={result.stdout!r}\nstderr={result.stderr!r}"
)
assert result.stdout.strip() == "{}"
def test_save_hook_floors_negative_save_interval(tmp_path: Path) -> None:
"""Negative MEMPAL_SAVE_INTERVAL falls back to default (no crash)."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "-5"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}"
@pytest.mark.parametrize("interval", ["08", "09", "008", "0099"])
def test_save_hook_handles_leading_zero_save_interval(tmp_path: Path, interval: str) -> None:
"""MEMPAL_SAVE_INTERVAL with leading zeros must NOT trigger bash octal arithmetic.
bash arithmetic ($((COUNT % INTERVAL))) parses any token starting
with `0` as octal. Values like "08" or "09" are not valid octal
digits and would crash the modulo step with::
bash: 08: value too great for base (error token is "08")
mempal_save_interval() in lib/common.sh strips leading zeros before
returning. Regression test for gemini-code-assist review on PR
#1633.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": interval},
)
assert result.returncode == 0, (
f"save hook crashed on MEMPAL_SAVE_INTERVAL={interval!r}:\n"
f"stdout={result.stdout!r}\nstderr={result.stderr!r}"
)
assert result.stdout.strip() == "{}"
# Stderr must not contain the octal "value too great for base" error.
assert "value too great for base" not in result.stderr, (
f"bash octal parse error leaked through for MEMPAL_SAVE_INTERVAL={interval!r}: "
f"{result.stderr!r}"
)
def test_common_sh_parser_omits_sentinel_on_malformed_json(tmp_path: Path) -> None:
"""`mempal_parse_stdin` must NOT print the success sentinel on parse failure.
The bash callers detect parse failure by checking whether line 1
of the parser output is exactly ``__MEMPAL_PARSE_OK__``. If
json.load is wrapped in try/except (and falls back to data={}),
the sentinel still gets printed and the bash defense-in-depth
branch never engages. Regression test for gemini-code-assist
review on PR #1633.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
state.mkdir(parents=True, exist_ok=True)
# Source the lib and call mempal_parse_stdin with malformed JSON.
cmd = f". {COMMON_LIB}; mempal_parse_stdin '{{not even close to json{{'"
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
env={
**os.environ,
"HOME": str(home),
"MEMPAL_STATE_DIR": str(state),
},
timeout=10,
)
# The function itself shouldn't error (the inner Python crashes,
# but the subshell catches it). Stdout must NOT contain the sentinel.
assert "__MEMPAL_PARSE_OK__" not in result.stdout, (
f"parser printed success sentinel on bad JSON, defeating "
f"bash-side error detection: stdout={result.stdout!r}"
)
def test_save_hook_missing_mempalace_python_module_does_not_crash(tmp_path: Path) -> None:
"""When the resolved Python interpreter cannot run `-m mempalace`, fail open.
The save hook now invokes mempalace via ``"$MEMPAL_PYTHON_BIN"
-m mempalace mine ...`` rather than the bare ``mempalace`` console
script. If MEMPAL_PYTHON points at an interpreter that doesn't
have the package installed, the hook must log the failure and
still emit ``{}`` — never crash, never block the user.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
transcript = tmp_path / "transcript.jsonl"
transcript.write_text("{}\n", encoding="utf-8")
# Point MEMPAL_PYTHON at a stub interpreter that has no mempalace
# package installed — `python -m mempalace --version` will fail.
stub = tmp_path / "stub_python"
stub.write_text(
"#!/bin/sh\n"
"# Minimal python stub: rejects every -m invocation so the\n"
'# hook hits the "module unrunnable" branch.\n'
'case "$*" in\n'
' *"-m mempalace"*) exit 1 ;;\n'
' *) exec /usr/bin/env python3 "$@" ;;\n'
"esac\n",
encoding="utf-8",
)
stub.chmod(0o755)
result = _run_hook(
SAVE_HOOK,
_stop_payload(transcriptPath=str(transcript)),
state_dir=state,
home=home,
extra_env={
"MEMPAL_PYTHON": str(stub),
"MEMPAL_SAVE_INTERVAL": "1",
},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}"
# The "is not runnable" line is written by the detached background
# subshell now (the probe moved off the foreground), so poll for it.
log = state / "antigravity_hook.log"
assert _poll_log_contains(log, "is not runnable via"), (
f"expected the 'mempalace not runnable via $MEMPAL_PYTHON_BIN' log line; "
f"got:\n{log.read_text(errors='replace') if log.is_file() else '<no log>'}"
)
def test_save_hook_uses_python_module_invocation(tmp_path: Path) -> None:
"""The save hook source MUST invoke mempalace via `-m mempalace`.
Locks in the gemini-code-assist fix so a future edit doesn't
silently regress to the bare ``mempalace`` console-script call,
which fails when the user's PATH doesn't expose the venv bin.
"""
body = SAVE_HOOK.read_text(encoding="utf-8")
assert '"$MEMPAL_PYTHON_BIN" -m mempalace' in body, (
"save hook should invoke mempalace via $MEMPAL_PYTHON_BIN -m mempalace, "
"not the bare `mempalace` console script. The bare invocation breaks "
"when the venv's bin/ isn't on the hook's PATH."
)
# Also verify the bare invocation is gone (defense-in-depth).
# Allow `mempalace` to appear in comments / strings, but not as
# the start of a `nohup ... mempalace mine` command.
assert "nohup mempalace " not in body, (
"bare `nohup mempalace ...` invocation found; should be "
'`nohup "$MEMPAL_PYTHON_BIN" -m mempalace ...`'
)
def test_save_hook_backgrounds_probe_mine_and_cleanup_in_one_subshell(tmp_path: Path) -> None:
"""Probe + mine + marker cleanup must live in ONE detached subshell.
igorls' PR #1633 review: the foreground `mempalace --version`
probe pays the full chromadb/onnx cold-start import (the `mine`
subparser imports `mempalace.miner` before argparse handles
`--version`), which blows the save budget. Moving the probe into
the background subshell — together with the mine and the marker
cleanup — keeps the foreground instant.
Folding cleanup into the same subshell also retires the previous
`kill -0 $MINE_PID` watcher: the `rm -f "$PENDING_FILE"` runs
sequentially after the mine in the shell that owns it, so there is
no sibling-PID `wait`/`kill -0` hazard anymore. This test locks in
that structure.
"""
body = SAVE_HOOK.read_text(encoding="utf-8")
# The buggy sibling `wait` must stay gone.
assert 'wait "$MINE_PID"' not in body, (
'buggy `wait "$MINE_PID"` reappeared. POSIX wait cannot watch a sibling pid.'
)
# The kill -0 polling watcher is no longer needed and should be gone.
assert "kill -0" not in body, (
"the `kill -0` watcher should have been retired when probe+mine+cleanup "
"were folded into a single background subshell."
)
# Probe still happens (just inside the subshell now) and uses -m.
assert '"$MEMPAL_PYTHON_BIN" -m mempalace --version' in body, (
"the runnability probe must still run via $MEMPAL_PYTHON_BIN -m mempalace"
)
# The whole block is backgrounded: a subshell close followed by the
# detach redirection + `&`.
assert ") >/dev/null 2>&1 < /dev/null &" in body, (
"probe+mine+cleanup must be wrapped in a detached `( ... ) ... &` subshell"
)
# MINE_PID capture is no longer used (no separate watcher).
assert "MINE_PID=$!" not in body, (
"MINE_PID capture is vestigial now that there is no separate watcher"
)
def test_save_hook_returns_before_slow_version_probe(tmp_path: Path) -> None:
"""The hook must return immediately even if `--version` is slow.
Proves the probe was moved off the foreground. We point
MEMPAL_PYTHON at a stub that sleeps for 3s on any `-m mempalace`
invocation. If the probe still ran in the foreground the hook
would block ~3s; with the probe backgrounded it returns in well
under a second.
"""
import time
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
transcript = tmp_path / "transcript.jsonl"
transcript.write_text("{}\n", encoding="utf-8")
stub = tmp_path / "slow_python"
stub.write_text(
"#!/bin/sh\n"
"# Stub python: any -m mempalace call sleeps 3s, simulating a\n"
"# heavy cold-start import. Everything else proxies to python3.\n"
'case "$*" in\n'
' *"-m mempalace"*) sleep 3; exit 0 ;;\n'
' *) exec /usr/bin/env python3 "$@" ;;\n'
"esac\n",
encoding="utf-8",
)
stub.chmod(0o755)
start = time.monotonic()
result = _run_hook(
SAVE_HOOK,
_stop_payload(transcriptPath=str(transcript)),
state_dir=state,
home=home,
extra_env={"MEMPAL_PYTHON": str(stub), "MEMPAL_SAVE_INTERVAL": "1"},
timeout=10.0,
)
elapsed = time.monotonic() - start
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}"
assert elapsed < 2.0, (
f"save hook took {elapsed:.2f}s with a 3s --version probe; the probe "
f"is still running in the foreground instead of the background subshell."
)
def test_save_hook_counter_write_is_atomic(tmp_path: Path) -> None:
"""Counter is written via mempal_write_counter_atomic (temp + mv).
igorls' PR #1633 review: the counter was written with a plain
`printf > file` (truncate-then-write) while the comment claimed it
was atomic. We verify (a) the source uses the atomic helper, not a
bare redirect into the counter file, (b) the counter still
increments correctly across fires, and (c) no temp file is left
behind.
"""
body = SAVE_HOOK.read_text(encoding="utf-8")
assert 'mempal_write_counter_atomic "$COUNTER_FILE" "$COUNT"' in body, (
"save hook must write the counter via mempal_write_counter_atomic"
)
assert 'printf \'%s\' "$COUNT" > "$COUNTER_FILE"' not in body, (
"non-atomic `printf > $COUNTER_FILE` should have been replaced"
)
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
counter_path = state / "antigravity_save_count_test-conv-001"
for expected in (1, 2, 3):
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "999"},
)
assert result.returncode == 0
assert counter_path.read_text().strip() == str(expected)
# No leftover temp files from the atomic write.
temps = [
p.name
for p in state.iterdir()
if ".XXXXXX" in p.name or p.name.startswith("antigravity_save_count_test-conv-001.")
]
assert not temps, f"atomic counter write left temp files behind: {temps}"
def test_save_hook_helper_uses_temp_and_mv(tmp_path: Path) -> None:
"""mempal_write_counter_atomic must use a temp file + mv (not a bare redirect)."""
body = COMMON_LIB.read_text(encoding="utf-8")
assert "mempal_write_counter_atomic()" in body
assert "mktemp" in body, "atomic counter helper should create a temp file via mktemp"
assert "mv -f" in body, "atomic counter helper should promote the temp with mv -f"
def test_wake_hook_uses_sys_executable_module_invocation(tmp_path: Path) -> None:
"""The wake hook's inner Python must invoke mempalace via sys.executable -m.
Same rationale as the save hook fix: the bare ``mempalace``
console script fails when the venv's bin/ isn't on the hook's
PATH. Using ``[sys.executable, '-m', 'mempalace', ...]`` binds
the call to the same interpreter that resolved MEMPAL_PYTHON.
"""
body = WAKE_HOOK.read_text(encoding="utf-8")
assert "sys.executable, '-m', 'mempalace'" in body, (
"wake hook should invoke mempalace via [sys.executable, '-m', 'mempalace', ...], "
"not ['mempalace', ...]. The bare invocation breaks when the venv's bin/ "
"isn't on the hook's PATH."
)
assert "['mempalace', 'wake-up'" not in body, (
"bare ['mempalace', 'wake-up', ...] invocation found in wake hook"
)
def test_save_hook_rejects_traversal_in_transcript_path(tmp_path: Path) -> None:
"""A `..` segment in transcriptPath must be rejected."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Set interval to 1 so the modulo gate would normally fire on the
# first Stop, then prove the path validator stops the spawn.
result = _run_hook(
SAVE_HOOK,
_stop_payload(transcriptPath="/legit/../etc/passwd"),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
log = state / "antigravity_hook.log"
assert log.is_file()
log_body = log.read_text()
assert "invalid transcriptPath rejected" in log_body or "does not exist" in log_body
def test_save_hook_rejects_non_jsonl_transcript_path(tmp_path: Path) -> None:
"""A transcriptPath ending in something other than .json[l] is rejected."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(transcriptPath="/tmp/transcript.txt"),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_state_files_are_namespaced_antigravity(tmp_path: Path) -> None:
"""Every state file the save hook touches starts with `antigravity_`.
The shared state directory is also home to Claude Code, Codex, and
(in the future) Cursor hook state. Namespacing prevents collisions.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "999"},
)
assert result.returncode == 0
leaks = [p.name for p in state.iterdir() if not p.name.startswith("antigravity_")]
assert not leaks, f"save hook created non-antigravity-namespaced state files: {leaks}"
def test_save_hook_pending_marker_blocks_concurrent_save(tmp_path: Path) -> None:
"""A fresh pending marker should cause the next save to skip."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
pending = state / "antigravity_pending_test-conv-001"
state.mkdir(parents=True, exist_ok=True)
pending.touch()
# Force the modulo gate to fire by setting interval=1.
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
assert "pending save still in flight" in log_body
# ── Wake hook ─────────────────────────────────────────────────────────
def test_wake_hook_emits_empty_object_on_kill_switch(tmp_path: Path) -> None:
"""MEMPAL_DISABLE_HOOK=1 silences the wake hook."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
@pytest.mark.parametrize("invocation", [0, 2, 5, 100])
def test_wake_hook_emits_empty_when_invocation_num_not_one(tmp_path: Path, invocation: int) -> None:
"""Only invocationNum == 1 triggers injection."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
WAKE_HOOK,
_wake_payload(invocationNum=invocation),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}", (
f"wake hook injected at invocationNum={invocation}: {result.stdout!r}"
)
def test_wake_hook_loop_guard_prevents_repeat_injection(tmp_path: Path) -> None:
"""A second fire for the same conversationId must skip via the mkdir guard."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Pre-create the woke marker dir.
woke = state / "antigravity_woke_test-conv-001"
state.mkdir(parents=True, exist_ok=True)
woke.mkdir()
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
assert "already woke this conversation" in log_body
def test_wake_hook_never_emits_decision_field(tmp_path: Path) -> None:
"""The wake hook must never emit a `decision` key (that field is Stop-only)."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
)
assert result.returncode == 0
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
pytest.fail(f"wake hook emitted non-JSON: {result.stdout!r}")
assert "decision" not in payload, f"wake hook emitted a decision field: {payload!r}"
def test_wake_hook_emits_empty_when_mempalace_missing(tmp_path: Path) -> None:
"""When mempalace can't be run, the wake hook degrades to `{}`.
Antigravity's hook framework should never see a stack trace from
a missing CLI — emit `{}` and let the conversation start without
injection.
Note: the wake hook binds the wake-up call to
``[sys.executable, '-m', 'mempalace', ...]`` (the interpreter that
resolved MEMPAL_PYTHON), NOT a bare ``mempalace`` on PATH. So we
force MEMPAL_PYTHON="" (resolution falls back to ``python3`` on the
minimal PATH below) and strip PATH to a system python3 that has no
mempalace package installed. The inner ``python3 -m mempalace``
then fails and the hook must emit `{}`. We also override
MEMPAL_PYTHON explicitly so a value exported in the developer's
shell can't leak in and point at an interpreter that *does* have
mempalace.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Strip PATH down to just the bash + python essentials, dropping
# any directory that might have a `mempalace` binary, and clear
# MEMPAL_PYTHON so resolution falls back to this minimal PATH.
minimal_path = "/usr/bin:/bin"
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
extra_env={"PATH": minimal_path, "MEMPAL_PYTHON": ""},
)
assert result.returncode == 0, (
f"wake hook crashed when mempalace is missing:\n"
f"stdout={result.stdout!r}\nstderr={result.stderr!r}"
)
assert result.stdout.strip() == "{}"
def test_wake_hook_state_files_are_namespaced_antigravity(tmp_path: Path) -> None:
"""Wake hook state files are also namespaced."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(WAKE_HOOK, _wake_payload(), state_dir=state, home=home)
assert result.returncode == 0
leaks = [p.name for p in state.iterdir() if not p.name.startswith("antigravity_")]
assert not leaks, leaks
# ── Wing inference ────────────────────────────────────────────────────
def test_wing_inference_picks_first_workspace_path(tmp_path: Path) -> None:
"""Wing is derived from workspacePaths[0]'s leaf directory.
Antigravity sends an array; the first element is canonical.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Set interval=1 and a real existing transcript so the save path
# logs the inferred wing.
transcript = tmp_path / "transcript.jsonl"
transcript.write_text("{}\n", encoding="utf-8")
workspace = tmp_path / "myproj-with-dashes"
workspace.mkdir()
result = _run_hook(
SAVE_HOOK,
_stop_payload(
transcriptPath=str(transcript),
workspacePaths=[str(workspace), "/some/other/workspace"],
),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
# Hyphens become underscores; lowercase.
assert "wing=wing_myproj_with_dashes" in log_body, log_body
def test_wing_inference_defaults_to_sessions_when_workspace_empty(tmp_path: Path) -> None:
"""An empty workspacePaths array yields wing_sessions."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
transcript = tmp_path / "transcript.jsonl"
transcript.write_text("{}\n", encoding="utf-8")
result = _run_hook(
SAVE_HOOK,
_stop_payload(
transcriptPath=str(transcript),
workspacePaths=[],
),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
assert "wing=wing_sessions" in log_body
# ── Performance budget (soft) ─────────────────────────────────────────
@pytest.mark.skipif(sys.platform == "win32", reason="not relevant on Windows code path")
def test_save_hook_returns_quickly_under_kill_switch(tmp_path: Path) -> None:
"""Under the kill switch the hook should return well under 1s.
The integration brief budgets hooks at <500ms. We allow a generous
1500ms here because CI machines can be slow on cold-cache subprocess
spawn. The point of the test is to fail loudly if a future edit
introduces a synchronous mempalace import or DB connection.
"""
import time
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
start = time.monotonic()
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
elapsed = time.monotonic() - start
assert result.returncode == 0
assert result.stdout.strip() == "{}"
assert elapsed < 1.5, (
f"save hook under kill switch took {elapsed:.3f}s; expected < 1.5s. "
"A regression here usually means a synchronous import / DB connection "
"is happening before the kill-switch short-circuit."
)
# ── State-file GC (PR #1633 hygiene) ──────────────────────────────────
def _backdate(path: Path, days: int) -> None:
"""Set a path's atime/mtime ``days`` days into the past."""
import time
past = time.time() - days * 86400
os.utime(path, (past, past))
def test_gc_removes_stale_state_files(tmp_path: Path) -> None:
"""Stale per-conversation state (older than the TTL) is swept.
igorls' PR #1633 review flagged unbounded growth of
antigravity_save_count_*, antigravity_pending_*, and
antigravity_woke_* artifacts. mempal_gc_stale_state removes those
older than MEMPAL_STATE_TTL_DAYS (default 30).
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
state.mkdir(parents=True, exist_ok=True)
# Stale artifacts (40 days old) — all three shapes.
stale_count = state / "antigravity_save_count_old-conv"
stale_pending = state / "antigravity_pending_old-conv"
stale_woke = state / "antigravity_woke_old-conv"
stale_count.write_text("7", encoding="utf-8")
stale_pending.write_text("", encoding="utf-8")
stale_woke.mkdir()
for p in (stale_count, stale_pending, stale_woke):
_backdate(p, 40)
# Fresh artifacts must survive.
fresh_count = state / "antigravity_save_count_new-conv"
fresh_count.write_text("1", encoding="utf-8")
# Protected files must never be touched even when stale.
log = state / "antigravity_hook.log"
log.write_text("log line\n", encoding="utf-8")
_backdate(log, 99)
cmd = f". {COMMON_LIB}; mempal_gc_stale_state"
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
env={**os.environ, "HOME": str(home), "MEMPAL_STATE_DIR": str(state)},
timeout=10,
)
assert result.returncode == 0, result.stderr
assert not stale_count.exists(), "stale counter file not swept"
assert not stale_pending.exists(), "stale pending marker not swept"
assert not stale_woke.exists(), "stale woke marker dir not swept"
assert fresh_count.exists(), "fresh counter file was wrongly swept"
assert log.exists(), "protected hook.log was swept (name glob too broad)"
def test_gc_is_throttled_to_once_per_day(tmp_path: Path) -> None:
"""A fresh antigravity_last_sweep marker (<24h) skips the sweep."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
state.mkdir(parents=True, exist_ok=True)
# Fresh sweep marker — GC should bail before touching anything.
marker = state / "antigravity_last_sweep"
marker.write_text("", encoding="utf-8")
stale_count = state / "antigravity_save_count_old-conv"
stale_count.write_text("7", encoding="utf-8")
_backdate(stale_count, 40)
cmd = f". {COMMON_LIB}; mempal_gc_stale_state"
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
env={**os.environ, "HOME": str(home), "MEMPAL_STATE_DIR": str(state)},
timeout=10,
)
assert result.returncode == 0, result.stderr
# Throttled: stale file should still be present.
assert stale_count.exists(), (
"GC ran despite a fresh antigravity_last_sweep marker; throttle failed"
)
def test_gc_runs_when_marker_is_stale(tmp_path: Path) -> None:
"""A stale antigravity_last_sweep marker (>24h) lets the sweep run."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
state.mkdir(parents=True, exist_ok=True)
marker = state / "antigravity_last_sweep"
marker.write_text("", encoding="utf-8")
_backdate(marker, 2) # 2 days old -> stale
stale_count = state / "antigravity_save_count_old-conv"
stale_count.write_text("7", encoding="utf-8")
_backdate(stale_count, 40)
cmd = f". {COMMON_LIB}; mempal_gc_stale_state"
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
env={**os.environ, "HOME": str(home), "MEMPAL_STATE_DIR": str(state)},
timeout=10,
)
assert result.returncode == 0, result.stderr
assert not stale_count.exists(), "stale counter not swept despite stale marker"
def test_state_ttl_days_floors_and_strips(tmp_path: Path) -> None:
"""mempal_state_ttl_days validates input like mempal_save_interval."""
home = tmp_path / "home"
_ensure_palace(home)
def ttl(value: str | None) -> str:
env = {**os.environ, "HOME": str(home)}
if value is None:
env.pop("MEMPAL_STATE_TTL_DAYS", None)
else:
env["MEMPAL_STATE_TTL_DAYS"] = value
out = subprocess.run(
["bash", "-c", f". {COMMON_LIB}; mempal_state_ttl_days"],
capture_output=True,
text=True,
env=env,
timeout=10,
)
return out.stdout.strip()
assert ttl(None) == "30", "default TTL should be 30"
assert ttl("") == "30", "empty TTL falls back to 30"
assert ttl("abc") == "30", "garbage TTL falls back to 30"
assert ttl("7") == "7"
# Leading zeros must be stripped so `find -mtime +N` never sees octal-ish tokens.
assert ttl("007") == "7"
assert ttl("0") == "0"
def test_save_hook_calls_gc(tmp_path: Path) -> None:
"""The save hook wires mempal_gc_stale_state in and creates the sweep marker."""
body = SAVE_HOOK.read_text(encoding="utf-8")
assert "mempal_gc_stale_state" in body, "save hook must call mempal_gc_stale_state"
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "999"},
)
assert result.returncode == 0
assert (state / "antigravity_last_sweep").exists(), (
"save hook should have created the antigravity_last_sweep throttle marker"
)
def test_gc_does_not_run_under_kill_switch(tmp_path: Path) -> None:
"""When the kill switch trips, the save hook returns before GC runs."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
# GC runs after the kill-switch check, so no sweep marker is written.
assert not (state / "antigravity_last_sweep").exists(), (
"GC ran despite the kill switch being tripped"
)
# ── Python interpreter resolution (mempal_resolve_python) ─────────────
#
# The hooks run `"$MEMPAL_PYTHON_BIN" -m mempalace`, so MEMPAL_PYTHON_BIN
# must resolve to an interpreter that owns the mempalace package. The
# common install path — `uv tool install mempalace` / `pipx install` —
# puts the console scripts on PATH inside an ISOLATED env whose
# interpreter is NOT system python3. The resolver derives that
# interpreter from the console-script shebang so mining works without
# the user having to set MEMPAL_PYTHON. Regression coverage for the
# silent-skip bug a real user hit on PR #1633.
def _resolve_python(env: dict[str, str]) -> str:
"""Source common.sh under ``env`` and return the resolved MEMPAL_PYTHON_BIN."""
out = subprocess.run(
["bash", "-c", f'. {COMMON_LIB}; printf "%s" "$MEMPAL_PYTHON_BIN"'],
capture_output=True,
text=True,
env=env,
timeout=10,
)
assert out.returncode == 0, out.stderr
return out.stdout.strip()
def _make_fake_python(path: Path) -> Path:
"""Create an executable file whose basename looks like a Python interpreter."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\necho fake-python\n", encoding="utf-8")
path.chmod(0o755)
return path
def _make_console_script(path: Path, shebang_interp: str) -> Path:
"""Create a fake mempalace console script with the given shebang interpreter."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"#!{shebang_interp}\nprint('hi')\n", encoding="utf-8")
path.chmod(0o755)
return path
def test_resolve_python_derives_interpreter_from_console_script_shebang(
tmp_path: Path,
) -> None:
"""With MEMPAL_PYTHON unset, the resolver reads the mempalace-mcp shebang.
Simulates a `uv tool install` layout: the console script is on PATH
but its interpreter is an isolated Python, NOT the system python3
that PATH would otherwise resolve.
"""
home = tmp_path / "home"
_ensure_palace(home)
interp = _make_fake_python(tmp_path / "pyhome" / "python3.12")
bindir = tmp_path / "bin"
_make_console_script(bindir / "mempalace-mcp", str(interp))
env = {**os.environ, "HOME": str(home), "PATH": f"{bindir}:/usr/bin:/bin"}
env.pop("MEMPAL_PYTHON", None)
assert _resolve_python(env) == str(interp), (
"resolver should derive the interpreter from the mempalace-mcp "
"console-script shebang when MEMPAL_PYTHON is unset"
)
def test_resolve_python_prefers_mcp_script_over_path_python3(tmp_path: Path) -> None:
"""The shebang-derived interpreter must win over a system python3 on PATH.
This is the crux of the fix: a system python3 is present (and would
be picked by the old resolver) but lacks the package, while the
console script's interpreter owns it.
"""
home = tmp_path / "home"
_ensure_palace(home)
interp = _make_fake_python(tmp_path / "pyhome" / "python3.12")
bindir = tmp_path / "bin"
_make_console_script(bindir / "mempalace-mcp", str(interp))
# A decoy python3 earlier on PATH must be ignored in favour of the shebang.
_make_fake_python(bindir / "python3")
env = {**os.environ, "HOME": str(home), "PATH": f"{bindir}:/usr/bin:/bin"}
env.pop("MEMPAL_PYTHON", None)
assert _resolve_python(env) == str(interp), (
"shebang-derived interpreter must take precedence over a python3 on PATH"
)
def test_resolve_python_override_beats_shebang(tmp_path: Path) -> None:
"""An explicit MEMPAL_PYTHON override always wins over shebang derivation."""
home = tmp_path / "home"
_ensure_palace(home)
override = _make_fake_python(tmp_path / "override" / "python3")
interp = _make_fake_python(tmp_path / "pyhome" / "python3.12")
bindir = tmp_path / "bin"
_make_console_script(bindir / "mempalace-mcp", str(interp))
env = {
**os.environ,
"HOME": str(home),
"PATH": f"{bindir}:/usr/bin:/bin",
"MEMPAL_PYTHON": str(override),
}
assert _resolve_python(env) == str(override), (
"MEMPAL_PYTHON override must take precedence over the console-script shebang"
)
def test_resolve_python_rejects_env_style_shebang(tmp_path: Path) -> None:
"""A `#!/usr/bin/env python3` wrapper must be skipped, not used verbatim.
The first shebang token would be `/usr/bin/env`, which is not a
Python interpreter. The resolver must reject it (basename guard) and
fall through to python3 on PATH rather than trying to run
`/usr/bin/env -m mempalace`.
"""
home = tmp_path / "home"
_ensure_palace(home)
bindir = tmp_path / "bin"
_make_console_script(bindir / "mempalace-mcp", "/usr/bin/env python3")
env = {**os.environ, "HOME": str(home), "PATH": f"{bindir}:/usr/bin:/bin"}
env.pop("MEMPAL_PYTHON", None)
resolved = _resolve_python(env)
assert resolved != "/usr/bin/env", "resolver must not return /usr/bin/env"
assert os.path.basename(resolved).startswith("python"), (
f"resolver should fall back to a python3 on PATH; got {resolved!r}"
)
def test_resolve_python_skips_shebang_interp_that_is_not_executable(
tmp_path: Path,
) -> None:
"""A shebang pointing at a missing/non-executable interpreter is skipped.
Guards against a stale console script whose interpreter was deleted:
the resolver must fall through to python3 rather than returning a
dead path.
"""
home = tmp_path / "home"
_ensure_palace(home)
bindir = tmp_path / "bin"
missing = tmp_path / "pyhome" / "python3.12" # never created -> not -x
_make_console_script(bindir / "mempalace-mcp", str(missing))
env = {**os.environ, "HOME": str(home), "PATH": f"{bindir}:/usr/bin:/bin"}
env.pop("MEMPAL_PYTHON", None)
resolved = _resolve_python(env)
assert resolved != str(missing), "resolver returned a non-executable shebang interp"
assert os.path.basename(resolved).startswith("python"), (
f"resolver should fall back to python3 on PATH; got {resolved!r}"
)
def test_resolve_python_falls_back_to_path_python3_without_console_scripts(
tmp_path: Path,
) -> None:
"""With no mempalace console scripts on PATH, resolve to python3 (prior behaviour)."""
home = tmp_path / "home"
_ensure_palace(home)
env = {**os.environ, "HOME": str(home), "PATH": "/usr/bin:/bin"}
env.pop("MEMPAL_PYTHON", None)
resolved = _resolve_python(env)
assert os.path.basename(resolved).startswith("python"), (
f"resolver should fall back to python3 on PATH; got {resolved!r}"
)