diff --git a/src/server/desktop_gateway_translate.py b/src/server/desktop_gateway_translate.py
index b6c73add..7395b16a 100644
--- a/src/server/desktop_gateway_translate.py
+++ b/src/server/desktop_gateway_translate.py
@@ -12,6 +12,7 @@
from __future__ import annotations
+import re
from typing import Any
# ── usage mapping ────────────────────────────────────────────────────────────
@@ -80,6 +81,246 @@ def _tool_output_text(content: Any) -> str:
return _text_of(content)
+# ── tool vocabulary adapter ──────────────────────────────────────────────────
+# clawcodex speaks the Claude Code tool vocabulary (Read/Bash/Glob/…). The
+# desktop's tool renderer keys every per-tool formatter — icon, title, diff
+# view, split stdout/stderr, count chip — off its OWN fixed set of names
+# (ui-desktop/.../tool/fallback-model/index.ts ``TOOL_META`` plus its
+# ``toolName === …`` branches), and it reads a tool's output out of *named
+# result fields* rather than raw text: a plain-string ``result`` is dropped
+# outright by the renderer's ``parseMaybeJsonObject``. Unadapted, every row
+# fell through to the generic path — no title, no icon, and no output at all.
+#
+# Summaries deliberately mirror the TUI's (ui-tui/src/gatewayClient.ts
+# ``toolContext`` / ``formatToolResult``) so both surfaces describe the same
+# tool run the same way.
+
+_RENDER_TOOL_NAMES: dict[str, str] = {
+ "read": "read_file",
+ "write": "write_file",
+ "edit": "edit_file",
+ "multiedit": "edit_file",
+ "notebookedit": "edit_file",
+ "bash": "terminal",
+ "bashoutput": "terminal",
+ "killshell": "terminal",
+ "killbash": "terminal",
+ "glob": "list_files",
+ "ls": "list_files",
+ "grep": "search_files",
+ "websearch": "web_search",
+ "webfetch": "web_extract",
+ "todowrite": "todo",
+ "askuserquestion": "clarify",
+}
+
+
+def render_tool_name(name: str) -> str:
+ """clawcodex tool name → the name the desktop renderer formats for.
+
+ Unknown tools (Task, custom MCP tools, …) pass through unchanged and get
+ the renderer's generic treatment, which is the right fallback.
+ """
+ return _RENDER_TOOL_NAMES.get(name.strip().lower(), name)
+
+
+def tool_context(tool_input: Any) -> str:
+ """The single argument worth showing beside the tool name.
+
+ Priority order is the TUI's ``toolContext``, so a Bash row reads
+ ``ls src/`` on both surfaces and a Grep row shows its pattern rather than
+ its search path. The renderer picks this up as ``context`` — the key its
+ generic title/preview path is built around.
+ """
+ if not isinstance(tool_input, dict):
+ return ""
+ pattern = tool_input.get("pattern")
+ if pattern is not None:
+ return str(pattern)
+ for key in ("file_path", "path", "notebook_path"):
+ value = tool_input.get(key)
+ if value is not None:
+ return str(value)
+ for key in ("command", "url", "query", "description", "prompt"):
+ value = tool_input.get(key)
+ if value is not None:
+ return str(value)
+ return ""
+
+
+def render_tool_args(tool_input: Any) -> dict[str, Any]:
+ """Tool input plus the aliases the renderer reads.
+
+ It looks for ``path``/``file``/``filepath`` when resolving a file target;
+ clawcodex spells it ``file_path``/``notebook_path``. Aliased rather than
+ renamed so the raw arguments stay intact for the args view.
+ """
+ if not isinstance(tool_input, dict):
+ return {}
+ args = dict(tool_input)
+ if not isinstance(args.get("path"), str):
+ for key in ("file_path", "notebook_path"):
+ if isinstance(args.get(key), str):
+ args["path"] = args[key]
+ break
+ return args
+
+
+_SANDBOX_BLOCK_RE = re.compile(r".*?", re.DOTALL)
+_ERROR_TAG_RE = re.compile(r"?(?:tool_use_error|error)>")
+_PREFIXED_ERROR_RE = re.compile(r"^(?:Error|Cancelled):\s")
+_NUMBERED_LINE_RE = re.compile(r"^\s*\d+\t")
+
+
+def clean_tool_error(text: str) -> str:
+ """Tool failure text → the sentence a row should show (TUI parity).
+
+ Strips the model-facing markup (```` wrappers, sandbox
+ violation blocks) that is bookkeeping for the agent, not for a reader.
+ """
+ cleaned = _ERROR_TAG_RE.sub("", _SANDBOX_BLOCK_RE.sub("", text)).strip()
+ if not cleaned:
+ return "Tool execution failed"
+ if "InputValidationError: " in cleaned:
+ return "Invalid tool parameters"
+ if _PREFIXED_ERROR_RE.match(cleaned):
+ return cleaned
+ return f"Error: {cleaned}"
+
+
+def _format_file_size(size: int) -> str:
+ if size < 1024:
+ return f"{size} bytes"
+ value = float(size)
+ for unit in ("KB", "MB", "GB"):
+ value /= 1024
+ if value < 1024 or unit == "GB":
+ return f"{value:.1f}".removesuffix(".0") + unit
+ return f"{size} bytes"
+
+
+def _plural(count: int, noun: str) -> str:
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
+
+
+def summarize_tool_result(name: str, text: str, display: Any) -> str:
+ """One-line description of what a tool produced, matching the TUI.
+
+ Returned as the row's ``context``. The renderer prefers ``context`` over
+ everything else on its generic detail path, so this is set ONLY where the
+ summary is the whole story — never where it would hide real output the
+ expanded row should show (a Grep's matches get a count chip instead).
+ Empty means "the raw output speaks for itself".
+ """
+ if name == "Read":
+ if isinstance(display, dict) and display.get("type") == "image":
+ size = display.get("originalSize")
+ if isinstance(size, int) and not isinstance(size, bool):
+ return f"Read image ({_format_file_size(size)})"
+ return "Read image"
+ lines = [line for line in text.split("\n") if line]
+ if lines and _NUMBERED_LINE_RE.match(lines[0]):
+ return f"Read {_plural(len(lines), 'line')}"
+ return ""
+ if name == "WebSearch" and isinstance(display, dict):
+ count = display.get("searchCount")
+ if isinstance(count, int) and not isinstance(count, bool):
+ summary = f"Did {count} search{'' if count == 1 else 'es'}"
+ seconds = display.get("durationSeconds")
+ if isinstance(seconds, (int, float)) and not isinstance(seconds, bool):
+ summary += f" in {round(seconds)}s" if seconds >= 1 else f" in {round(seconds * 1000)}ms"
+ return summary
+ return ""
+
+
+def inline_diff_from_display(display: Any) -> str:
+ """``structuredPatch`` display envelope → a unified diff string.
+
+ The renderer paints ``inline_diff`` as a real diff and counts its +/-
+ lines for the "Added N, removed M" summary, mirroring how the TUI treats
+ an edit's diff AS its result rather than printing result text.
+ """
+ if not isinstance(display, dict):
+ return ""
+ path = display.get("filePath")
+ path_label = path if isinstance(path, str) else "file"
+ patch = display.get("structuredPatch")
+ if not isinstance(patch, list) or not patch:
+ content = display.get("content")
+ if display.get("type") == "create" and isinstance(content, str) and content:
+ body = "\n".join(f"+{line}" for line in content.split("\n"))
+ return f"--- /dev/null\n+++ {path_label}\n{body}"
+ return ""
+ lines = [f"--- {path_label}", f"+++ {path_label}"]
+ for hunk in patch:
+ if not isinstance(hunk, dict):
+ continue
+ body = hunk.get("lines")
+ if not isinstance(body, list):
+ continue
+ lines.append(
+ "@@ -{},{} +{},{} @@".format(
+ hunk.get("oldStart", 0), hunk.get("oldLines", 0),
+ hunk.get("newStart", 0), hunk.get("newLines", 0),
+ )
+ )
+ lines.extend(str(line) for line in body)
+ return "\n".join(lines) if len(lines) > 2 else ""
+
+
+def render_tool_result(name: str, text: str, display: Any) -> dict[str, Any]:
+ """Tool output → the result object the renderer knows how to read.
+
+ Each renderer tool family reads its output from a different field
+ (``content`` for a file read, ``output`` for a shell run, ``inline_diff``
+ for an edit); a bare string reaches none of them.
+ """
+ render = render_tool_name(name)
+ result: dict[str, Any] = {}
+
+ if render in ("read_file", "web_extract"):
+ if text:
+ result["content"] = text
+ elif render == "terminal":
+ # The renderer paints this as an ANSI terminal body.
+ result["output"] = text
+ elif render in ("edit_file", "write_file"):
+ diff = inline_diff_from_display(display)
+ if diff:
+ result["inline_diff"] = diff
+ elif text:
+ result["message"] = text
+ if isinstance(display, dict) and isinstance(display.get("filePath"), str):
+ result["path"] = display["filePath"]
+ elif text:
+ # No dedicated renderer branch, so the body comes from the generic
+ # path — which prefers the *argument* context ("*.py") over anything
+ # in the result, and would print the glob back at the user instead of
+ # the files it matched. Repeating the output under `context` is what
+ # outranks it; `output` stays for the copy and subtitle paths, which
+ # read that key instead. A real summary below replaces the `context`
+ # copy where one exists.
+ result["output"] = text
+ result["context"] = text
+
+ if render == "list_files" and text:
+ result["file_count"] = len([line for line in text.split("\n") if line.strip()])
+ elif render == "search_files" and text:
+ result["match_count"] = len([line for line in text.split("\n") if line.strip()])
+ elif render == "web_search" and isinstance(display, dict):
+ count = display.get("searchCount")
+ if isinstance(count, int) and not isinstance(count, bool):
+ result["result_count"] = count
+ seconds = display.get("durationSeconds")
+ if isinstance(seconds, (int, float)) and not isinstance(seconds, bool):
+ result["duration_s"] = seconds
+
+ summary = summarize_tool_result(name, text, display)
+ if summary:
+ result["context"] = summary
+ return result
+
+
# ── frame translators ────────────────────────────────────────────────────────
@@ -110,13 +351,13 @@ def translate_sdk_envelope(
``tool_names`` carries tool_use_id → tool name across the two frames. A
``tool_result`` names only the id, but the renderer labels each row from
- ``name`` (falling back to the literal "tool") and matches rows by name +
- id — so completing without it produced the bare, contentless "Tool" rows.
+ ``name`` (falling back to the literal "tool") and matches a completion to
+ its running row by name + id — so completing without it produced the
+ bare, contentless "Tool" rows.
- Field names follow what the renderer actually reads (``toolResult`` /
- ``toolArgs`` in lib/chat-messages.ts): ``result`` (not "output"),
- ``error`` (not "is_error"), plus ``inline_diff``/``summary``/``duration_s``
- when the agent supplies its richer display envelope.
+ Names, arguments and results all go through the vocabulary adapter above,
+ which is what engages the renderer's per-tool formatting instead of its
+ unlabelled generic fallback.
"""
kind = frame.get("type")
message = frame.get("message") or {}
@@ -131,10 +372,16 @@ def translate_sdk_envelope(
name = str(block.get("name") or "")
if tool_names is not None and tool_id:
tool_names[tool_id] = name
- events.append(
- ("tool.start", {"tool_id": tool_id, "name": name,
- "args": block.get("input") or {}}),
- )
+ tool_input = block.get("input") or {}
+ payload: dict[str, Any] = {
+ "tool_id": tool_id,
+ "name": render_tool_name(name),
+ "args": render_tool_args(tool_input),
+ }
+ context = tool_context(tool_input)
+ if context:
+ payload["context"] = context
+ events.append(("tool.start", payload))
return events
if kind != "user":
@@ -148,21 +395,16 @@ def translate_sdk_envelope(
if block.get("type") != "tool_result":
continue
tool_id = str(block.get("tool_use_id") or "")
- payload: dict[str, Any] = {
- "tool_id": tool_id,
- "name": (tool_names or {}).pop(tool_id, "") if tool_names else "",
- "result": _tool_output_text(block.get("content")),
- }
+ name = (tool_names or {}).pop(tool_id, "") if tool_names else ""
+ text = _tool_output_text(block.get("content"))
+ payload = {"tool_id": tool_id, "name": render_tool_name(name)}
if block.get("is_error"):
- # The renderer flags a failed row from `error` being truthy; keep
- # the message so the row can show WHY it failed.
- payload["error"] = payload["result"] or "tool failed"
- if isinstance(display, dict):
- for key in ("inline_diff", "summary", "preview", "duration_s",
- "structuredPatch", "filePath", "type", "originalSize",
- "numLines", "totalLines"):
- if display.get(key) is not None:
- payload.setdefault(key, display[key])
+ # A failed row shows the reason, not the output — and no diff:
+ # nothing was written. The renderer flags failure off `error`.
+ payload["error"] = clean_tool_error(text)
+ payload["result"] = {}
+ else:
+ payload["result"] = render_tool_result(name, text, display)
events.append(("tool.complete", payload))
return events
@@ -245,6 +487,13 @@ def translate_frame(
__all__ = [
"approval_request_payload",
"as_text",
+ "clean_tool_error",
+ "inline_diff_from_display",
+ "render_tool_args",
+ "render_tool_name",
+ "render_tool_result",
+ "summarize_tool_result",
+ "tool_context",
"translate_frame",
"translate_result",
"translate_sdk_envelope",
diff --git a/tests/server/test_desktop_translate.py b/tests/server/test_desktop_translate.py
index 5e2477f4..82b093e6 100644
--- a/tests/server/test_desktop_translate.py
+++ b/tests/server/test_desktop_translate.py
@@ -10,15 +10,36 @@
from __future__ import annotations
+import json
+import os
+from pathlib import Path
+
from src.server.desktop_gateway_translate import (
as_text,
+ clean_tool_error,
+ inline_diff_from_display,
+ render_tool_name,
+ render_tool_result,
+ summarize_tool_result,
+ tool_context,
translate_frame,
translate_sdk_envelope,
translate_stream_event,
)
-def test_tool_start_carries_name_and_args() -> None:
+def _complete(name: str, text: str, display=None, is_error: bool = False):
+ block = {"type": "tool_result", "tool_use_id": "c", "content": text}
+ if is_error:
+ block["is_error"] = True
+ frame = {"type": "user", "message": {"role": "user", "content": [block]}}
+ if display is not None:
+ frame["tool_use_result"] = display
+ (_, payload), = translate_sdk_envelope(frame, {"c": name})
+ return payload
+
+
+def test_tool_start_carries_name_args_and_context() -> None:
names: dict[str, str] = {}
events = translate_sdk_envelope(
{
@@ -31,75 +52,196 @@ def test_tool_start_carries_name_and_args() -> None:
},
names,
)
- assert events == [("tool.start", {"tool_id": "call_1", "name": "Read",
- "args": {"file_path": "/w/seed.py"}})]
+ (kind, payload), = events
+ assert kind == "tool.start"
# Text blocks are NOT re-emitted (they already streamed as deltas).
- assert all(kind != "message.delta" for kind, _ in events)
- # The name is remembered for the matching tool_result.
+ assert payload["tool_id"] == "call_1"
+ assert payload["name"] == "read_file"
+ # `path` is aliased in; the renderer never looks for `file_path`.
+ assert payload["args"]["path"] == "/w/seed.py"
+ assert payload["args"]["file_path"] == "/w/seed.py"
+ assert payload["context"] == "/w/seed.py"
+ # The name is remembered under its ORIGINAL spelling for the result frame.
assert names == {"call_1": "Read"}
-def test_tool_complete_uses_the_fields_the_renderer_reads() -> None:
- names = {"call_1": "Read"}
- events = translate_sdk_envelope(
- {
- "type": "user",
- "message": {"role": "user", "content": [
- {"type": "tool_result", "tool_use_id": "call_1",
- "content": [{"type": "text", "text": "1\tprint('seed')"}]},
- ]},
- },
- names,
- )
- (kind, payload), = events
- assert kind == "tool.complete"
- # `name` labels the row (absent → the literal "tool"); `result` is what
- # toolResult() reads — the old "output" key rendered nothing.
- assert payload["name"] == "Read"
- assert payload["result"] == "1\tprint('seed')"
- assert payload["tool_id"] == "call_1"
- assert "output" not in payload
+def test_tool_names_map_to_the_renderers_vocabulary() -> None:
+ """Every per-tool formatter in the desktop renderer keys off these names;
+ an unmapped name falls through to an unlabelled generic row."""
+ assert render_tool_name("Read") == "read_file"
+ assert render_tool_name("Bash") == "terminal"
+ assert render_tool_name("Glob") == "list_files"
+ assert render_tool_name("Grep") == "search_files"
+ assert render_tool_name("WebSearch") == "web_search"
+ assert render_tool_name("WebFetch") == "web_extract"
+ assert render_tool_name("Edit") == "edit_file"
+ assert render_tool_name("TodoWrite") == "todo"
+ # Unknown tools pass through rather than being mangled.
+ assert render_tool_name("Task") == "Task"
-def test_tool_complete_forwards_the_display_envelope() -> None:
- """Edit/Write ride a trimmed `tool_use_result` (structuredPatch, filePath);
- forwarding it is what lets the row render a diff instead of raw text."""
- events = translate_sdk_envelope(
- {
- "type": "user",
- "message": {"role": "user", "content": [
- {"type": "tool_result", "tool_use_id": "call_2", "content": "ok"},
- ]},
- "tool_use_result": {
- "type": "update", "filePath": "/w/a.py",
- "structuredPatch": [{"lines": ["-old", "+new"]}],
- "duration_s": 0.4,
- },
- },
- {"call_2": "Edit"},
+def test_tool_context_matches_the_tui_priority() -> None:
+ # A pattern beats a path, so Grep shows what it searched FOR.
+ assert tool_context({"pattern": "TODO", "path": "/w"}) == "TODO"
+ assert tool_context({"file_path": "/w/a.py"}) == "/w/a.py"
+ assert tool_context({"command": "ls src/"}) == "ls src/"
+ assert tool_context({"query": "weather"}) == "weather"
+ assert tool_context({"description": "refactor"}) == "refactor"
+ assert tool_context({}) == ""
+
+
+def test_read_result_lands_where_the_renderer_reads_it() -> None:
+ payload = _complete("Read", "1\tprint('seed')\n2\tprint('again')")
+ assert payload["name"] == "read_file"
+ # A plain string here is dropped by the renderer's parseMaybeJsonObject —
+ # the text has to arrive under a key it knows.
+ assert payload["result"]["content"] == "1\tprint('seed')\n2\tprint('again')"
+ assert payload["result"]["context"] == "Read 2 lines"
+
+
+def test_bash_output_becomes_a_terminal_body() -> None:
+ payload = _complete("Bash", "src\ntests\n")
+ assert payload["name"] == "terminal"
+ assert payload["result"]["output"] == "src\ntests\n"
+
+
+def test_glob_and_grep_keep_their_matches_and_get_a_count_chip() -> None:
+ """A count reads like the TUI's "Found 3 files" without a summary
+ displacing the matches themselves — which the renderer would do if the
+ only `context` in play were the argument ("*.py")."""
+ glob = _complete("Glob", "a.py\nb.py\nc.py")
+ assert glob["name"] == "list_files"
+ assert glob["result"]["file_count"] == 3
+ assert glob["result"]["output"] == "a.py\nb.py\nc.py"
+ assert glob["result"]["context"] == "a.py\nb.py\nc.py"
+
+ grep = _complete("Grep", "a.py:1:TODO\nb.py:4:TODO")
+ assert grep["result"]["match_count"] == 2
+
+
+def test_edit_result_is_a_diff() -> None:
+ payload = _complete(
+ "Edit", "ok",
+ {"type": "update", "filePath": "/w/a.py",
+ "structuredPatch": [{"oldStart": 1, "oldLines": 1, "newStart": 1,
+ "newLines": 1, "lines": ["-old", "+new"]}]},
)
- (_, payload), = events
- assert payload["name"] == "Edit"
- assert payload["filePath"] == "/w/a.py"
- assert payload["structuredPatch"] == [{"lines": ["-old", "+new"]}]
- assert payload["duration_s"] == 0.4
+ assert payload["name"] == "edit_file"
+ diff = payload["result"]["inline_diff"]
+ assert "@@ -1,1 +1,1 @@" in diff
+ assert "-old" in diff and "+new" in diff
+ assert payload["result"]["path"] == "/w/a.py"
-def test_tool_error_uses_error_not_is_error() -> None:
- events = translate_sdk_envelope(
- {
- "type": "user",
- "message": {"role": "user", "content": [
- {"type": "tool_result", "tool_use_id": "c", "content": "boom",
- "is_error": True},
- ]},
- },
- {"c": "Bash"},
+def test_write_of_a_new_file_renders_as_an_all_additions_diff() -> None:
+ diff = inline_diff_from_display(
+ {"type": "create", "filePath": "/w/hi.py", "content": "print('hi')", "structuredPatch": []}
)
- (_, payload), = events
+ assert diff == "--- /dev/null\n+++ /w/hi.py\n+print('hi')"
+
+
+def test_read_image_reports_its_size() -> None:
+ payload = _complete("Read", "", {"type": "image", "originalSize": 12_600})
+ assert payload["result"]["context"] == "Read image (12.3KB)"
+
+
+def test_web_search_summarizes_count_and_duration() -> None:
+ display = {"type": "web_search", "searchCount": 3, "durationSeconds": 2.4}
+ assert summarize_tool_result("WebSearch", "", display) == "Did 3 searches in 2s"
+ payload = _complete("WebSearch", "…blob…", display)
+ assert payload["result"]["result_count"] == 3
+ assert payload["result"]["duration_s"] == 2.4
+
+
+def test_failed_tool_shows_the_reason_and_no_diff() -> None:
+ payload = _complete("Bash", "no such file",
+ is_error=True)
# The renderer flags a failed row from `error` being truthy.
- assert payload["error"] == "boom"
- assert payload["result"] == "boom"
+ assert payload["error"] == "Error: no such file"
+ # Nothing was written, so nothing is rendered as output.
+ assert payload["result"] == {}
+
+
+def test_error_text_is_cleaned_like_the_tui() -> None:
+ assert clean_tool_error("") == "Tool execution failed"
+ assert clean_tool_error("InputValidationError: bad") == (
+ "Invalid tool parameters"
+ )
+ # An already-prefixed message isn't double-prefixed.
+ assert clean_tool_error("Error: boom") == "Error: boom"
+ assert clean_tool_error("Cancelled: by user") == "Cancelled: by user"
+ assert clean_tool_error("xplain") == "Error: plain"
+
+
+def test_unknown_tool_still_renders_its_output() -> None:
+ result = render_tool_result("SomeMcpTool", "hello", None)
+ assert result == {"output": "hello", "context": "hello"}
+
+
+# ── cross-language contract ──────────────────────────────────────────────────
+# A payload the renderer can't read is invisible to both languages' own tests
+# and shows up only as a blank row in the transcript — which is how the "Tool"
+# rows shipped. So the payloads this translator emits are frozen into a
+# fixture, and ui-desktop/src/lib/gateway-tool-contract.test.ts renders that
+# same fixture and asserts what the user ends up seeing. Changing the wire
+# shape fails here until the fixture is regenerated, and fails there if the
+# new shape doesn't actually render.
+
+FIXTURE = Path(__file__).resolve().parents[2] / "ui-desktop/src/lib/gateway-tool-events.fixture.json"
+
+# (case name, tool, input, output text, display envelope, is_error)
+CONTRACT_CASES = [
+ ("read", "Read", {"file_path": "/w/seed.py"},
+ "1\tprint('seed')\n2\tprint('again')", None, False),
+ ("bash", "Bash", {"command": "ls src/"}, "app\nlib\n", None, False),
+ ("edit", "Edit", {"file_path": "/w/a.py", "old_string": "old", "new_string": "new"}, "ok",
+ {"type": "update", "filePath": "/w/a.py",
+ "structuredPatch": [{"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 1,
+ "lines": ["-old", "+new"]}]}, False),
+ ("write", "Write", {"file_path": "/w/hi.py", "content": "print('hi')"}, "ok",
+ {"type": "create", "filePath": "/w/hi.py", "content": "print('hi')",
+ "structuredPatch": []}, False),
+ ("glob", "Glob", {"pattern": "*.py"}, "a.py\nb.py\nc.py", None, False),
+ ("grep", "Grep", {"pattern": "TODO", "path": "/w"}, "a.py:1:TODO\nb.py:4:TODO", None, False),
+ ("web_search", "WebSearch", {"query": "weather"}, "…blob…",
+ {"type": "web_search", "searchCount": 3, "durationSeconds": 2.4}, False),
+ ("failure", "Bash", {"command": "cat nope"},
+ "no such file", None, True),
+ ("unknown_tool", "Task", {"description": "refactor"}, "done", None, False),
+]
+
+
+def _contract_payloads() -> dict[str, dict]:
+ cases: dict[str, dict] = {}
+ for name, tool, tool_input, text, display, is_error in CONTRACT_CASES:
+ names: dict[str, str] = {}
+ (_, start), = translate_sdk_envelope(
+ {"type": "assistant", "message": {"role": "assistant", "content": [
+ {"type": "tool_use", "id": "call", "name": tool, "input": tool_input},
+ ]}},
+ names,
+ )
+ block = {"type": "tool_result", "tool_use_id": "call", "content": text}
+ if is_error:
+ block["is_error"] = True
+ frame = {"type": "user", "message": {"role": "user", "content": [block]}}
+ if display is not None:
+ frame["tool_use_result"] = display
+ (_, complete), = translate_sdk_envelope(frame, names)
+ cases[name] = {"start": start, "complete": complete}
+ return cases
+
+
+def test_wire_payloads_match_the_renderer_fixture() -> None:
+ current = _contract_payloads()
+ if os.environ.get("UPDATE_FIXTURES"):
+ FIXTURE.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n")
+ recorded = json.loads(FIXTURE.read_text())
+ assert current == recorded, (
+ "tool.start/tool.complete payloads changed. Re-run with UPDATE_FIXTURES=1 "
+ "and make sure ui-desktop's gateway-tool-contract.test.ts still passes — "
+ "it renders this fixture and asserts what the user actually sees."
+ )
def test_as_text_never_leaks_a_tool_block_as_prose() -> None:
diff --git a/ui-desktop/src/lib/gateway-tool-contract.test.ts b/ui-desktop/src/lib/gateway-tool-contract.test.ts
new file mode 100644
index 00000000..f7b03e75
--- /dev/null
+++ b/ui-desktop/src/lib/gateway-tool-contract.test.ts
@@ -0,0 +1,132 @@
+// The consumer half of the tool-row contract.
+//
+// `gateway-tool-events.fixture.json` is generated by the gateway translator
+// (src/server/desktop_gateway_translate.py, frozen by
+// tests/server/test_desktop_translate.py) — it is literally what goes over
+// the wire. Here we push it through the real renderer and assert what a user
+// ends up seeing.
+//
+// This pairing exists because a payload the renderer can't read is invisible
+// to both languages' own tests: Python asserts it sent a field, TypeScript
+// asserts it renders a field, and neither notices they're different fields.
+// That mismatch shipped as unlabelled "Tool" rows with no output in them.
+
+import { describe, expect, it } from 'vitest'
+
+import { buildToolView, inlineDiffFromResult } from '../components/assistant-ui/tool/fallback-model'
+import type { ToolPart } from '../components/assistant-ui/tool/fallback-model'
+import { upsertToolPart } from './chat-messages'
+import fixture from './gateway-tool-events.fixture.json'
+
+type Case = keyof typeof fixture
+
+function render(name: Case) {
+ const { start, complete } = fixture[name]
+ const running = upsertToolPart([], start as Record, 'running')
+ const done = upsertToolPart(running, complete as Record, 'complete')
+
+ // start and complete must land on ONE row — mismatched names would split
+ // them into a running row that never finishes plus an orphan result.
+ expect(done).toHaveLength(1)
+
+ const part = done[0] as unknown as ToolPart
+
+ // Mirrors the ToolEntry component, which resolves the diff before building.
+ return { part, view: buildToolView(part, inlineDiffFromResult(part.result)) }
+}
+
+describe('gateway tool payloads', () => {
+ it('never falls back to an unlabelled row', () => {
+ for (const name of Object.keys(fixture) as Case[]) {
+ const { part, view } = render(name)
+
+ expect(part.toolName).not.toBe('tool')
+ expect(view.title.trim()).not.toBe('')
+ }
+ })
+
+ it('renders a file read with its path and contents', () => {
+ const { view } = render('read')
+
+ // The title names the file, which is why the renderer blanks the subtitle.
+ expect(view.title).toContain('seed.py')
+ expect(view.status).toBe('success')
+ expect(view.detail).toContain("print('seed')")
+ })
+
+ it('renders a shell run as a terminal with its command and output', () => {
+ const { view } = render('bash')
+
+ expect(view.terminalCommand).toBe('ls src/')
+ expect(view.rendersAnsi).toBe(true)
+ expect(view.detail).toContain('app')
+ })
+
+ it('renders an edit as a diff with add/remove counts', () => {
+ const { view } = render('edit')
+
+ expect(view.inlineDiff).toContain('+new')
+ expect(view.inlineDiff).toContain('-old')
+ // The +++/--- headers must not be counted as edits.
+ expect(countDiff(view.inlineDiff)).toEqual({ added: 1, removed: 1 })
+ })
+
+ it('renders a new file as an all-additions diff', () => {
+ const { view } = render('write')
+
+ expect(countDiff(view.inlineDiff)).toEqual({ added: 1, removed: 0 })
+ expect(view.inlineDiff).toContain("print('hi')")
+ })
+
+ it('counts glob matches without hiding them', () => {
+ const { view } = render('glob')
+
+ expect(view.countLabel).toBe('3 files')
+ expect(view.detail).toContain('b.py')
+ })
+
+ it('counts grep matches without hiding them', () => {
+ const { view } = render('grep')
+
+ expect(view.countLabel).toBe('2 matches')
+ expect(view.detail).toContain('a.py:1:TODO')
+ })
+
+ it('summarizes a web search by count and duration', () => {
+ const { view } = render('web_search')
+
+ expect(view.searchQuery).toBe('weather')
+ expect(view.countLabel).toBe('3 results')
+ expect(view.durationLabel).toBe('2.4s')
+ })
+
+ it('marks a failed tool and shows why', () => {
+ const { part, view } = render('failure')
+
+ expect(part.isError).toBe(true)
+ expect(view.status).toBe('error')
+ expect(`${view.subtitle}${view.detail}`).toContain('no such file')
+ })
+
+ it('still renders a tool it has no formatter for', () => {
+ const { view } = render('unknown_tool')
+
+ expect(view.title.toLowerCase()).toContain('task')
+ expect(view.detail).toContain('done')
+ })
+})
+
+function countDiff(diff: string): { added: number; removed: number } {
+ let added = 0
+ let removed = 0
+
+ for (const line of diff.split('\n')) {
+ if (line.startsWith('+') && !line.startsWith('+++')) {
+ added += 1
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
+ removed += 1
+ }
+ }
+
+ return { added, removed }
+}
diff --git a/ui-desktop/src/lib/gateway-tool-events.fixture.json b/ui-desktop/src/lib/gateway-tool-events.fixture.json
new file mode 100644
index 00000000..c6af1b82
--- /dev/null
+++ b/ui-desktop/src/lib/gateway-tool-events.fixture.json
@@ -0,0 +1,172 @@
+{
+ "bash": {
+ "complete": {
+ "name": "terminal",
+ "result": {
+ "output": "app\nlib\n"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "command": "ls src/"
+ },
+ "context": "ls src/",
+ "name": "terminal",
+ "tool_id": "call"
+ }
+ },
+ "edit": {
+ "complete": {
+ "name": "edit_file",
+ "result": {
+ "inline_diff": "--- /w/a.py\n+++ /w/a.py\n@@ -1,1 +1,1 @@\n-old\n+new",
+ "path": "/w/a.py"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "file_path": "/w/a.py",
+ "new_string": "new",
+ "old_string": "old",
+ "path": "/w/a.py"
+ },
+ "context": "/w/a.py",
+ "name": "edit_file",
+ "tool_id": "call"
+ }
+ },
+ "failure": {
+ "complete": {
+ "error": "Error: no such file",
+ "name": "terminal",
+ "result": {},
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "command": "cat nope"
+ },
+ "context": "cat nope",
+ "name": "terminal",
+ "tool_id": "call"
+ }
+ },
+ "glob": {
+ "complete": {
+ "name": "list_files",
+ "result": {
+ "context": "a.py\nb.py\nc.py",
+ "file_count": 3,
+ "output": "a.py\nb.py\nc.py"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "pattern": "*.py"
+ },
+ "context": "*.py",
+ "name": "list_files",
+ "tool_id": "call"
+ }
+ },
+ "grep": {
+ "complete": {
+ "name": "search_files",
+ "result": {
+ "context": "a.py:1:TODO\nb.py:4:TODO",
+ "match_count": 2,
+ "output": "a.py:1:TODO\nb.py:4:TODO"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "path": "/w",
+ "pattern": "TODO"
+ },
+ "context": "TODO",
+ "name": "search_files",
+ "tool_id": "call"
+ }
+ },
+ "read": {
+ "complete": {
+ "name": "read_file",
+ "result": {
+ "content": "1\tprint('seed')\n2\tprint('again')",
+ "context": "Read 2 lines"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "file_path": "/w/seed.py",
+ "path": "/w/seed.py"
+ },
+ "context": "/w/seed.py",
+ "name": "read_file",
+ "tool_id": "call"
+ }
+ },
+ "unknown_tool": {
+ "complete": {
+ "name": "Task",
+ "result": {
+ "context": "done",
+ "output": "done"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "description": "refactor"
+ },
+ "context": "refactor",
+ "name": "Task",
+ "tool_id": "call"
+ }
+ },
+ "web_search": {
+ "complete": {
+ "name": "web_search",
+ "result": {
+ "context": "Did 3 searches in 2s",
+ "duration_s": 2.4,
+ "output": "\u2026blob\u2026",
+ "result_count": 3
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "query": "weather"
+ },
+ "context": "weather",
+ "name": "web_search",
+ "tool_id": "call"
+ }
+ },
+ "write": {
+ "complete": {
+ "name": "write_file",
+ "result": {
+ "inline_diff": "--- /dev/null\n+++ /w/hi.py\n+print('hi')",
+ "path": "/w/hi.py"
+ },
+ "tool_id": "call"
+ },
+ "start": {
+ "args": {
+ "content": "print('hi')",
+ "file_path": "/w/hi.py",
+ "path": "/w/hi.py"
+ },
+ "context": "/w/hi.py",
+ "name": "write_file",
+ "tool_id": "call"
+ }
+ }
+}