fix(desktop): send full tool args so expanded rows show the whole command

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.
This commit is contained in:
ethernet 2026-08-09 16:23:05 -04:00
parent 461c493972
commit c002b6fbe5
5 changed files with 119 additions and 6 deletions

View File

@ -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 },

View File

@ -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',

View File

@ -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

View File

@ -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("<ctx>")` and the desktop prepends "Running"/"Ran". Sending a

View File

@ -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