fix(codex): bridge app-server item/started events to Telegram tool-progress (#38835)

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>
This commit is contained in:
teknium1 2026-06-21 08:32:49 -07:00 committed by Teknium
parent 8a506ed3ac
commit 2f4f23fbfb
2 changed files with 152 additions and 0 deletions

View file

@ -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

View file

@ -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