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