fix(streaming): detect text-only stream drops with no finish_reason (#32086)

When a streaming response ends cleanly (HTTP 200) with no finish_reason
after delivering text but no tool calls, the chunk collector silently
stamps finish_reason='stop' and the conversation loop presents truncated
text as a complete response.

Three stream-drop paths now exist after chunk collection:

1. Zero-chunk → EmptyStreamError, retried (existing)
2. Tool-call in progress, no finish_reason → partial-stream-stub (existing)
3. Text-only, no finish_reason → partial-stream-stub (NEW — this fix)

Path 3 routes through the same PARTIAL_STREAM_STUB_ID + FINISH_REASON_LENGTH
machinery as path 2. The conversation loop shows 'Stream interrupted —
requesting continuation' and injects a continue prompt, giving the model
a chance to resume where the stream dropped.

Observed with DeepSeek provider where CloudFront drops SSE streams
mid-response after delivering partial text.
This commit is contained in:
ajzrva-sys 2026-07-19 13:45:31 -04:00 committed by kshitij
parent 134c2ed8b3
commit 65bb16c8ce
2 changed files with 73 additions and 0 deletions

View file

@ -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"

View file

@ -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 ──────────────────────────────────