From 2f4f23fbfb541246d08ecbadafe95facbae4ecc9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:32:49 -0700 Subject: [PATCH] fix(codex): bridge app-server item/started events to Telegram tool-progress (#38835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the main provider is the Codex app-server runtime (api_mode codex_app_server), the gateway showed no verbose 'running X' tool-progress breadcrumbs on Telegram while every other provider did. The app-server session processes item/started notifications (command execution, file changes, MCP/dynamic tool calls) but never surfaced them as Hermes tool-progress events — the session was constructed without an on_event hook, so the agent's tool_progress_callback was never invoked on this route. Add _codex_note_to_tool_progress() mapping item/started → (tool_name, preview, args) for commandExecution / fileChange / mcpToolCall / dynamicToolCall, and wire an on_event hook into CodexAppServerSession that forwards mapped events to agent.tool_progress_callback('tool.started', ...) — the same signature the chat_completions path uses (tool_executor.py). Non-tool items (agentMessage/reasoning) and non-item/started methods map to None and are ignored. Co-authored-by: jplew <462836+jplew@users.noreply.github.com> --- agent/codex_runtime.py | 73 +++++++++++++++++ .../test_codex_app_server_integration.py | 79 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 4ff67871934..9928c07878c 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -25,6 +25,61 @@ from typing import Any, Dict, List logger = logging.getLogger(__name__) +def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None: + """Map a Codex app-server ``item/started`` notification to a Hermes + tool-progress event ``(tool_name, preview, args)``. + + The Codex app-server runtime processes ``item/started`` notifications for + command execution, file changes, and MCP/dynamic tool calls, but never + surfaced them as Hermes tool-progress events — so gateways (Telegram, etc.) + showed no verbose "running X" breadcrumbs on this route while every other + provider did (#38835). Returns None for items that aren't tool-shaped. + """ + if not isinstance(note, dict) or note.get("method") != "item/started": + return None + params = note.get("params") or {} + item = params.get("item") or {} + if not isinstance(item, dict): + return None + + item_type = item.get("type") or "" + if item_type == "commandExecution": + command = item.get("command") or "" + return "exec_command", command, {"command": command, "cwd": item.get("cwd") or ""} + + if item_type == "fileChange": + changes = item.get("changes") or [] + preview = "file changes" + if isinstance(changes, list) and changes: + paths = [ + str(change.get("path")) + for change in changes + if isinstance(change, dict) and change.get("path") + ] + if paths: + preview = ", ".join(paths[:3]) + if len(paths) > 3: + preview += f", +{len(paths) - 3} more" + return "apply_patch", preview, {"changes": changes} + + if item_type == "mcpToolCall": + server = item.get("server") or "mcp" + tool = item.get("tool") or "unknown" + args = item.get("arguments") or {} + if not isinstance(args, dict): + args = {"arguments": args} + return f"mcp.{server}.{tool}", tool, args + + if item_type == "dynamicToolCall": + tool = item.get("tool") or "unknown" + args = item.get("arguments") or {} + if not isinstance(args, dict): + args = {"arguments": args} + return tool, tool, args + + return None + + def _coerce_usage_int(value: Any) -> int: if isinstance(value, bool): return 0 @@ -204,9 +259,27 @@ def run_codex_app_server_turn( approval_callback = _get_approval_callback() except Exception: approval_callback = None + + def _on_codex_event(note: dict) -> None: + # Bridge Codex app-server item/started notifications to Hermes + # tool-progress so gateways show verbose "running X" breadcrumbs + # on this route too (#38835). + progress_callback = getattr(agent, "tool_progress_callback", None) + if progress_callback is None: + return + mapped = _codex_note_to_tool_progress(note) + if mapped is None: + return + tool_name, preview, args = mapped + try: + progress_callback("tool.started", tool_name, preview, args) + except Exception: + logger.debug("codex tool-progress callback raised", exc_info=True) + agent._codex_session = CodexAppServerSession( cwd=cwd, approval_callback=approval_callback, + on_event=_on_codex_event, ) # NOTE: the user message is ALREADY appended to messages by the diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index b0d2ec23861..b1de32a3302 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -477,3 +477,82 @@ class TestSessionRetirementOnRunAgent: assert agent._codex_session is None assert result["completed"] is False assert "codex segfaulted" in result["error"] + + +class TestCodexToolProgressBridge: + """#38835: Codex app-server item/started notifications must surface as + Hermes tool-progress so gateways show verbose breadcrumbs on this route.""" + + def test_mapper_command_execution(self): + from agent.codex_runtime import _codex_note_to_tool_progress + note = {"method": "item/started", "params": {"item": { + "type": "commandExecution", "command": "ls -la", "cwd": "/tmp"}}} + name, preview, args = _codex_note_to_tool_progress(note) + assert name == "exec_command" + assert preview == "ls -la" + assert args == {"command": "ls -la", "cwd": "/tmp"} + + def test_mapper_file_change(self): + from agent.codex_runtime import _codex_note_to_tool_progress + note = {"method": "item/started", "params": {"item": { + "type": "fileChange", + "changes": [{"path": "a.py"}, {"path": "b.py"}]}}} + name, preview, args = _codex_note_to_tool_progress(note) + assert name == "apply_patch" + assert preview == "a.py, b.py" + + def test_mapper_mcp_and_dynamic_tool_calls(self): + from agent.codex_runtime import _codex_note_to_tool_progress + mcp = {"method": "item/started", "params": {"item": { + "type": "mcpToolCall", "server": "fs", "tool": "read", "arguments": {"p": 1}}}} + name, preview, args = _codex_note_to_tool_progress(mcp) + assert name == "mcp.fs.read" + assert preview == "read" + assert args == {"p": 1} + + dyn = {"method": "item/started", "params": {"item": { + "type": "dynamicToolCall", "tool": "web_search", "arguments": {"q": "x"}}}} + assert _codex_note_to_tool_progress(dyn)[0] == "web_search" + + def test_mapper_ignores_non_tool_items_and_other_methods(self): + from agent.codex_runtime import _codex_note_to_tool_progress + # agentMessage / reasoning items are not tool-shaped + assert _codex_note_to_tool_progress({"method": "item/started", "params": { + "item": {"type": "agentMessage", "text": "hi"}}}) is None + # non-item/started methods + assert _codex_note_to_tool_progress({"method": "item/completed", "params": {}}) is None + assert _codex_note_to_tool_progress({}) is None + + def test_session_wired_with_on_event_that_fires_tool_progress(self, monkeypatch): + """The session is constructed with an on_event hook that, when fed an + item/started note, calls the agent's tool_progress_callback.""" + captured_init = {} + events = [] + + def fake_init(self, **kwargs): + captured_init.update(kwargs) + # minimal attrs so the rest of run_turn stubs work + self._client = None + + def fake_run_turn(self, user_input, **kwargs): + # Exercise the wired on_event hook with a real item/started note. + on_event = captured_init.get("on_event") + if on_event: + on_event({"method": "item/started", "params": {"item": { + "type": "commandExecution", "command": "pytest", "cwd": "/repo"}}}) + return TurnResult(final_text="done", projected_messages=[ + {"role": "assistant", "content": "done"}], turn_id="t1", thread_id="th1") + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr(CodexAppServerSession, "ensure_started", lambda self: "th1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + + agent = _make_codex_agent() + agent.tool_progress_callback = lambda kind, name, preview, args: events.append( + (kind, name, preview)) + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("run the tests") + + assert "on_event" in captured_init and captured_init["on_event"] is not None + assert ("tool.started", "exec_command", "pytest") in events +