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"") +_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" + } + } +}