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:
parent
461c493972
commit
c002b6fbe5
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue