"""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 CODEX_KEYS = { "mcp_url": "mcpUrl", "auto_recall": "autoRecall", "auto_memory": "autoMemoryEnabled", "recall_limit": "searchLimit", "recall_min_score": "recallMinScore", "recall_timeout": "requestTimeoutMs", "request_timeout": "backgroundTimeoutMs", "memory_interval": "autoMemoryInterval", "shutdown_timeout": "shutdownTimeoutMs", } def host_settings(adapter, values): """Express shared behavior tests using each host's own public configuration keys.""" if adapter.HOST != "codex" or not isinstance(values, dict): return values return { CODEX_KEYS.get(key, key): ( value * 1000 if key in {"recall_timeout", "request_timeout", "shutdown_timeout"} else value ) for key, value in values.items() } def write_config(adapter, values): adapter.write_json(adapter.data_dir() / "config.json", host_settings(adapter, values)) def option(adapter, key): return CODEX_KEYS.get(key, key) if adapter.HOST == "codex" else key 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": "item_completed", "turn_id": f"turn-{index}", "item": { "type": "UserMessage" if role == "user" else "AgentMessage", "content": [{"type": "text" if role == "user" else "Text", "text": 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], "turn_id": f"turn-{len(pairs) - 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" in hooks) is (host == "codex") for event, groups in hooks.items(): hook = groups[0]["hooks"][0] assert '"${' in hook["command"] # Paths with spaces survive native substitution. if host == "claude-code": assert hook.get("async", False) is (event in {"Stop", "SessionStart"}) elif event in {"Stop", "SubagentStop"}: assert hook["command"].endswith(" --capture-only") and not hook.get("async", False) assert groups[0]["hooks"][1]["async"] else: assert hook.get("async", False) is (event == "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[option(adapter, "memory_interval")] == 5 assert config[option(adapter, "auto_recall")] and config[option(adapter, "auto_memory")] @pytest.mark.parametrize( "values", [ [], {"bogus": 1}, {"auto_memory": "false"}, {"memory_interval": 0}, {"recall_timeout": float("nan")}, {"request_timeout": 3601}, {"recall_limit": True}, {"memory_interval": 1.5}, {"shutdown_timeout": 61}, {"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): write_config(adapter, values) with pytest.raises(ValueError): adapter.load_config() def test_custom_endpoint_has_one_source(adapter): write_config(adapter, {"mcp_url": "https://example.com/reme/mcp/"}) config = adapter.load_config() assert config[option(adapter, "mcp_url")] == "https://example.com/reme/mcp" assert config["endpoint"] == "https://example.com/reme" def test_custom_mcp_path_and_http_prefix(adapter): write_config( adapter, { "mcp_url": "https://example.com/tools/reme", **({"api_url": "https://example.com/jobs/reme/"} if adapter.HOST == "claude-code" else {}), }, ) 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): write_config(adapter, {"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) write_config(adapter, {"auto_recall": False, "auto_memory": False}) result = adapter.handle_event({"hook_event_name": "UserPromptSubmit", "prompt": "fact"}) assert "reme-context" not in str(result) adapter.handle_event({"hook_event_name": "SessionStart"}) call.assert_not_called() write_config(adapter, {"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 write_config( adapter, { "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] == (10 if adapter.HOST == "codex" else 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) @pytest.mark.parametrize("final_phase", [None, "final_answer"]) def test_codex_completed_items_capture_only_final_text_and_deduplicate(adapter, tmp_path, monkeypatch, final_phase): 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_phase, "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.", "turn_id": "turn-one", } 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) write_config(adapter, {"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" payload["turn_id"] = "turn-0" 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(), **host_settings(adapter, {"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(), **host_settings(adapter, {"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, **host_settings(adapter, {"auto_memory": False, "auto_recall": False})}, ) call = Mock() monkeypatch.setattr(adapter, "call", call) for event in ("Stop", "SessionStart", "SessionEnd", "UserPromptSubmit"): result = adapter.handle_event({**payload, "hook_event_name": event, "prompt": "anything"}) assert "reme-context" not in str(result) 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(), **host_settings(adapter, {"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(), **host_settings(adapter, {"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 @pytest.mark.parametrize("adapter", ["codex"], indirect=True) def test_codex_matches_turn_id_even_when_final_replies_are_identical(adapter, tmp_path): payload = transcript(adapter, tmp_path / "rollout.jsonl", [("first", "Saved."), ("second", "Saved.")]) payload["turn_id"] = "turn-0" pair = adapter.completed_turn(payload, "session") assert [message["content"][0]["text"] for message in pair] == ["first", "Saved."] payload["turn_id"] = "unknown" assert adapter.completed_turn(payload, "session") == [] @pytest.mark.parametrize("adapter", ["codex"], indirect=True) def test_codex_localized_guidance_survives_disabled_and_failed_recall(adapter, monkeypatch): write_config(adapter, {"language": "zh", "auto_recall": False}) call = Mock() monkeypatch.setattr(adapter, "call", call) payload = {"hook_event_name": "UserPromptSubmit", "prompt": "过去的决定"} result = adapter.handle_event(payload) context = result["hookSpecificOutput"]["additionalContext"] assert "长期记忆" in context and "reme_search" in context and "