From ec3d958425a8d72937ea4e6a043ec70c3106fa55 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:54:49 -0700 Subject: [PATCH] feat(codex): webSearch bubbles + bare hermes-tools names in app-server bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more display gaps from #26541 grafted onto the merged bridge: - webSearch: codex's built-in web search now produces a tool.started/ tool.completed bubble pair (query as preview + args). Previously the item type wasn't in _CODEX_TOOL_ITEM_TYPES, so built-in searches showed nothing. - mcp.hermes-tools.* stripping: tools codex invokes through Hermes' own hermes-tools MCP server display as their bare names (web_search, browser_navigate) instead of mcp.hermes-tools.web_search. The inner dispatch subprocess can't fire native progress events, so the codex-level event is the display event — name it the way users know the tool. Credit: both behaviors designed and first implemented by @simpolism in PR #26541 (May 15, earliest of the app-server display-bridge family). --- agent/codex_runtime.py | 24 +++++++++++- .../test_codex_app_server_event_bridge.py | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 901638ffb07..394ada176e2 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -287,10 +287,23 @@ def _record_codex_app_server_compaction( # therefore deserve a tool_progress bubble pair). The projector lives in # agent/transports/codex_event_projector.py — keep these in sync so the # tool name shown in the UI matches the name recorded in messages. +# webSearch is codex's built-in web search tool — it has no projector +# entry (codex handles it internally) but still deserves a bubble. _CODEX_TOOL_ITEM_TYPES = frozenset( - {"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall"} + {"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch"} ) +# Internal MCP server that wraps Hermes' native tools for codex. When +# codex calls back through it, the inner dispatch runs in a SEPARATE +# hermes-tools-mcp-server subprocess that has no access to the parent +# agent's tool_progress_callback — so the inner call can never surface +# its own native progress event. The codex-level mcpToolCall event IS +# the display event for those calls; we strip the mcp.hermes-tools.* +# namespacing and emit the bare tool name (web_search, browser_navigate, +# vision_analyze, ...) since the user thinks of these as Hermes tools, +# not as MCP calls. +_INTERNAL_MCP_SERVER = "hermes-tools" + def _codex_item_to_tool_name(item: dict) -> str: """Synthetic Hermes tool name for a codex item. Mirrors @@ -304,9 +317,13 @@ def _codex_item_to_tool_name(item: dict) -> str: if item_type == "mcpToolCall": server = item.get("server") or "mcp" tool = item.get("tool") or "unknown" + if server == _INTERNAL_MCP_SERVER: + return tool return f"mcp.{server}.{tool}" if item_type == "dynamicToolCall": return item.get("tool") or "dynamic" + if item_type == "webSearch": + return "web_search" return item_type or "unknown" @@ -327,6 +344,8 @@ def _codex_item_to_args(item: dict) -> dict: if item_type in {"mcpToolCall", "dynamicToolCall"}: args = item.get("arguments") or {} return args if isinstance(args, dict) else {"arguments": args} + if item_type == "webSearch": + return {"query": item.get("query") or ""} return {} @@ -354,6 +373,9 @@ def _codex_item_to_preview(item: dict) -> Any: return json.dumps(args, ensure_ascii=False)[:120] except (TypeError, ValueError): return None + if item_type == "webSearch": + query = item.get("query") or "" + return query[:120] if query else None return None diff --git a/tests/agent/test_codex_app_server_event_bridge.py b/tests/agent/test_codex_app_server_event_bridge.py index e02748cfa69..05032c1c88e 100644 --- a/tests/agent/test_codex_app_server_event_bridge.py +++ b/tests/agent/test_codex_app_server_event_bridge.py @@ -79,6 +79,22 @@ class TestCodexItemToToolName: {"type": "dynamicToolCall", "tool": "web_search"} ) == "web_search" + def test_hermes_tools_mcp_server_emits_bare_tool_name(self): + """The hermes-tools MCP server wraps Hermes' own tools for codex; + the inner dispatch subprocess can't fire native progress events, + so the codex-level event IS the display event — shown without the + mcp.hermes-tools.* namespacing (from #26541 by @simpolism).""" + assert _codex_item_to_tool_name( + {"type": "mcpToolCall", "server": "hermes-tools", "tool": "web_search"} + ) == "web_search" + assert _codex_item_to_tool_name( + {"type": "mcpToolCall", "server": "hermes-tools", "tool": "browser_navigate"} + ) == "browser_navigate" + + def test_web_search_builtin_maps_to_web_search(self): + """Codex's built-in webSearch tool gets a bubble too (#26541).""" + assert _codex_item_to_tool_name({"type": "webSearch"}) == "web_search" + def test_unknown_type_returns_type_string(self): assert _codex_item_to_tool_name( {"type": "plan"} @@ -368,6 +384,27 @@ class TestToolProgressDispatch: assert completed.kwargs["is_error"] is False assert "results" in completed.kwargs["result"] + def test_web_search_builtin_fires_started_and_completed(self): + """Codex's built-in webSearch produces a start/complete bubble pair + with the query as preview and args (#26541).""" + agent = _make_stub_agent() + bridge = make_codex_app_server_event_bridge(agent) + bridge(_item_started({ + "type": "webSearch", + "id": "ws-1", + "query": "hermes agent docs", + })) + bridge(_item_completed({ + "type": "webSearch", + "id": "ws-1", + "query": "hermes agent docs", + })) + calls = agent.tool_progress_callback.call_args_list + assert [c.args[0] for c in calls] == ["tool.started", "tool.completed"] + assert calls[0].args[1] == "web_search" + assert calls[0].args[2] == "hermes agent docs" + assert calls[0].args[3] == {"query": "hermes agent docs"} + def test_duration_falls_back_to_wall_time_when_codex_missing_ms(self): agent = _make_stub_agent() bridge = make_codex_app_server_event_bridge(agent)