diff --git a/agent/turn_failure_copy.py b/agent/turn_failure_copy.py index 1686f28c63..45e812d019 100644 --- a/agent/turn_failure_copy.py +++ b/agent/turn_failure_copy.py @@ -94,6 +94,9 @@ _EXIT_REASON_FAILURES: Tuple[Tuple[str, str, bool, bool], ...] = ( # Advisory: the reasoning-only text may literally be the answer, and cron stays silent. ("empty_response_exhausted", "empty_response", True, False), ("all_retries_exhausted_no_response", FailoverReason.server_error.value, True, True), + # #55316/#54756: the loop stopped on a tool tail with no follow-up text; the + # finalizer synthesizes the visible close and fails the turn. + ("pending_tool_result", "loop_error", True, True), ("interpreter_shutdown", "interpreter_shutdown", False, True), # Advisory: a deterministic local bug is not a task failure for the kanban breaker. ("local_processing_error", "loop_error", False, False), diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index c8460bb4d0..da4f472462 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -505,6 +505,40 @@ def finalize_turn( logger=logger, ) + # A non-interrupted turn that fell out of the loop after a tool result, with no + # follow-up assistant text, is the Desktop/TUI "silent stop" (#55316, #54756): the + # composer returns to ready (or keeps spinning) while the durable transcript ends + # at a raw ``tool`` row — the user never learns the turn stopped, and the next user + # message lands as ``tool → user``. Interrupted tails keep + # ``close_interrupted_tool_sequence``; this is the non-interrupt sibling. Mint the + # exit reason, fail the turn, and synthesize the visible close so the tail close in + # ``_persist_step`` persists an assistant row. A turn that already streamed text is + # left alone: ``_recover_final_from_stream`` owns that recovery (#95514). + if ( + not final_response + and not interrupted + and messages + and isinstance(messages[-1], dict) + and messages[-1].get("role") == "tool" + and not (getattr(agent, "_current_streamed_assistant_text", "") or "").strip() + ): + _turn_exit_reason = "pending_tool_result" + failed = True + final_response = "" + try: + if agent._turn_completion_explainer_enabled(): + final_response = ( + agent._format_turn_completion_explanation("pending_tool_result", None) or "" + ) + except Exception: + final_response = "" + if not final_response: + # The turn-completion explainer opt-out must not reintroduce the silent stop. + final_response = ( + "No reply: the turn stopped while a tool result was still pending. " + "Send `continue` to let the model summarize." + ) + # Loop exits that are failures in their own right (outer-loop error cap, shutdown, context # that could not be shrunk) carry the verdict the UI descriptor needs; a bare # ``turn_exit_reason`` collapsed to code="unknown", retryable=True on every surface. diff --git a/tests/agent/test_turn_finalizer_interrupt_alternation.py b/tests/agent/test_turn_finalizer_interrupt_alternation.py index 83158dafe3..081942c176 100644 --- a/tests/agent/test_turn_finalizer_interrupt_alternation.py +++ b/tests/agent/test_turn_finalizer_interrupt_alternation.py @@ -61,6 +61,8 @@ class _StubAgent: self.session_cost_status = "ok" self.session_cost_source = "stub" self.persisted_messages = None + # #95514 stream-recovery state read by the finalizer; None on a clean stub. + self._current_streamed_assistant_text: str | None = None # --- fallible cleanup surfaces (all succeed here) ------------------ def _save_trajectory(self, *a, **k): @@ -194,3 +196,65 @@ def test_interrupted_turn_with_diagnostic_text_is_not_completed(): assert result["interrupted"] is True assert result["completed"] is False assert result["failed"] is False + + +def _pending_tool_result_tail(): + """A non-interrupted turn that fell out of the loop after a tool result, with no + follow-up assistant text — the #55316/#54756 "silent stop" shape.""" + return [ + {"role": "user", "content": "summarize the log"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "function": {"name": "terminal", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "log output"}, + ] + + +def test_non_interrupted_tool_tail_gets_visible_close(): + """A turn that stops on a tool tail WITHOUT an interrupt must not return a silent, + ready-looking result: the finalizer fails the turn, mints the ``pending_tool_result`` + exit reason, and persists a visible assistant close so the durable transcript does + not end at a raw ``tool`` row (#55316, #54756).""" + agent = _StubAgent() + messages = _pending_tool_result_tail() + result = _finalize(agent, messages, interrupted=False, final_response=None) + + assert result["turn_exit_reason"] == "pending_tool_result" + assert result["failed"] is True + assert result["completed"] is False + assert result["final_response"].strip() + # The durable tail is an assistant row, not the raw tool result. + assert messages[-1]["role"] == "assistant" + assert messages[-1]["content"].strip() + assert agent.persisted_messages is not None + assert agent.persisted_messages[-1]["role"] == "assistant" + follow_on = agent.persisted_messages + [{"role": "user", "content": "continue"}] + _assert_no_tool_then_user(follow_on) + + +def test_tool_tail_with_streamed_text_is_recovered_not_marked_pending(): + """A turn whose stream already delivered text is #95514's stream-recovery case; the + ``pending_tool_result`` close must not fire over it.""" + agent = _StubAgent() + agent._current_streamed_assistant_text = "Here is the summary you asked for." + messages = _pending_tool_result_tail() + result = _finalize(agent, messages, interrupted=False, final_response=None) + + assert result["turn_exit_reason"] != "pending_tool_result" + assert result["final_response"] == "Here is the summary you asked for." + assert messages[-1]["role"] == "assistant" + + +def test_tool_tail_with_non_tool_last_role_is_untouched(): + """Only the tool-tail shape triggers the close; a plain user-tail turn keeps its + existing exit reason.""" + agent = _StubAgent() + result = _finalize( + agent, [{"role": "user", "content": "hi"}], interrupted=False, final_response=None, + ) + assert result["turn_exit_reason"] == "interrupted_by_user" # unchanged passthrough + assert result["failed"] is False