Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2589,6 +2589,13 @@ def test_load_enabled_toolsets_reports_disabled_mcp_separately(monkeypatch, caps


def test_history_to_messages_preserves_tool_calls_for_resume_display():
# Regression for the empty-assistant-with-tool_calls case. The legacy
# code path dropped the assistant frame (it had empty ``content``) and
# synthesized a ``tool`` role frame, but the synthesized frame did not
# carry ``tool_call_id`` so the desktop resume payload lost the link
# between the tool result and the call that produced it. The fix
# preserves the assistant frame with its ``tool_calls`` intact; the
# follow-up tool result frame pairs with it via the call id.
history = [
{"role": "user", "content": "first prompt"},
{
Expand All @@ -2611,11 +2618,25 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():

assert server._history_to_messages(history) == [
{"role": "user", "text": "first prompt"},
{
"role": "assistant",
"text": "",
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "search_files",
"arguments": json.dumps({"pattern": "resume"}),
},
}
],
},
{
"args": {"pattern": "resume"},
"context": "resume",
"name": "search_files",
"role": "tool",
"tool_call_id": "call_1",
},
{"role": "assistant", "text": "first answer"},
{"role": "user", "text": "second prompt"},
Expand Down Expand Up @@ -2724,9 +2745,10 @@ def test_history_to_messages_ships_full_tool_args():
]

rows = server._history_to_messages(history)
assert rows[1]["args"] == {"command": long_command}
# Assistant frame is preserved at index 1; the tool row follows at 2.
assert rows[2]["args"] == {"command": long_command}
# The preview stays alongside for the collapsed title.
assert rows[1]["context"]
assert rows[2]["context"]

# A tool row with no recorded args keeps the old small shape.
argless = server._history_to_messages(
Expand Down Expand Up @@ -2806,6 +2828,48 @@ def test_history_to_messages_still_drops_empty_assistant_without_reasoning():
]


def test_history_to_messages_preserves_tool_calls_on_assistant_frame():
"""Regression for the tool-call-only assistant frame (#43233 + Teknium
sweeper review 2026-07-25): the assistant frame with ``tool_calls`` but
empty ``content`` must survive ``_history_to_messages`` so the desktop
resume payload can route the tool invocation back to the right id.

Previously the assistant frame was dropped and only a synthetic ``tool``
role frame was emitted; that frame doesn't carry the ``tool_call_id``,
so the desktop resume payload lost the link between the tool result
and the call that produced it. The fix attaches the original
``tool_calls`` list to the assistant frame and lets the synthetic
tool frame carry the resolved context.
"""
history = [
{"role": "user", "content": "check status"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_42",
"function": {
"name": "terminal",
"arguments": json.dumps({"command": "uptime"}),
},
}
],
},
{"role": "tool", "content": "12:34", "tool_call_id": "call_42"},
{"role": "assistant", "content": "uptime is 12:34"},
]

msgs = server._history_to_messages(history)
assistant_with_tcs = [m for m in msgs if m.get("role") == "assistant" and m.get("tool_calls")]
assert len(assistant_with_tcs) == 1, msgs
assert assistant_with_tcs[0]["tool_calls"] == history[1]["tool_calls"]
# tool_call_id link survives via the tool frame so the desktop can pair
# the tool result with the call.
tool_frames = [m for m in msgs if m.get("role") == "tool"]
assert tool_frames and tool_frames[0]["role"] == "tool"


def test_history_to_messages_renders_multimodal_content():
# bb/gui preserves image URLs in the resume payload so the desktop
# renderer's extractEmbeddedImages can pull them back out and display
Expand Down
23 changes: 20 additions & 3 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9128,7 +9128,11 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
except (json.JSONDecodeError, TypeError):
args = {}
tool_call_args[tc_id] = (fn["name"], args)
if not content_text.strip():
# A tool-call-only assistant frame (empty ``content``) must
# survive so the desktop resume payload can route the tool
# invocation back to its id — dropping it here loses the link
# between the call and its result. (#43233 + Teknium review)
if not content_text.strip() and not m.get("tool_calls"):
continue
if role == "tool":
tc_id = m.get("tool_call_id", "")
Expand All @@ -9143,7 +9147,16 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
# truncation was permanent.
if args:
tool_msg["args"] = args
messages.append(tool_msg)
# Preserve the original tool result link so the desktop resume
# payload can pair the tool result with the call that produced it.
# The legacy code path dropped ``tool_call_id`` here, which broke
# the link for any consumer that needed to route the result back to
# its invocation (e.g. the assistant frame re-injection on resume).
# Keep the synthesized ``name`` + ``context`` for the human-readable
# affordance, and carry the original id forward. The raw result
# payload is deliberately NOT forwarded: tool output can contain
# secrets, and the desktop transcript must stay redacted.
messages.append({**tool_msg, "tool_call_id": tc_id})
continue
# An assistant turn may carry only reasoning/thinking content with no
# visible text (extended-thinking turns, thinking-only recovery
Expand All @@ -9161,14 +9174,18 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
has_reasoning = role == "assistant" and any(
m.get(key) for key in reasoning_keys
)
if not content_text.strip() and not has_reasoning:
if not content_text.strip() and not has_reasoning and not (
role == "assistant" and m.get("tool_calls")
):
continue
msg = {"role": role, "text": content_text}
# Persisted authoring time (Unix seconds) for display.timestamps
# renderers (#41531). Display-only: never fed back into model context.
ts = m.get("timestamp")
if isinstance(ts, (int, float)) and ts > 0:
msg["timestamp"] = float(ts)
if role == "assistant" and m.get("tool_calls"):
msg["tool_calls"] = m["tool_calls"]
# Durable row identity, stamped by _rows_to_conversation. The renderer's
# own message ids are ephemeral (timestamp+index derived, and a
# different shape for live vs rehydrated vs optimistic rows), so
Expand Down