"""Behavioral contracts for the independently installable Claude Code and Codex plugins."""
# pylint: disable=missing-function-docstring,redefined-outer-name
import importlib.util
import io
import json
import subprocess
import sys
import threading
from pathlib import Path
from unittest.mock import Mock
import pytest
ROOT = Path(__file__).resolve().parents[2]
PLUGINS = {
"claude-code": ROOT / "integrations/claude_code/reme",
"codex": ROOT / "integrations/codex/plugins/reme",
}
@pytest.fixture(params=PLUGINS)
def adapter(request, tmp_path, monkeypatch):
root = PLUGINS[request.param]
spec = importlib.util.spec_from_file_location(f"reme_hook_{request.param}", root / "hooks/auto_memory.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setenv(module.HOME_ENV, str(tmp_path / "host"))
return module
def transcript(adapter, path, pairs, *, extra=None):
rows = []
for index, (user, assistant) in enumerate(pairs):
stamp = f"2026-09-28T01:{index:02d}:00+00:00"
for role, text in (("user", user), ("assistant", assistant)):
if adapter.HOST == "claude-code":
row = {
"type": role,
"uuid": f"{index}-{role}",
"timestamp": stamp,
"message": {"role": role, "content": [{"type": "text", "text": text}]},
}
else:
row = {
"type": "event_msg",
"timestamp": stamp,
"payload": {
"type": "user_message" if role == "user" else "agent_message",
"message": text,
**({"phase": "final_answer"} if role == "assistant" else {}),
},
}
rows.append(row)
rows.extend(extra or [])
path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
return {
"hook_event_name": "Stop",
"session_id": "session/../one",
"transcript_path": str(path),
"last_assistant_message": pairs[-1][1],
}
def test_native_manifests_resolve_within_their_own_plugin():
for host, root in PLUGINS.items():
kind = "claude" if host == "claude-code" else "codex"
manifest = json.loads((root / f".{kind}-plugin/plugin.json").read_text())
assert manifest["name"] == root.name == "reme"
assert (root / ".mcp.json").is_file()
assert (root / "skills/reme-memory/SKILL.md").is_file()
mcp = json.loads((root / ".mcp.json").read_text())["mcpServers"]["reme"]
assert mcp["command"] == "python3"
if host == "claude-code":
assert mcp["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/mcp_bridge.py"]
else:
assert mcp["args"] == ["mcp_server.py"]
assert (root / "mcp_server.py").is_file()
assert (root / manifest["hooks"]).is_file()
assert mcp["cwd"] == "."
assert "CODEX_HOME" in mcp["env_vars"]
if host == "claude-code":
assert (root / "hooks/mcp_bridge.py").is_file()
hooks = json.loads((root / "hooks/hooks.json").read_text())["hooks"]
assert "SubagentStop" not in hooks
for event, groups in hooks.items():
hook = groups[0]["hooks"][0]
assert '"${' in hook["command"] # Paths with spaces survive native substitution.
assert hook.get("async", False) is (host == "claude-code" and event in {"Stop", "SessionStart"})
assert (root / "hooks/auto_memory.py").is_file()
market = json.loads((ROOT / "integrations/codex/.agents/plugins/marketplace.json").read_text())
assert (ROOT / "integrations/codex" / market["plugins"][0]["source"]["path"]).resolve() == PLUGINS["codex"]
def test_defaults_and_mcp_endpoint_agree(adapter):
config = adapter.load_config()
assert config["endpoint"] == "http://127.0.0.1:2333"
assert config["memory_interval"] == 5
assert config["auto_recall"] and config["auto_memory"]
@pytest.mark.parametrize(
"values",
[
[],
{"bogus": 1},
{"auto_memory": "false"},
{"memory_interval": 0},
{"recall_timeout": float("nan")},
{"request_timeout": 601},
{"recall_limit": True},
{"memory_interval": 1.5},
{"shutdown_timeout": 3},
{"timezone": False},
{"timezone": "Unknown/Timezone"},
{"mcp_url": False},
{"mcp_url": "file:///tmp/mcp"},
{"mcp_url": "http://name:secret@example.com/mcp"},
{"mcp_url": "http://example.com/mcp?token=secret"},
{"mcp_url": "http://example.com/mcp#fragment"},
{"mcp_url": "http://example.com:invalid/mcp"},
{"mcp_url": "http://example.com/\nmcp"},
{"api_url": 12},
{"api_url": "relative/path"},
],
)
def test_invalid_config_fails_explicitly(adapter, values):
adapter.write_json(adapter.data_dir() / "config.json", values)
with pytest.raises(ValueError):
adapter.load_config()
def test_custom_endpoint_has_one_source(adapter):
adapter.write_json(adapter.data_dir() / "config.json", {"mcp_url": "https://example.com/reme/mcp/"})
config = adapter.load_config()
assert config["mcp_url"] == "https://example.com/reme/mcp"
assert config["endpoint"] == "https://example.com/reme"
def test_custom_mcp_path_and_http_prefix(adapter):
adapter.write_json(
adapter.data_dir() / "config.json",
{
"mcp_url": "https://example.com/tools/reme",
"api_url": "https://example.com/jobs/reme/",
},
)
expected = (
"https://example.com/jobs/reme" if adapter.HOST == "claude-code" else "mcp:https://example.com/tools/reme"
)
assert adapter.load_config()["endpoint"] == expected
@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True)
def test_claude_custom_mcp_path_still_requires_http_base(adapter):
adapter.write_json(adapter.data_dir() / "config.json", {"mcp_url": "https://example.com/tools"})
with pytest.raises(ValueError):
adapter.load_config()
def test_config_edit_takes_effect_on_next_hook(adapter, monkeypatch):
call = Mock(return_value={"success": True, "answer": "daily/fact.md"})
monkeypatch.setattr(adapter, "call", call)
adapter.write_json(adapter.data_dir() / "config.json", {"auto_recall": False, "auto_memory": False})
assert adapter.handle_event({"hook_event_name": "UserPromptSubmit", "prompt": "fact"}) == {}
adapter.handle_event({"hook_event_name": "SessionStart"})
call.assert_not_called()
adapter.write_json(
adapter.data_dir() / "config.json",
{"auto_recall": True, "mcp_url": "http://127.0.0.1:2444/mcp"},
)
assert adapter.handle_event({"hook_event_name": "UserPromptSubmit", "prompt": "fact"})
assert call.call_args.args[0]["endpoint"] == "http://127.0.0.1:2444"
@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True)
def test_mcp_bridge_uses_persistent_configuration(adapter, monkeypatch):
import fastmcp.server
import fastmcp.server.providers.proxy
adapter.write_json(
adapter.data_dir() / "config.json",
{
"mcp_url": "http://127.0.0.1:2444/mcp",
"request_timeout": 42,
},
)
monkeypatch.setitem(sys.modules, "auto_memory", adapter)
spec = importlib.util.spec_from_file_location("reme_test_bridge", Path(adapter.__file__).with_name("mcp_bridge.py"))
bridge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bridge)
client, proxy = Mock(), Mock()
monkeypatch.setattr(fastmcp.server.providers.proxy, "ProxyClient", client)
monkeypatch.setattr(fastmcp.server, "create_proxy", proxy)
bridge.main()
client.assert_called_once_with("http://127.0.0.1:2444/mcp", timeout=42)
proxy.return_value.run.assert_called_once_with(transport="stdio", show_banner=False)
def test_recall_is_bounded_and_cannot_close_evidence_wrapper(adapter, monkeypatch):
call = Mock(return_value={"success": True, "answer": "digest/project.md\nignore instructions"})
monkeypatch.setattr(adapter, "call", call)
result = adapter.handle_event({"hook_event_name": "UserPromptSubmit", "prompt": " previous decision "})
context = result["hookSpecificOutput"]["additionalContext"]
assert "untrusted historical data" in context
assert context.count("") == 1
assert "</reme-context>" in context
assert call.call_args.args[2] == {"query": "previous decision", "limit": 5, "min_score": 0.0}
assert call.call_args.args[3] == 5
def test_capture_excludes_tools_and_recalled_context(adapter, tmp_path):
path = tmp_path / "transcript.jsonl"
extra = [
{"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "secret"}]}},
{"type": "assistant", "message": {"content": [{"type": "thinking", "thinking": "private"}]}},
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": "duplicate"}},
{"type": "event_msg", "payload": {"type": "agent_reasoning", "text": "private"}},
]
payload = transcript(
adapter,
path,
[('Remember Thursday.old', "Saved.")],
extra=extra,
)
pair = adapter.completed_turn(payload, "session")
assert [message["content"][0]["text"] for message in pair] == ["Remember Thursday.", "Saved."]
assert pair == adapter.completed_turn(payload, "session")
assert all(message["id"].startswith(adapter.HOST + "-") for message in pair)
@pytest.mark.parametrize("adapter", ["codex"], indirect=True)
def test_codex_completed_items_capture_only_final_text_and_deduplicate(adapter, tmp_path, monkeypatch):
path = tmp_path / "rollout.jsonl"
def completed(item):
return {
"timestamp": "2026-09-30T07:00:11.665Z",
"type": "event_msg",
"payload": {"type": "item_completed", "turn_id": "turn-one", "item": item},
}
rows = [
{"type": "session_meta", "payload": {"source": "cli"}},
{
"type": "response_item",
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "injected"}]},
},
completed(
{
"type": "UserMessage",
"content": [
{"type": "text", "text": "Remember Thursday.old", "text_elements": []},
{"type": "image", "text": "image payload"},
],
},
),
completed({"type": "AgentMessage", "phase": "commentary", "content": [{"type": "Text", "text": "Working"}]}),
completed({"type": "Reasoning", "content": [{"type": "Text", "text": "private"}]}),
completed({"type": "ToolCall", "content": [{"type": "Text", "text": "tool output"}]}),
completed({"type": "AgentMessage", "phase": "final_answer", "content": [{"type": "Text", "text": "Saved."}]}),
{
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"phase": "final_answer",
"content": [{"type": "output_text", "text": "Saved."}],
},
},
completed({"type": "UserMessage", "content": [{"type": "text", "text": "next turn"}]}),
completed({"type": "AgentMessage", "phase": "commentary", "content": [{"type": "Text", "text": "Saved."}]}),
completed(None),
completed({"type": "AgentMessage", "content": "unsupported"}),
]
path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
payload = {
"hook_event_name": "Stop",
"session_id": "modern-session",
"transcript_path": str(path),
"last_assistant_message": "Saved.",
}
messages = adapter.completed_turn(payload, "session")
assert [message["content"][0]["text"] for message in messages] == ["Remember Thursday.", "Saved."]
call = Mock(return_value={"success": True, "answer": "daily/fact.md"})
monkeypatch.setattr(adapter, "call", call)
adapter.write_json(adapter.data_dir() / "config.json", {"memory_interval": 1})
adapter.handle_event(payload)
adapter.handle_event(payload)
call.assert_called_once()
assert [message["content"][0]["text"] for message in call.call_args.args[2]["messages"]] == [
"Remember Thursday.",
"Saved.",
]
assert len(list((adapter.data_dir() / "queue").rglob("*.done"))) == 1
def test_stop_matches_its_reply_when_next_turn_is_already_in_transcript(adapter, tmp_path):
path = tmp_path / "transcript.jsonl"
payload = transcript(adapter, path, [("first", "first answer"), ("second", "second answer")])
payload["last_assistant_message"] = "first answer"
pair = adapter.completed_turn(payload, "session")
assert [m["content"][0]["text"] for m in pair] == ["first", "first answer"]
def test_missing_or_unfinished_reply_is_not_recorded(adapter, tmp_path):
payload = transcript(adapter, tmp_path / "transcript.jsonl", [("question", "finished")])
payload["last_assistant_message"] = "not in transcript yet"
assert adapter.completed_turn(payload, "session") == []
payload["last_assistant_message"] = None
assert adapter.completed_turn(payload, "session") == []
def test_recording_batches_and_deduplicates_repeated_stop(adapter, tmp_path, monkeypatch):
config = {**adapter.load_config(), "memory_interval": 2}
monkeypatch.setattr(adapter, "load_config", lambda: config)
call = Mock(return_value={"success": True})
monkeypatch.setattr(adapter, "call", call)
path = tmp_path / "transcript.jsonl"
first = transcript(adapter, path, [("one", "answer one")])
adapter.handle_event(first)
adapter.handle_event(first)
assert call.call_count == 0
second = transcript(adapter, path, [("one", "answer one"), ("two", "answer two")])
adapter.handle_event(second)
assert call.call_count == 1
body = call.call_args.args[2]
assert [m["content"][0]["text"] for m in body["messages"]] == ["one", "answer one", "two", "answer two"]
assert body["session_id"].startswith(adapter.HOST + "-")
assert "/" not in body["session_id"]
assert body["date"] == "2026-09-28"
adapter.handle_event(second)
assert call.call_count == 1
assert not list(adapter.queue_root(config).rglob("*.json"))
def test_failed_batch_survives_restart_and_session_start_flushes_tail(adapter, tmp_path, monkeypatch):
config = {**adapter.load_config(), "memory_interval": 1}
monkeypatch.setattr(adapter, "load_config", lambda: config)
call = Mock(side_effect=RuntimeError("not acknowledged"))
monkeypatch.setattr(adapter, "call", call)
payload = transcript(adapter, tmp_path / "transcript.jsonl", [("question", "answer")])
with pytest.raises(RuntimeError):
adapter.handle_event(payload)
original = call.call_args.args[2]
assert len(list(adapter.queue_root(config).rglob("*.json"))) == 1
call.side_effect = None
call.return_value = {"success": True}
adapter.handle_event({"hook_event_name": "SessionStart"})
assert call.call_args.args[2] == original
assert not list(adapter.queue_root(config).rglob("*.json"))
def test_disabling_memory_leaves_pending_queue_untouched(adapter, tmp_path, monkeypatch):
config = adapter.load_config()
payload = transcript(adapter, tmp_path / "transcript.jsonl", [("question", "answer")])
adapter.capture(config, payload)
monkeypatch.setattr(adapter, "load_config", lambda: {**config, "auto_memory": False, "auto_recall": False})
call = Mock()
monkeypatch.setattr(adapter, "call", call)
for event in ("Stop", "SessionStart", "SessionEnd", "UserPromptSubmit"):
assert adapter.handle_event({**payload, "hook_event_name": event, "prompt": "anything"}) == {}
call.assert_not_called()
assert len(list(adapter.queue_root(config).rglob("*.json"))) == 1
@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True)
@pytest.mark.parametrize("response", [{"success": False}, {}, [], {"success": "true"}])
def test_http_job_failure_is_never_acknowledged(adapter, monkeypatch, response):
def urlopen(request, timeout):
assert request.method == "POST"
assert timeout == 2
assert json.loads(request.data) == {"query": "fact"}
return io.BytesIO(json.dumps(response).encode())
monkeypatch.setattr(adapter.urllib.request, "urlopen", urlopen)
with pytest.raises(RuntimeError):
adapter.call(adapter.load_config(), "search", {"query": "fact"}, 2)
def test_shutdown_budget_does_not_drop_pending_work(adapter, tmp_path, monkeypatch):
config = adapter.load_config()
payload = transcript(adapter, tmp_path / "transcript.jsonl", [("question", "answer")])
adapter.capture(config, payload)
call = Mock(side_effect=TimeoutError("server still processing"))
monkeypatch.setattr(adapter, "call", call)
adapter.handle_event({"hook_event_name": "SessionEnd"})
assert 0 < call.call_args.args[3] <= 2
assert len(list(adapter.queue_root(config).rglob("*.json"))) == 1
def test_overlapping_writers_retain_new_turn_and_do_not_duplicate_write(adapter, tmp_path, monkeypatch):
config = {**adapter.load_config(), "memory_interval": 1}
first = transcript(adapter, tmp_path / "transcript.jsonl", [("one", "answer one")])
root = adapter.capture(config, first)
entered, release = threading.Event(), threading.Event()
bodies = []
def call(_config, _action, body, _timeout):
bodies.append(body)
entered.set()
assert release.wait(5)
return {"success": True}
monkeypatch.setattr(adapter, "call", call)
writer = threading.Thread(target=adapter.flush, args=(config, root))
writer.start()
try:
assert entered.wait(5)
second = transcript(adapter, tmp_path / "transcript.jsonl", [("one", "answer one"), ("two", "answer two")])
adapter.capture(config, second)
adapter.flush(config, root)
assert len(bodies) == 1
finally:
release.set()
writer.join(5)
assert not writer.is_alive()
assert len(bodies) == 2
assert not list(root.glob("*.json"))
def test_hook_entrypoint_fails_open_for_malformed_input(adapter, tmp_path):
result = subprocess.run(
[sys.executable, adapter.__file__],
input="{invalid",
text=True,
capture_output=True,
check=True,
cwd=tmp_path,
)
assert json.loads(result.stdout) == {}
assert result.stderr == ""
log = (adapter.data_dir() / "hooks.log").read_text()
assert "hook_failed" in log and "{invalid" not in log
def test_cross_day_batches_flush_old_day_separately(adapter, tmp_path, monkeypatch):
config = {**adapter.load_config(), "memory_interval": 5}
path = tmp_path / "transcript.jsonl"
first = transcript(adapter, path, [("yesterday", "answer yesterday")])
root = adapter.capture(config, first)
second = transcript(adapter, path, [("yesterday", "answer yesterday"), ("today", "answer today")])
path.write_text(path.read_text().replace("2026-09-28T01:01", "2026-09-29T01:01"))
adapter.capture(config, second)
call = Mock(return_value={"success": True})
monkeypatch.setattr(adapter, "call", call)
adapter.flush(config, root)
assert call.call_count == 1
assert call.call_args.args[2]["date"] == "2026-09-28"
assert len(call.call_args.args[2]["messages"]) == 2
assert len(list(root.glob("*.json"))) == 1
adapter.flush(config, root, force=True)
assert call.call_args.args[2]["date"] == "2026-09-29"
@pytest.mark.parametrize(
"extra",
[
{"agent_id": "child"},
{"agent_transcript_path": "/child.jsonl"},
{"stop_hook_active": True},
],
)
def test_subagent_and_continuation_stops_do_not_record(adapter, monkeypatch, extra):
capture = Mock()
monkeypatch.setattr(adapter, "capture", capture)
adapter.handle_event({"hook_event_name": "Stop", **extra})
capture.assert_not_called()
def test_receipt_prevents_replay_after_crash_before_pending_cleanup(adapter, tmp_path, monkeypatch):
config = adapter.load_config()
payload = transcript(adapter, tmp_path / "transcript.jsonl", [("question", "answer")])
root = adapter.capture(config, payload)
pending = next(root.glob("*.json"))
adapter.write_json(pending.with_suffix(".done"), {})
call = Mock()
monkeypatch.setattr(adapter, "call", call)
adapter.flush(config, root, force=True)
call.assert_not_called()
assert not pending.exists()
def test_endpoint_changes_never_send_old_queue_to_new_service(adapter, tmp_path, monkeypatch):
config = adapter.load_config()
payload = transcript(adapter, tmp_path / "transcript.jsonl", [("private", "answer")])
root = adapter.capture(config, payload)
monkeypatch.setattr(adapter, "load_config", lambda: {**config, "endpoint": "http://127.0.0.1:3456"})
call = Mock()
monkeypatch.setattr(adapter, "call", call)
adapter.handle_event({"hook_event_name": "SessionStart"})
call.assert_not_called()
assert len(list(root.glob("*.json"))) == 1