diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d29f5b12a..47c476b01 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -847,6 +847,41 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display(): ] +def test_history_to_messages_keeps_reasoning_only_assistant_turn(): + # A thinking-only assistant turn (reasoning present, no visible text) is + # persisted and recallable, but was dropped from the resumed session view + # as "empty" -- so it vanished while the agent could still recall it from + # the transcript. Keep it (with reasoning) so the desktop "Thinking…" + # disclosure renders. (#44022) + history = [ + {"role": "user", "content": "think about this"}, + {"role": "assistant", "content": "", "reasoning": "step-by-step thoughts"}, + {"role": "assistant", "content": "here is the answer"}, + ] + + assert server._history_to_messages(history) == [ + {"role": "user", "text": "think about this"}, + {"role": "assistant", "text": "", "reasoning": "step-by-step thoughts"}, + {"role": "assistant", "text": "here is the answer"}, + ] + + +def test_history_to_messages_still_drops_empty_assistant_without_reasoning(): + # A genuinely empty assistant turn (no text, no reasoning, no tool calls) + # remains filtered out -- the fix only spares reasoning-bearing turns. + history = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "", "reasoning": ""}, + {"role": "assistant", "content": " "}, + {"role": "assistant", "content": "real reply"}, + ] + + assert server._history_to_messages(history) == [ + {"role": "user", "text": "hi"}, + {"role": "assistant", "text": "real reply"}, + ] + + 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 a54453aea..774deb89f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3708,16 +3708,27 @@ def _history_to_messages(history: list[dict]) -> list[dict]: {"role": "tool", "name": name, "context": _tool_ctx(name, args)} ) continue - if not content_text.strip(): + # An assistant turn may carry only reasoning/thinking content with no + # visible text (extended-thinking turns, thinking-only recovery + # responses). Such a turn is persisted with its reasoning fields and is + # recallable from the transcript, but dropping it here as "empty" makes + # it vanish from the resumed/reloaded session view while the desktop's + # reasoning disclosure has nothing to render. Keep it when it carries + # reasoning so the "Thinking…" block still shows. (#44022) + reasoning_keys = ( + "reasoning", + "reasoning_content", + "reasoning_details", + "codex_reasoning_items", + ) + has_reasoning = role == "assistant" and any( + m.get(key) for key in reasoning_keys + ) + if not content_text.strip() and not has_reasoning: continue msg = {"role": role, "text": content_text} if role == "assistant": - for key in ( - "reasoning", - "reasoning_content", - "reasoning_details", - "codex_reasoning_items", - ): + for key in reasoning_keys: if key in m and m.get(key) is not None: msg[key] = m.get(key) messages.append(msg)