Per-tool emoji on tool cards (gateway-resolved via hermes get_tool_emoji)
CI / Gateway plugin tests (push) Successful in 5m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s

tool.start gains an optional cosmetic 'emoji' field, resolved server-side
through hermes' own display layer (active-skin overrides, then the tool
registry's per-tool emoji) so icons track the user's hermes theme and
new/plugin tools get their glyph for free. Omitted for unknown tools so
the app falls back to its default wrench.

- protocol.py: tool_start(emoji=...) kwarg, payload field when set
- adapter.py: _tool_emoji() helper (lazy import, None on unknown/failure)
- frames.schema.json + docs/04: field documented
- app: ToolStartPayload.emoji -> ToolItem.emoji -> ToolCard header
- tests: frame shape, resolution/fallback, end-to-end tool.start emoji
This commit is contained in:
ARIA committed 2026-08-22 20:26:38 +02:00
1 parent e6015033b6
commit 7f0bdcbbc1
8 files changed
+98 -3

No files matched your search

+19
View File
@@ -321,6 +321,24 @@ def _tool_end_fields(tool_name: str) -> dict[str, Any]:
return fields
def _tool_emoji(tool_name: str) -> str | None:
"""Cosmetic per-tool emoji for the ``tool.start`` frame.
Resolved via hermes' own display layer (``agent.display.get_tool_emoji``):
active-skin ``tool_emojis`` overrides first, then the tool registry's
per-tool ``emoji`` field — so icons track the user's hermes theme and
new/plugin tools get their registered glyph for free. Returns ``None``
when the tool is unknown (or the import fails) so the frame omits the
field and the app falls back to its own default glyph.
"""
try:
from agent.display import get_tool_emoji
return get_tool_emoji(tool_name, default="") or None
except Exception:
return None
# ---------------------------------------------------------------------------
# Runtime-metadata footer (post_api_request hook)
#
@@ -1569,6 +1587,7 @@ class AndroidAdapter(BasePlatformAdapter):
name,
preview=preview,
args=args,
emoji=_tool_emoji(name),
thread_id=thread_id,
),
)
+3
View File
@@ -423,12 +423,15 @@ def tool_start(
thread_id: str | None = None,
preview: str | None = None,
args: dict[str, Any] | None = None,
emoji: str | None = None,
) -> Frame:
payload: dict[str, Any] = {"index": index, "name": name}
if preview:
payload["preview"] = preview
if args:
payload["args"] = args
if emoji:
payload["emoji"] = emoji
return Frame(
type=TYPE_TOOL_START,
chat_id=chat_id,
+61
View File
@@ -2285,6 +2285,67 @@ def test_parse_tool_line_or_block_verbose(plugin):
assert name2 == "web_search" and preview2 == "x" and args2 is None
def test_tool_start_frame_emoji_field(plugin):
"""``tool.start`` carries the cosmetic emoji when given, omits it when
None (the app then falls back to its own default glyph)."""
pf = plugin.protocol.tool_start
f = pf("android:default", 3, "terminal", emoji="💻")
assert f.payload["emoji"] == "💻"
f2 = pf("android:default", 3, "terminal")
assert "emoji" not in f2.payload
f3 = pf("android:default", 3, "terminal", emoji=None)
assert "emoji" not in f3.payload
def test_tool_emoji_resolves_via_hermes_display(plugin, monkeypatch):
"""``_tool_emoji`` delegates to hermes' ``get_tool_emoji`` (skin overrides
-> registry) and maps unknown tools / failures to None (field omitted)."""
a = plugin.adapter
seen = {}
def fake_get_tool_emoji(name, default="⚡"):
seen[name] = default
return {"terminal": "💻", "read_file": "📖"}.get(name, default)
monkeypatch.setattr("agent.display.get_tool_emoji", fake_get_tool_emoji)
assert a._tool_emoji("terminal") == "💻"
assert a._tool_emoji("read_file") == "📖"
# Unknown tool: the adapter asks for an empty default and drops it.
assert a._tool_emoji("no_such_tool") is None
assert seen["no_such_tool"] == ""
# A display-layer failure degrades to None, never a crash.
def boom(name, default="⚡"):
raise RuntimeError("no display")
monkeypatch.setattr("agent.display.get_tool_emoji", boom)
assert a._tool_emoji("terminal") is None
@pytest.mark.asyncio
async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkeypatch):
"""A tool-progress line emits ``tool.start`` with the per-tool emoji
resolved from hermes' display layer."""
ws, _ = ws_client
monkeypatch.setattr(
"agent.display.get_tool_emoji",
lambda name, default="⚡": "💻" if name == "terminal" else default,
)
res = await adapter.send('android:default', '💻 terminal: "ls -la"')
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
payload = frames[-1]["payload"]
assert payload["name"] == "terminal"
assert payload["emoji"] == "💻"
# Unknown tool -> field omitted (app falls back to its default glyph).
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
res2 = await adapter.send('android:default', '🔧 patch: "x"')
assert res2.success
frames2 = await recv_until(
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
)
assert "emoji" not in frames2[-1]["payload"]
# ── Slash-command catalog (the app's "/" drawer) ──────────────────────────