feat(codex): webSearch bubbles + bare hermes-tools names in app-server bridge

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).
This commit is contained in:
Teknium 2026-07-17 04:54:49 -07:00
parent 11a91a6d17
commit ec3d958425
2 changed files with 60 additions and 1 deletions

View file

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

View file

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