diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 04c59ae7fd1d..140805df663b 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2998,6 +2998,40 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= _dropped_tool_names=_dropped_names or None, ) + # Text-only stream drop: the upstream closed the connection (or the + # SSE stream simply ended) with no finish_reason after delivering + # text content but no tool calls. Without this guard the partial + # text is silently stamped finish_reason="stop" and the turn ends as + # if complete — the model's intended next step is lost (#32086). + _text_only_dropped_no_finish = ( + finish_reason is None + and content_parts + and not tool_calls_acc + ) + if _text_only_dropped_no_finish: + logger.warning( + "Stream ended with no finish_reason after delivering text " + "with no tool calls; treating as a mid-stream drop." + ) + full_reasoning = "".join(reasoning_parts) or None + mock_message = SimpleNamespace( + role=role, + content=full_content, + tool_calls=None, + reasoning_content=full_reasoning, + ) + mock_choice = SimpleNamespace( + index=0, + message=mock_message, + finish_reason=FINISH_REASON_LENGTH, + ) + return SimpleNamespace( + id=PARTIAL_STREAM_STUB_ID, + model=model_name, + choices=[mock_choice], + usage=usage_obj, + ) + effective_finish_reason = finish_reason or "stop" if has_truncated_tool_args: effective_finish_reason = "length" diff --git a/tests/run_agent/test_partial_stream_finish_reason.py b/tests/run_agent/test_partial_stream_finish_reason.py index 62b40d8091ff..6a31efe27a00 100644 --- a/tests/run_agent/test_partial_stream_finish_reason.py +++ b/tests/run_agent/test_partial_stream_finish_reason.py @@ -230,6 +230,45 @@ class TestCleanStreamEndMidToolCall: assert response.id.startswith("stream-") assert response.choices[0].finish_reason == FINISH_REASON_LENGTH + @patch("run_agent.AIAgent._create_request_openai_client") + @patch("run_agent.AIAgent._close_request_openai_client") + def test_no_finish_reason_text_only_routes_to_stub( + self, _mock_close, mock_create, monkeypatch, + ): + """A clean stream-end with no finish_reason after text-only + delivery must route through the partial-stream-stub path so the + conversation loop continues instead of silently accepting + truncated text as a complete response (#32086).""" + + def _clean_ending_stream(): + yield _make_stream_chunk(content="Let me compare the ") + yield _make_stream_chunk(content="vision configs:") + # falls off the end — clean close, no terminator + + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = ( + lambda *a, **kw: _clean_ending_stream() + ) + mock_create.return_value = mock_client + + agent = _make_agent() + agent._fire_stream_delta = lambda text: None + + response = agent._interruptible_streaming_api_call({}) + + assert response.id == PARTIAL_STREAM_STUB_ID, ( + "A clean stream-end with no finish_reason after text-only " + "delivery must be tagged as a partial-stream stub, not " + "silently accepted as complete (#32086)." + ) + assert response.choices[0].finish_reason == FINISH_REASON_LENGTH + assert response.choices[0].message.content == "Let me compare the vision configs:" + assert response.choices[0].message.tool_calls is None + assert getattr(response, "_dropped_tool_names", None) is None, ( + "Text-only drops must not carry dropped tool names — there " + "were no tool calls in flight." + ) + # ── Length-continuation prompt branching ──────────────────────────────────