a58e48fce5
An agent event set its label to the SSH profile's name, so the transcript read "homeserver · ls -la" -- naming the machine rather than the thing that was done. Built-in tools set no label at all and fell back to the function name, so a saved memory read "memory_add". The status line said "Running shell_run…" and the approval card had its own hand-written wording. Four places, four answers, nothing checking that any of them agreed. services/tool_labels.py is the one table all of them read now. Bash, Read, Write, List, Web search, Memory saved; an icon each, instead of everything being the sparkle. The precedence is inverted on purpose. Tool events are persisted in Message.tool_calls_json, so every agent row already on disk carries the profile name -- a resolver that preferred the stored value would fix nothing for any transcript that already exists. So a name the table knows resolves from the table, and a name it does not -- a custom HTTP tool, an MCP tool, whose labels are per row and cannot be tabulated -- keeps its own. One rule, both cases correct. The machine moves to `detail`, where "where this ran" belongs. tool_label and tool_icon are Jinja globals because a message bubble is rendered from four handlers, and a fifth thing each of them must remember to pass is a fifth thing one of them will forget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
"""Rendering what a tool did.
|
|
|
|
The block is written from four places and read from stored rows written by
|
|
earlier versions, so it has to render anything shaped roughly like an event --
|
|
and everything in it is third-party text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from lembas.services import tool_labels
|
|
from lembas.services import tools as tools_service
|
|
from lembas.services.agent import tools as agent_tools
|
|
from lembas.web.templating import templates
|
|
|
|
|
|
def _render(*events, live: bool = False) -> str:
|
|
return templates.get_template("chat/_tool_activity.html").render(
|
|
{"tool_events": list(events), "live": live}
|
|
)
|
|
|
|
|
|
def test_a_search_still_says_it_searched_the_web():
|
|
html = _render(
|
|
{
|
|
"name": "web_search",
|
|
"kind": "search",
|
|
"query": "mallorn",
|
|
"status": "ok",
|
|
"results": [
|
|
{
|
|
"title": "Mallorn",
|
|
"url": "https://a.test/m",
|
|
"host": "a.test",
|
|
"snippet": "A tree.",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
assert "Searched the web for “mallorn”" in html
|
|
assert '<a class="tool-result__title" href="https://a.test/m"' in html
|
|
assert "1 result" in html
|
|
|
|
|
|
def test_a_library_tool_no_longer_claims_to_have_searched_the_web():
|
|
"""Stored rows predate `kind`, and every one of them used to render a globe
|
|
and "Searched the web for <the note title>"."""
|
|
html = _render({"name": "notes_search", "query": "shopping", "status": "ok", "results": []})
|
|
assert "Searched the web" not in html
|
|
assert "Notes searched" in html
|
|
|
|
|
|
def test_a_custom_tool_is_named_and_its_host_shown():
|
|
html = _render(
|
|
{
|
|
"name": "weather",
|
|
"kind": "custom",
|
|
"label": "Weather",
|
|
"query": "city='Minas Tirith'",
|
|
"detail": "GET api.test",
|
|
"status": "ok",
|
|
"results": [],
|
|
"text": "Sunny.",
|
|
}
|
|
)
|
|
assert "Weather" in html
|
|
assert "GET api.test" in html
|
|
assert "Sunny." in html
|
|
|
|
|
|
def test_a_tools_own_text_is_escaped_and_never_rendered_as_markdown():
|
|
"""Hard rule 6. A tool's reply is exactly as untrusted as a search result,
|
|
and markdown is the one path allowed to emit HTML."""
|
|
html = _render(
|
|
{
|
|
"name": "weather",
|
|
"kind": "custom",
|
|
"label": "Weather",
|
|
"status": "ok",
|
|
"results": [],
|
|
"text": "<img src=x onerror=alert(1)> [click](javascript:alert(1))",
|
|
}
|
|
)
|
|
assert "<img" not in html
|
|
assert "<img" in html
|
|
# The markdown link is shown as the text it is, not turned into an anchor.
|
|
assert "<a " not in html
|
|
assert "[click](javascript:alert(1))" in html
|
|
|
|
|
|
def test_a_result_url_that_is_not_http_never_becomes_a_link():
|
|
html = _render(
|
|
{
|
|
"name": "web_search",
|
|
"kind": "search",
|
|
"status": "ok",
|
|
"results": [{"title": "Bad", "url": "javascript:alert(1)", "host": "", "snippet": ""}],
|
|
}
|
|
)
|
|
assert "<a " not in html
|
|
assert '<span class="tool-result__title">Bad</span>' in html
|
|
|
|
|
|
def test_a_result_with_no_url_at_all_does_not_explode():
|
|
html = _render(
|
|
{
|
|
"name": "notes_search",
|
|
"status": "ok",
|
|
"results": [{"title": "A note", "id": "abc"}],
|
|
}
|
|
)
|
|
assert "A note" in html
|
|
|
|
|
|
def test_a_failure_shows_its_reason():
|
|
html = _render(
|
|
{
|
|
"name": "weather",
|
|
"kind": "custom",
|
|
"label": "Weather",
|
|
"status": "error",
|
|
"error": "HTTP 503",
|
|
"results": [],
|
|
}
|
|
)
|
|
assert "tool-activity--error" in html
|
|
assert "Weather failed" in html
|
|
assert "HTTP 503" in html
|
|
|
|
|
|
# --- What a tool is called -----------------------------------------------------
|
|
def test_a_stored_profile_name_no_longer_becomes_the_label():
|
|
"""The whole point of the inversion.
|
|
|
|
Every agent event written before today carries `label` set to the SSH
|
|
profile's name, so the transcript said "homeserver · ls -la" and named the
|
|
machine rather than the thing that was done. Those rows are on disk and are
|
|
re-rendered on every page load, so the fix has to reach them -- which means
|
|
the static table wins over the stored value, not the other way round.
|
|
"""
|
|
html = _render(
|
|
{
|
|
"name": "shell_run",
|
|
"kind": "agent",
|
|
"label": "homeserver",
|
|
"query": "ls -la",
|
|
"detail": "homeserver:/srv/app",
|
|
"status": "ok",
|
|
"results": [],
|
|
}
|
|
)
|
|
summary = html.split("</summary>")[0]
|
|
assert "Bash" in summary
|
|
assert "homeserver" not in summary
|
|
# It is still shown, in the body, where "where this ran" belongs.
|
|
assert "homeserver:/srv/app" in html
|
|
|
|
|
|
def test_a_custom_tools_own_label_still_wins():
|
|
"""The other half of the same rule. A row-backed tool's name is per row and
|
|
cannot be tabulated, so nothing in the table shadows it."""
|
|
html = _render({"name": "weather", "kind": "custom", "label": "Weather", "results": []})
|
|
assert "Weather" in html
|
|
|
|
|
|
def test_every_builtin_and_agent_tool_has_a_label_and_an_icon():
|
|
"""A property, not markup. A tool added without an entry renders its own
|
|
function name at somebody, which is the state this replaced."""
|
|
names = [tool.name for tool in tools_service.REGISTRY.values()]
|
|
names += [tool.name for tool in agent_tools.tool_defs()]
|
|
# plan_submit is filtered out of tool_defs() outside Plan mode.
|
|
names.append("plan_submit")
|
|
missing = [name for name in names if name not in tool_labels.LABELS]
|
|
assert not missing, f"no label for {missing}"
|
|
missing = [name for name in names if name not in tool_labels.ICONS]
|
|
assert not missing, f"no icon for {missing}"
|
|
|
|
|
|
def test_every_icon_named_exists_in_the_sprite():
|
|
"""A typo'd symbol id renders an empty box and says nothing. This is the
|
|
only thing that catches it."""
|
|
sprite = Path(tools_service.__file__).parents[1] / "web/templates/partials/icons.html"
|
|
available = set(re.findall(r'id="i-([a-z-]+)"', sprite.read_text()))
|
|
wanted = set(tool_labels.ICONS.values()) | set(tool_labels.KIND_ICONS.values())
|
|
wanted.add(tool_labels.FALLBACK_ICON)
|
|
assert wanted <= available, f"not in the sprite: {sorted(wanted - available)}"
|
|
|
|
|
|
def test_an_unknown_tool_falls_back_to_its_name():
|
|
assert tool_labels.label_for({"name": "mcp_thing"}) == "mcp_thing"
|
|
assert tool_labels.icon_for({"name": "mcp_thing", "kind": "mcp"}) == "server"
|
|
assert tool_labels.icon_for({"name": "whatever"}) == tool_labels.FALLBACK_ICON
|