diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index deba3bd53e22..39775949095e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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"}, { @@ -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"}, @@ -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( @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a2fe9bd5cb0e..62932b84f8b6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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", "") @@ -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 @@ -9161,7 +9174,9 @@ 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 @@ -9169,6 +9184,8 @@ def _history_to_messages(history: list[dict]) -> list[dict]: 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