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)