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

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