Per-tool emoji on tool cards (gateway-resolved via hermes get_tool_emoji)
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:
1 parent
e6015033b6
commit
7f0bdcbbc1
8 files changed
+98
-3
No files matched your search
@@ -103,6 +103,9 @@ data class ToolItem(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val preview: String? = null,
|
val preview: String? = null,
|
||||||
val args: JsonElement? = null,
|
val args: JsonElement? = null,
|
||||||
|
/** Cosmetic per-tool glyph from the gateway (hermes get_tool_emoji);
|
||||||
|
* null for unknown tools / older frames — the UI falls back to 🔧. */
|
||||||
|
val emoji: String? = null,
|
||||||
val note: String? = null,
|
val note: String? = null,
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
val ok: Boolean = true,
|
val ok: Boolean = true,
|
||||||
@@ -450,6 +453,7 @@ class ChatStore {
|
|||||||
name = p.name,
|
name = p.name,
|
||||||
preview = p.preview,
|
preview = p.preview,
|
||||||
args = p.args,
|
args = p.args,
|
||||||
|
emoji = p.emoji,
|
||||||
anchorId = anchorId,
|
anchorId = anchorId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,6 +271,9 @@ data class ToolStartPayload(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val preview: String? = null,
|
val preview: String? = null,
|
||||||
val args: JsonElement? = null,
|
val args: JsonElement? = null,
|
||||||
|
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
|
||||||
|
* absent for unknown tools — the UI falls back to its default. */
|
||||||
|
val emoji: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -2767,7 +2767,7 @@ private fun ToolCard(
|
|||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"🔧 ${tool.name}",
|
"${tool.emoji ?: "🔧"} ${tool.name}",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = IrisColors.textBright,
|
color = IrisColors.textBright,
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ truncated / nothing).
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"tool.start","chat_id":"…","payload":{
|
{"type":"tool.start","chat_id":"…","payload":{
|
||||||
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}}
|
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"},"emoji":"💻"}}
|
||||||
{"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}}
|
{"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}}
|
||||||
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
|
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
|
||||||
"output_preview":"12 passed"}}
|
"output_preview":"12 passed"}}
|
||||||
@@ -135,6 +135,11 @@ truncated / nothing).
|
|||||||
`args` may be large; the app truncates per its setting. `output_preview` is a
|
`args` may be large; the app truncates per its setting. `output_preview` is a
|
||||||
short tail (full output is not streamed — it lives in agent history).
|
short tail (full output is not streamed — it lives in agent history).
|
||||||
|
|
||||||
|
`emoji` is a cosmetic per-tool glyph resolved server-side via hermes'
|
||||||
|
`get_tool_emoji` (active-skin overrides, then the tool registry's per-tool
|
||||||
|
`emoji` — e.g. `read_file` 📖, `write_file` ✍️, `terminal` 💻). Omitted when
|
||||||
|
the tool is unknown, so the app falls back to its own default glyph.
|
||||||
|
|
||||||
### `typing` / `typing.stop`
|
### `typing` / `typing.stop`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } },
|
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } },
|
||||||
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
|
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
|
||||||
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
||||||
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
"tool.start": { "description": "Cosmetic per-tool emoji (resolved server-side via hermes' get_tool_emoji: active-skin overrides, then the tool registry's per-tool emoji); omitted when the tool is unknown so the app falls back to its own default glyph.", "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" }, "emoji": { "type": "string" } } },
|
||||||
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
||||||
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
|
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
|
||||||
"typing": { "payload": { "on": { "type": "boolean" } } },
|
"typing": { "payload": { "on": { "type": "boolean" } } },
|
||||||
|
|||||||
@@ -321,6 +321,24 @@ def _tool_end_fields(tool_name: str) -> dict[str, Any]:
|
|||||||
return fields
|
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)
|
# Runtime-metadata footer (post_api_request hook)
|
||||||
#
|
#
|
||||||
@@ -1569,6 +1587,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
name,
|
name,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
args=args,
|
args=args,
|
||||||
|
emoji=_tool_emoji(name),
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -423,12 +423,15 @@ def tool_start(
|
|||||||
thread_id: str | None = None,
|
thread_id: str | None = None,
|
||||||
preview: str | None = None,
|
preview: str | None = None,
|
||||||
args: dict[str, Any] | None = None,
|
args: dict[str, Any] | None = None,
|
||||||
|
emoji: str | None = None,
|
||||||
) -> Frame:
|
) -> Frame:
|
||||||
payload: dict[str, Any] = {"index": index, "name": name}
|
payload: dict[str, Any] = {"index": index, "name": name}
|
||||||
if preview:
|
if preview:
|
||||||
payload["preview"] = preview
|
payload["preview"] = preview
|
||||||
if args:
|
if args:
|
||||||
payload["args"] = args
|
payload["args"] = args
|
||||||
|
if emoji:
|
||||||
|
payload["emoji"] = emoji
|
||||||
return Frame(
|
return Frame(
|
||||||
type=TYPE_TOOL_START,
|
type=TYPE_TOOL_START,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
|
|||||||
@@ -2285,6 +2285,67 @@ def test_parse_tool_line_or_block_verbose(plugin):
|
|||||||
assert name2 == "web_search" and preview2 == "x" and args2 is None
|
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) ──────────────────────────
|
# ── Slash-command catalog (the app's "/" drawer) ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user