From c002b6fbe596d4cf9f03797a6beb94d123e5de37 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sun, 9 Aug 2026 16:23:05 -0400 Subject: [PATCH] fix(desktop): send full tool args so expanded rows show the whole command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway sent only an 80-char preview (context) for a tool call. The desktop rebuilds the expanded tool row from the args of the part. When the args were absent, the row showed the preview, and long commands ended in '...' after the user expanded them. Two paths had this fault: - tool.start: the payload had no args until tool.complete, so the expanded row was truncated while the tool ran. Now tool.start ships the args, the same as tool.complete already does. - _history_to_messages: the projection read the full arguments, then discarded them. Hydration from this projection (watch windows, compress, branch, seeded create) kept only the preview, so the truncation was permanent. Now tool rows carry the args. This projection is the display view of the transcript — each renderer decides what to paint, and the preview stays for collapsed titles. The DB rows do not change: the args already persist in tool_calls. --- apps/desktop/src/lib/chat-messages.test.ts | 24 ++++++++ apps/desktop/src/lib/chat-messages.ts | 7 ++- apps/desktop/src/types/hermes.ts | 6 ++ tests/test_tui_gateway_server.py | 67 +++++++++++++++++++++- tui_gateway/server.py | 21 +++++-- 5 files changed, 119 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 00b2bd78962da..ff54042a17cca 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -17,6 +17,30 @@ import { } from './chat-messages' describe('toChatMessages', () => { + it('rebuilds the full command from a gateway tool row carrying args', () => { + // Gateway watch-window hydration projects tool rows as + // {role:'tool', name, context, args?}. `context` is an 80-char preview; + // the backend also ships the full args, and the part must carry them so + // the expanded `$` transcript shows the whole command. + const longCommand = `echo ${'x'.repeat(200)}` + const messages = toChatMessages([ + { role: 'user', content: 'run it', timestamp: 1 }, + { + role: 'tool', + name: 'terminal', + content: '', + context: `${longCommand.slice(0, 79)}…`, + args: { command: longCommand }, + timestamp: 2 + } + ]) + + const toolPart = messages.flatMap(m => m.parts).find(part => part.type === 'tool-call') + + expect(toolPart).toBeDefined() + expect((toolPart as { args: { command?: string } }).args.command).toBe(longCommand) + }) + it('keeps a turn with interleaved tool-only rows in a single bubble', () => { const messages = toChatMessages([ { role: 'assistant', content: 'Planning.', timestamp: 1 }, diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index f58cd9d16f27e..0978683ca36ab 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -861,7 +861,12 @@ function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: Ses function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart { const name = toolMessage.tool_name || toolMessage.name || 'tool' const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '') - const args = context ? { context } : {} + // Prefer the full arguments when the gateway projection carries them: + // `context` is an 80-char display preview, and the expanded tool row + // rebuilds the real command from args. Keep `context` alongside as the + // title-side placeholder. + const storedArgs = parseMaybeJsonObject(toolMessage.args) + const args = { ...storedArgs, ...(context ? { context } : {}) } return { type: 'tool-call', diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 7b298a5fccfd8..ff45339ee7eb4 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -542,6 +542,12 @@ export interface MessageReaction { } export interface SessionMessage { + /** + * Full tool arguments for a gateway-projected tool row (`role: 'tool'`). + * `context` is an 80-char display preview. The expanded tool row rebuilds + * the full call from this field. Absent on a backend older than this app. + */ + args?: unknown codex_reasoning_items?: unknown content: unknown context?: unknown diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index eb45736453d62..1dafba2dc5012 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2190,12 +2190,77 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display(): assert server._history_to_messages(history) == [ {"role": "user", "text": "first prompt"}, - {"context": "resume", "name": "search_files", "role": "tool"}, + { + "args": {"pattern": "resume"}, + "context": "resume", + "name": "search_files", + "role": "tool", + }, {"role": "assistant", "text": "first answer"}, {"role": "user", "text": "second prompt"}, ] +def test_history_to_messages_ships_full_tool_args(): + # This is the display projection. `context` is an 80-char preview for + # collapsed row titles. A renderer that shows the full call (the expanded + # `$` transcript in the desktop) rebuilds it from `args`. When the + # projection dropped the args, the preview truncation was permanent. + long_command = "echo " + "x" * 200 + history = [ + {"role": "user", "content": "run it"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "terminal", + "arguments": json.dumps({"command": long_command}), + }, + } + ], + }, + {"role": "tool", "content": "{}", "tool_call_id": "call_1"}, + ] + + rows = server._history_to_messages(history) + assert rows[1]["args"] == {"command": long_command} + # The preview stays alongside for the collapsed title. + assert rows[1]["context"] + + # A tool row with no recorded args keeps the old small shape. + argless = server._history_to_messages( + [{"role": "tool", "content": "{}", "tool_call_id": "missing"}] + ) + assert "args" not in argless[0] + + +def test_tool_start_ships_full_args(monkeypatch): + # The desktop rebuilds the expanded row's `$` transcript from args. When + # only the 80-char `context` preview shipped, the expanded command was + # truncated until tool.complete. tool.complete already ships full args to + # every client, so tool.start does too. There is no per-client gate. + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + long_command = "echo " + "y" * 200 + monkeypatch.setitem( + server._sessions, + "args-test", + {"source": "desktop", "tool_progress_mode": "all", "tool_started_at": {}}, + ) + + server._on_tool_start("args-test", "tool-1", "terminal", {"command": long_command}) + server._on_tool_start("args-test", "tool-2", "terminal", {}) + + assert events[0][2]["args"] == {"command": long_command} + # Empty args stay omitted. Argless tools get no noise key. + assert "args" not in events[1][2] + + def test_tool_ctx_sends_an_arg_preview_not_a_phrased_label(): # Clients phrase their own verb around this string: the TUI renders # `Terminal("")` and the desktop prepends "Running"/"Ran". Sending a diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e44707c3f6e1c..f46a2617e3316 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5472,11 +5472,18 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): pass session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid) or _tool_lifecycle_required_for_ui(name): - payload = { + payload: dict[str, object] = { "tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args), } + # The desktop renders the expanded tool row (the `$` transcript) from + # the args of the part, and `context` is an 80-char display preview. + # tool.complete already ships full args to every client. When + # tool.start ships them too, the expanded row is complete while the + # tool runs, at the cost of one duplicate transient payload per call. + if args: + payload["args"] = args if _session_verbose(sid): args_text = _tool_args_text(args) if args_text: @@ -7145,9 +7152,15 @@ def _history_to_messages(history: list[dict]) -> list[dict]: tc_info = tool_call_args.get(tc_id) if tc_id else None name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" args = (tc_info[1] if tc_info else None) or {} - messages.append( - {"role": "tool", "name": name, "context": _tool_ctx(name, args)} - ) + tool_msg = {"role": "tool", "name": name, "context": _tool_ctx(name, args)} + # This is the display projection, so keep it faithful. `context` + # is an 80-char preview for collapsed row titles. A renderer that + # shows the full call (the expanded `$` transcript in the desktop) + # rebuilds it from args. When only the preview shipped, that + # truncation was permanent. + if args: + tool_msg["args"] = args + messages.append(tool_msg) continue # An assistant turn may carry only reasoning/thinking content with no # visible text (extended-thinking turns, thinking-only recovery