From cd0351c8ad7f60b10f4b5da7d34a1aeaf3efc65a Mon Sep 17 00:00:00 2001 From: huangsen Date: Wed, 30 Sep 2026 15:06:03 +0800 Subject: [PATCH] fix(codex): restore plugin hooks on current runtimes --- integrations/codex/README.md | 26 +++-- integrations/codex/README_ZH.md | 24 +++-- .../plugins/reme/.codex-plugin/plugin.json | 3 +- .../codex/plugins/reme/hooks/auto_memory.py | 21 +++- integrations/codex/plugins/reme/mcp.json | 13 --- integrations/codex/plugins/reme/plugin.json | 20 ---- .../integration/test_codex_plugin_install.py | 102 ++++++++++++++++++ tests/unit/test_coding_agent_plugins.py | 76 +++++++++++-- 8 files changed, 226 insertions(+), 59 deletions(-) delete mode 100644 integrations/codex/plugins/reme/mcp.json delete mode 100644 integrations/codex/plugins/reme/plugin.json create mode 100644 tests/integration/test_codex_plugin_install.py diff --git a/integrations/codex/README.md b/integrations/codex/README.md index fc62ae9e..87ef7fcf 100644 --- a/integrations/codex/README.md +++ b/integrations/codex/README.md @@ -41,10 +41,16 @@ codex plugin marketplace add /absolute/path/to/ReMe/integrations/codex codex plugin add reme@reme-codex ``` -Start Codex and review and trust the installed plugin's hooks in `/hooks`, then open a new conversation. +Start Codex and review and trust the four ReMe hooks (`SessionStart`, `SessionEnd`, +`UserPromptSubmit`, and `Stop`) in `/hooks`, then open a new conversation. The installation loads the bundled hooks, MCP connection, and skill together. After plugin updates, reinstall it and review any changed hook definitions. +This package uses the supported `.codex-plugin/plugin.json` and `.mcp.json` layout. +Codex 0.159.2 and desktop runtime 0.158.0-alpha.2.1 omit this plugin's hooks when a root +`plugin.json` selects the portable loader, even with an explicit hook declaration. Keep the +Codex layout until native hook discovery is verified with the portable loader. + ## 3. Use memory in Codex In a new conversation, ask “Check whether ReMe is available.” The installed plugin's tools check @@ -127,7 +133,9 @@ Clients without that extension can still use tools and hooks, but have no native ## Verify and troubleshoot -1. In Codex, ask the installed ReMe plugin to check the connection; confirm its status in `/mcp`. +1. Check that `/hooks` lists all four ReMe hooks and that each is enabled and trusted. Then use + **Check connection** in the MCP settings. A healthy result verifies the service connection; + it does not verify hook loading or execution. 2. For a quick test, set `memory_interval` to `1`, then start a new conversation and ask the agent to remember a synthetic fact, such as “Project Juniper reviews are on Thursday.” 3. Wait for the write and inspect `reme/hooks.log` for `memory_saved` and the ReMe workspace's `daily/`. @@ -135,10 +143,13 @@ Clients without that extension can still use tools and hooks, but have no native The installed plugin provides both automatic memory and explicit search/read and health checks. If its tools are missing, check that the plugin is installed and enabled, the service is running, -and `service.jobs` exposes the required jobs. If its hooks do not load, use a host version supporting -the plugin requirements and reinstall or reload the plugin. +and `service.jobs` exposes the required jobs. If ReMe is missing from `/hooks`, reinstall the current +plugin and restart the host. If listed but awaiting review, trust its current definitions. +If trusted hooks fail, check that the hook environment's `python3` is Python 3.11+ with FastMCP installed. -Capture currently reads Codex rollout JSONL `event_msg` records (`user_message` and final `agent_message`). The hook requires +Capture reads Codex rollout JSONL `event_msg` records: legacy `user_message` / final `agent_message`, +and newer `item_completed` events containing `UserMessage` / final `AgentMessage` text blocks. +`response_item` records are excluded, including injected context and duplicate messages. The hook requires `transcript_path` and `last_assistant_message`. Missing or unsupported transcripts are skipped rather than guessed. Codex transcript formats are not a stable public interface. Check `capture_skipped` or `hook_failed` events when recall works but recording does not. @@ -150,5 +161,8 @@ specific call. Trusted automatic hooks act as MCP clients themselves; they do no ## Source and validation Run `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v` from the ReMe checkout. Tests isolate host -state and mock service calls. Native hook behavior is described in the [Codex hook reference](https://learn.chatgpt.com/docs/hooks). +state and mock service calls. With a runtime supporting `hooks/list`, run +`REME_CODEX_BIN=/path/to/codex pytest tests/integration/test_codex_plugin_install.py -v` +to install into an isolated profile and verify hook discovery before trust, without model calls or hook execution. +Native hook behavior is described in the [Codex hook reference](https://learn.chatgpt.com/docs/hooks). ReMe is developed at [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) under Apache-2.0. diff --git a/integrations/codex/README_ZH.md b/integrations/codex/README_ZH.md index 0df87061..e6c635be 100644 --- a/integrations/codex/README_ZH.md +++ b/integrations/codex/README_ZH.md @@ -39,9 +39,15 @@ codex plugin marketplace add /absolute/path/to/ReMe/integrations/codex codex plugin add reme@reme-codex ``` -启动 Codex,在 `/hooks` 中检查并信任已安装插件的 Hook,然后新建会话。 +启动 Codex,在 `/hooks` 中检查并信任 ReMe 的四个 Hook(`SessionStart`、`SessionEnd`、 +`UserPromptSubmit`、`Stop`),然后新建会话。 插件安装会一并加载 Hook、MCP 连接和内置 Skill。更新后重新安装插件,并审核有变化的 Hook。 +本插件使用受支持的 `.codex-plugin/plugin.json` 与 `.mcp.json` 布局。 +在 Codex 0.159.2 和桌面运行时 0.158.0-alpha.2.1 中,根目录 `plugin.json` 会选择 portable +加载器,导致本插件的 Hook 被遗漏,显式声明 Hook 路径也不能解决。确认 portable 加载器能通过 +原生 Hook 发现验证前,保留 Codex 布局。 + ## 3. 在 Codex 中使用记忆 在新会话中询问“检查 ReMe 是否可用”,由已安装插件的工具检查服务连接;也可通过 `/mcp` 查看插件连接状态。 @@ -104,17 +110,20 @@ Codex 0.145.0 会跳过设置了 `async` 的 hook,因此本适配器使用同 ## 验证与排查 -1. 在 Codex 中让已安装的 ReMe 插件检查连接,通过 `/mcp` 确认插件连接状态。 +1. 在 `/hooks` 中确认 ReMe 的四个 Hook 均已加载、启用且信任,再在 MCP 设置中点击 + **Check connection**。健康结果只验证服务连接,不代表 Hook 已加载或执行。 2. 快速验证时把 `memory_interval` 设为 `1`,新建会话,要求记住一条合成事实,例如“Juniper 项目每周四评审”。 3. 等待写入,检查 `reme/hooks.log` 的 `memory_saved`,以及 ReMe workspace 的 `daily/` 笔记。 4. 再新建一个独立会话,询问该事实,并确认回答附有 ReMe 来源路径。 自动记忆和显式检索、阅读、健康检查均由已安装的插件提供。工具缺失时,检查插件是否已安装并启用、 -ReMe 服务是否运行,以及 `service.jobs` 是否开放所需 Job。Hook 无法加载时,请使用满足插件要求的宿主版本, -再重新安装或加载插件。 +ReMe 服务是否运行,以及 `service.jobs` 是否开放所需 Job。`/hooks` 中没有 ReMe 时,重新安装当前插件并 +重启宿主;已列出但待审核时,信任当前 Hook 定义。已信任的 Hook 执行失败时,检查 Hook 环境中的 +`python3` 是否为 Python 3.11+,并已安装 FastMCP。 -记录要求 Hook 提供 `transcript_path` 和 `last_assistant_message`,读取 rollout 的 `event_msg` -(`user_message` 与最终 `agent_message`),不读取私有推理。 +记录要求 Hook 提供 `transcript_path` 和 `last_assistant_message`,读取 rollout 的 `event_msg`: +旧版 `user_message` / 最终 `agent_message`,以及新版 `item_completed` 中的 +`UserMessage` / 最终 `AgentMessage` 文本块。排除 `response_item` 中的注入上下文和重复消息,不读取私有推理。 Codex transcript 不是稳定公开接口;未知格式或缺失文件会跳过并记录状态,不猜测对话。 召回正常但未记录时,检查 `capture_skipped`、`hook_failed`。 @@ -123,5 +132,8 @@ Codex transcript 不是稳定公开接口;未知格式或缺失文件会跳过 ## 源码与验证 在 ReMe 仓库运行 `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v`。测试使用隔离目录和模拟服务。 +使用支持 `hooks/list` 的运行时,执行 +`REME_CODEX_BIN=/path/to/codex pytest tests/integration/test_codex_plugin_install.py -v`, +可在隔离配置目录安装插件,验证信任前的 Hook 发现行为,不调用模型或执行 Hook。 宿主行为以 [Codex Hook 文档](https://learn.chatgpt.com/docs/hooks) 为准。 ReMe 在 [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) 开发,采用 Apache-2.0 许可证。 diff --git a/integrations/codex/plugins/reme/.codex-plugin/plugin.json b/integrations/codex/plugins/reme/.codex-plugin/plugin.json index d088e88a..6ecbb5b9 100644 --- a/integrations/codex/plugins/reme/.codex-plugin/plugin.json +++ b/integrations/codex/plugins/reme/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "reme", - "version": "0.1.0+codex.20260930055341", + "version": "0.1.0+codex.20260930070500", "description": "MCP-native automatic memory and editable plugin settings for Codex.", "author": { "name": "EconML team of Alibaba Tongyi Lab", @@ -18,6 +18,7 @@ ], "skills": "./skills/", "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "ReMe", "shortDescription": "Long-term memory for agents", diff --git a/integrations/codex/plugins/reme/hooks/auto_memory.py b/integrations/codex/plugins/reme/hooks/auto_memory.py index 3b8ace03..3d42e927 100644 --- a/integrations/codex/plugins/reme/hooks/auto_memory.py +++ b/integrations/codex/plugins/reme/hooks/auto_memory.py @@ -47,7 +47,7 @@ def clean_text(value: str) -> str: def transcript_messages(path: Path): - """Read Codex rollout user/agent events, excluding reasoning and response-item duplicates.""" + """Read legacy and completed-item events, excluding reasoning and response-item duplicates.""" with path.open(encoding="utf-8") as handle: for index, line in enumerate(handle): if not line.endswith("\n"): @@ -64,10 +64,23 @@ def transcript_messages(path: Path): return if record.get("type") != "event_msg": continue - role = {"user_message": "user", "agent_message": "assistant"}.get(payload.get("type")) - if role is None or role == "assistant" and payload.get("phase") not in {None, "final_answer"}: + if payload.get("type") == "item_completed": + item = payload.get("item") + if not isinstance(item, dict) or not isinstance(item.get("content"), list): + continue + role = {"UserMessage": "user", "AgentMessage": "assistant"}.get(item.get("type")) + phase = item.get("phase") + text_type = "text" if role == "user" else "Text" + content = "\n".join( + block["text"] + for block in item["content"] + if isinstance(block, dict) and block.get("type") == text_type and isinstance(block.get("text"), str) + ) + else: + role = {"user_message": "user", "agent_message": "assistant"}.get(payload.get("type")) + phase, content = payload.get("phase"), payload.get("message") + if role is None or role == "assistant" and phase not in {None, "final_answer"}: continue - content = payload.get("message") if role == "user" and isinstance(content, str) and "## My request for Codex:" in content: content = content.split("## My request for Codex:", 1)[1] if not isinstance(content, str) or not (text := clean_text(content)): diff --git a/integrations/codex/plugins/reme/mcp.json b/integrations/codex/plugins/reme/mcp.json deleted file mode 100644 index a8cb5454..00000000 --- a/integrations/codex/plugins/reme/mcp.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", - "mcpServers": { - "reme": { - "type": "stdio", - "command": "python3", - "args": [ - "${PLUGIN_ROOT}/mcp_server.py" - ], - "cwd": "${PLUGIN_ROOT}" - } - } -} diff --git a/integrations/codex/plugins/reme/plugin.json b/integrations/codex/plugins/reme/plugin.json deleted file mode 100644 index c06b4a07..00000000 --- a/integrations/codex/plugins/reme/plugin.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "reme", - "version": "0.1.0+codex.20260930055341", - "description": "MCP-native automatic memory and editable plugin settings for Codex.", - "author": { - "name": "EconML team of Alibaba Tongyi Lab", - "email": "jinli.yl@alibaba-inc.com" - }, - "homepage": "https://reme.agentscope.io", - "repository": "https://github.com/agentscope-ai/ReMe", - "license": "Apache-2.0", - "keywords": [ - "memory", - "reme", - "mcp", - "long-term-memory", - "agent" - ] -} diff --git a/tests/integration/test_codex_plugin_install.py b/tests/integration/test_codex_plugin_install.py new file mode 100644 index 00000000..f7bb0b92 --- /dev/null +++ b/tests/integration/test_codex_plugin_install.py @@ -0,0 +1,102 @@ +"""Opt-in native plugin discovery regression, without model calls or hook execution. + +Run with REME_CODEX_BIN=/path/to/codex pytest tests/integration/test_codex_plugin_install.py. +The selected runtime must support the hooks/list app-server API. +""" + +# pylint: disable=missing-function-docstring + +import json +import os +import queue +import subprocess +import threading +from pathlib import Path + +import pytest + +MARKETPLACE = Path(__file__).resolve().parents[2] / "integrations/codex" +EVENTS = {"sessionStart", "sessionEnd", "userPromptSubmit", "stop"} + + +def test_installed_plugin_exposes_all_hooks_before_trust(tmp_path): + binary = os.environ.get("REME_CODEX_BIN") + if not binary: + pytest.skip("Set REME_CODEX_BIN to run the native Codex installation check") + home = tmp_path / "codex-home" + home.mkdir() + env = {**os.environ, "CODEX_HOME": str(home)} + for args in ( + ["plugin", "marketplace", "add", str(MARKETPLACE)], + ["plugin", "add", "reme@reme-codex"], + ): + result = subprocess.run([binary, *args], env=env, capture_output=True, text=True, check=False, timeout=60) + assert result.returncode == 0, result.stderr + + with ( + (tmp_path / "app-server.log").open("w", encoding="utf-8") as stderr, + subprocess.Popen( + [binary, "app-server"], + env=env, + cwd=tmp_path, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=stderr, + text=True, + ) as process, + ): + responses = queue.Queue() + + def receive(): + for line in process.stdout: + responses.put(json.loads(line)) + + reader = threading.Thread(target=receive, daemon=True) + reader.start() + + def request(request_id, method, params): + process.stdin.write(json.dumps({"id": request_id, "method": method, "params": params}) + "\n") + process.stdin.flush() + while True: + response = responses.get(timeout=30) + if response.get("id") == request_id: + assert "error" not in response, response + return response["result"] + + try: + request( + 0, + "initialize", + { + "clientInfo": {"name": "reme-install-test", "version": "1.0"}, + "capabilities": {"experimentalApi": True}, + }, + ) + process.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n") + process.stdin.flush() + details = request( + 1, + "plugin/read", + {"marketplacePath": str(MARKETPLACE / ".agents/plugins/marketplace.json"), "pluginName": "reme"}, + )["plugin"] + assert details["summary"]["installed"] and details["summary"]["enabled"] + assert details["mcpServers"] == ["reme"] + assert {hook["eventName"] for hook in details["hooks"]} == EVENTS + + entries = request(2, "hooks/list", {"cwds": [str(tmp_path)]})["data"] + assert len(entries) == 1 + assert not entries[0]["errors"] + hooks = [hook for hook in entries[0]["hooks"] if hook.get("pluginId") == "reme@reme-codex"] + assert len(hooks) == 4 + assert {hook["eventName"] for hook in hooks} == EVENTS + assert all(hook["enabled"] and hook["trustStatus"] == "untrusted" for hook in hooks) + finally: + process.stdin.close() + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + reader.join(timeout=5) + process.stdout.close() diff --git a/tests/unit/test_coding_agent_plugins.py b/tests/unit/test_coding_agent_plugins.py index 5d6766e3..be080048 100644 --- a/tests/unit/test_coding_agent_plugins.py +++ b/tests/unit/test_coding_agent_plugins.py @@ -77,15 +77,7 @@ def test_native_manifests_resolve_within_their_own_plugin(): else: assert mcp["args"] == ["mcp_server.py"] assert (root / "mcp_server.py").is_file() - portable = json.loads((root / "plugin.json").read_text()) - assert portable["name"] == manifest["name"] - assert portable["version"] == manifest["version"] - assert not {"skills", "mcpServers", "interface"} & portable.keys() - portable_mcp = json.loads((root / "mcp.json").read_text())["mcpServers"]["reme"] - assert portable_mcp["type"] == "stdio" - assert portable_mcp["args"] == ["${PLUGIN_ROOT}/mcp_server.py"] - assert portable_mcp["cwd"] == "${PLUGIN_ROOT}" - assert portable_mcp["command"] == mcp["command"] + assert (root / manifest["hooks"]).is_file() assert mcp["cwd"] == "." assert "CODEX_HOME" in mcp["env_vars"] if host == "claude-code": @@ -238,6 +230,72 @@ def test_capture_excludes_tools_and_recalled_context(adapter, tmp_path): 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")])