Files
mem0/integrations/mem0-agent/tests/test_generate.py
T
Deshraj YadavandClaude Fable 5 dda5f21332 fix(mem0-agent): bundle a launcher so hooks never depend on a global CLI
SessionStart failed with 'pyenv: mem0-agent: command not found' in any repo whose
.python-version selects a Python the console script was not installed into. A
pip entry point is the wrong dependency for a plugin.

- bin/mem0-agent: POSIX launcher that picks the first CPython >= 3.10 it can find
  and runs the package from source (there are no third-party dependencies)
- hooks now invoke ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent, matching how the v1
  plugin calls its own scripts; the generator enforces this
- app_id override renamed to MEM0_AGENT_APP_ID: v1 exports MEM0_PROJECT_ID, which
  was silently overriding this plugin's project scope while both are installed

Verified: all six hooks exit 0 from a pyenv-3.11 repo, and app_id resolves per
repository again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:20:28 -07:00

191 lines
6.8 KiB
Python

"""The hook manifests are generated, so these tests guard the generator and the spec.
v1's four hand-written manifests drifted; the drift test below is the mechanism that
makes that impossible now.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
PKG = Path(__file__).resolve().parents[1]
HOOKS = PKG / "hooks"
sys.path.insert(0, str(HOOKS))
import generate # noqa: E402
EXPECTED_EVENTS = {
"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "PreCompact", "SessionEnd",
}
@pytest.fixture(scope="module")
def spec():
return generate.load_spec()
@pytest.fixture(scope="module")
def manifest(spec):
return generate.build_manifest(spec, "claude-code")
# ------------------------------------------------------------------ spec parsing
def test_spec_parses_into_editors_and_hooks(spec):
assert isinstance(spec["hooks"], list) and spec["hooks"]
ids = [e["id"] for e in spec["editors"]]
assert ids[0] == "claude-code", "claude-code is the reference dialect and comes first"
assert {"cursor", "codex"} <= set(ids), "other editors stay declared so gaps are visible"
assert [e["id"] for e in generate.supported_editors(spec)] == ["claude-code"]
def test_every_hook_declares_its_contract(spec):
for entry in spec["hooks"]:
assert entry["why"], f"{entry['id']} must say why it exists"
assert isinstance(entry["local_only"], bool)
assert isinstance(entry["background"], bool)
assert entry["command"].startswith("${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent "), (
"hooks must call the plugin's bundled launcher, not a global console script "
"(a pyenv shim resolves against the directory's Python version and can vanish)"
)
def test_tiny_parser_handles_quotes_comments_and_nesting():
parsed = generate.parse_yaml(
'top: 1 # trailing comment\n'
'# whole line comment\n'
'block:\n'
' flag: true\n'
' none: null\n'
' text: "a: b # not a comment"\n'
'items:\n'
' - id: one\n'
' n: 2\n'
' - id: two\n'
' nested:\n'
' k: "v"\n'
)
assert parsed == {
"top": 1,
"block": {"flag": True, "none": None, "text": "a: b # not a comment"},
"items": [{"id": "one", "n": 2}, {"id": "two", "nested": {"k": "v"}}],
}
# --------------------------------------------------------------- manifest shape
def test_manifest_contains_every_declared_event(spec, manifest):
declared = {e["event"] for e in spec["hooks"]}
assert declared == EXPECTED_EVENTS
assert set(manifest["hooks"]) == declared
for event, groups in manifest["hooks"].items():
for group in groups:
for step in group["hooks"]:
assert step["type"] == "command", event
assert "mem0-agent " in step["command"]
assert isinstance(step["timeout"], int)
def test_manifest_matches_claude_code_schema(manifest):
start = manifest["hooks"]["SessionStart"][0]
assert start["matcher"] == "startup|resume|compact"
assert "--session-id" in start["hooks"][0]["command"]
# Events without a matcher must omit the key rather than emit null.
assert "matcher" not in manifest["hooks"]["Stop"][0]
assert manifest["hooks"]["PostToolUse"][0]["matcher"] == "Bash"
def test_user_prompt_submit_is_declared_local_only(spec, manifest):
entry = next(e for e in spec["hooks"] if e["event"] == "UserPromptSubmit")
assert entry["local_only"] is True
command = manifest["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
assert "MEM0_LOCAL_ONLY=1" in command, "the local-only contract must be machine-enforced"
assert entry["background"] is False and entry["blocking"] is True
def test_flush_and_assist_hooks_are_detached(spec, manifest):
for event in ("PostToolUse", "Stop", "PreCompact", "SessionEnd"):
command = manifest["hooks"][event][0]["hooks"][0]["command"]
assert command.startswith("(") and command.endswith("&)"), event
assert ">/dev/null" in command, event
def test_editor_env_is_pinned(manifest):
for groups in manifest["hooks"].values():
for group in groups:
for step in group["hooks"]:
assert "MEM0_EDITOR=claude-code" in step["command"]
def test_unsupported_editors_are_not_emitted(spec):
for ed in spec["editors"]:
if not ed["supported"]:
assert not generate.output_path(spec, ed["id"]).exists(), ed["id"]
# ------------------------------------------------------------------- validation
def test_validate_rejects_networked_user_prompt_hook(spec):
broken = json.loads(json.dumps(spec))
entry = next(e for e in broken["hooks"] if e["event"] == "UserPromptSubmit")
entry["local_only"] = False
with pytest.raises(generate.SpecError, match="local_only"):
generate.validate(broken)
def test_validate_rejects_unknown_hook_field(spec):
broken = json.loads(json.dumps(spec))
broken["hooks"][0]["retries"] = 3
with pytest.raises(generate.SpecError, match="unknown fields"):
generate.validate(broken)
def test_validate_rejects_background_and_blocking(spec):
broken = json.loads(json.dumps(spec))
broken["hooks"][0]["background"] = True
broken["hooks"][0]["blocking"] = True
with pytest.raises(generate.SpecError, match="cannot also be blocking"):
generate.validate(broken)
# ------------------------------------------------------------------------ drift
def test_committed_manifest_is_up_to_date(spec, manifest):
path = generate.output_path(spec, "claude-code")
assert path.exists(), "run `python3 hooks/generate.py`"
assert json.loads(path.read_text()) == manifest
def test_check_flag_passes_against_committed_file():
proc = subprocess.run(
[sys.executable, str(HOOKS / "generate.py"), "--check"],
capture_output=True, text=True, cwd=PKG,
)
assert proc.returncode == 0, proc.stderr
def test_check_flag_detects_drift(tmp_path, spec):
path = generate.output_path(spec, "claude-code")
original = path.read_text()
try:
path.write_text(original.replace('"timeout": 10', '"timeout": 99'))
proc = subprocess.run(
[sys.executable, str(HOOKS / "generate.py"), "--check"],
capture_output=True, text=True, cwd=PKG,
)
assert proc.returncode == 1
assert "DRIFT" in proc.stderr
finally:
path.write_text(original)
def test_generate_is_idempotent(spec):
path = generate.output_path(spec, "claude-code")
before = path.read_text()
proc = subprocess.run(
[sys.executable, str(HOOKS / "generate.py")], capture_output=True, text=True, cwd=PKG
)
assert proc.returncode == 0, proc.stderr
assert path.read_text() == before