Files
turnstone/tests/test_drain_stream.py
T
Patrick Buckley cf7cfe8932 fix(providers): review round 4 — wire-error retryability, tap/mirror slot parity, terminal completeness
Correctness:

- drain_stream chains raw httpx.TransportError from stream iteration
  into retryable IncompleteStreamError (original type+message preserved
  via __cause__): streaming moved the body read out of the SDK's
  APIConnectionError-wrapped request, so mid-body connection drops and
  read timeouts — retried transparently on 1.7 — were escaping every
  single-shot retry loop as instantly-fatal raw httpx names.
- The index remap is extracted as ToolCallSlotter and GoogleProvider's
  raw tap slots THROUGH IT over the same delta sequence as the base
  iterator: round 3's mirror-side de-fusion had left the tap keying by
  wire index, so a degenerate stream produced 2 mirror calls vs 1 fused
  raw dict — _prepare_messages' length gate then silently dropped the
  thought_signature lane (400 on signature-strict Gemini models).
- The slotter also splits ID-LESS degenerate parallel calls: a delta
  announcing a name for a slot that already accumulated arguments is a
  second whole call, not a fragment (fragmented single calls pinned
  unaffected).
- A payload-less Responses terminal event keeps the provider_blocks
  already collected from output_item.done events (they came from the
  stream, not the missing payload); only usage is genuinely lost.
- The truncation-rebuild path walks the terminal output's message
  annotations, so truncated web-search turns keep their Sources footer
  (the in-flight item never received output_item.done).

Cleanup: one _raise_responses_failure ladder serves both in-band
failure shapes (error events + response.failed); IncompleteStreamError
joins the public providers export (docstrings tell callers to catch
it); the de-fusion tests ride the file's existing _openai_stream_chunk
helpers instead of a third hand-rolled SSE fake; the dead if-response
guard in the terminal branch is gone.

Deferred with note: classifying IncompleteStreamError once at the
retry-predicate consultation site instead of per-provider strings is
#832 territory (the predicate lives in ChatSession); the six-lane
parametrized test guards the listing until then.
2026-07-13 22:39:19 -07:00

298 lines
11 KiB
Python

"""Unit tests for ``drain_stream`` — the #831 single non-streaming transport.
Every single-shot lane consumes ``create_streaming`` through this
accumulator, so its semantics ARE the old ``create_completion`` contract:
each case here pins a rule the per-adapter non-streaming methods used to
implement independently (usage max-merge, tool-delta assembly, terminal
provider_blocks, trailing-citation fold).
"""
from __future__ import annotations
import pytest
from turnstone.core.providers import (
StreamChunk,
ToolCallDelta,
UsageInfo,
drain_stream,
)
from turnstone.core.providers._openai_common import RETRYABLE_ERROR_NAMES
from turnstone.core.providers._protocol import IncompleteStreamError
class TestContentAndReasoning:
def test_joins_content_deltas_in_order(self):
result = drain_stream(
iter(
[
StreamChunk(content_delta="Hello, "),
StreamChunk(content_delta="world"),
StreamChunk(finish_reason="stop"),
]
)
)
assert result.content == "Hello, world"
assert result.finish_reason == "stop"
def test_joins_reasoning_deltas_separately_from_content(self):
result = drain_stream(
iter(
[
StreamChunk(reasoning_delta="think "),
StreamChunk(reasoning_delta="hard"),
StreamChunk(content_delta="answer"),
StreamChunk(finish_reason="stop"),
]
)
)
assert result.reasoning == "think hard"
assert result.content == "answer"
def test_stream_without_finish_reason_raises_incomplete(self):
# Complete-or-error: every adapter emits a finish reason on a
# healthy stream, so its absence means the generation died
# mid-response — partial text must never be stored as a complete
# result (compaction summary, title). Typed and retryable.
assert "IncompleteStreamError" in RETRYABLE_ERROR_NAMES
with pytest.raises(IncompleteStreamError):
drain_stream(iter([StreamChunk(content_delta="half a summar")]))
def test_empty_stream_raises_incomplete(self):
with pytest.raises(IncompleteStreamError):
drain_stream(iter([]))
class TestToolCallAssembly:
def test_merges_deltas_by_index_id_name_once_args_concat(self):
result = drain_stream(
iter(
[
StreamChunk(
tool_call_deltas=[ToolCallDelta(index=0, id="call_1", name="read_file")]
),
StreamChunk(
tool_call_deltas=[ToolCallDelta(index=0, arguments_delta='{"path": ')]
),
StreamChunk(
tool_call_deltas=[ToolCallDelta(index=0, arguments_delta='"x.py"}')]
),
StreamChunk(finish_reason="tool_calls"),
]
)
)
assert result.tool_calls == [
{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path": "x.py"}'},
}
]
def test_parallel_calls_ordered_by_index(self):
# Interleaved argument deltas for two calls must not cross-contaminate,
# and the assembled list is index-ordered regardless of arrival order.
result = drain_stream(
iter(
[
StreamChunk(tool_call_deltas=[ToolCallDelta(index=1, id="b", name="beta")]),
StreamChunk(tool_call_deltas=[ToolCallDelta(index=0, id="a", name="alpha")]),
StreamChunk(
tool_call_deltas=[
ToolCallDelta(index=0, arguments_delta="{}"),
ToolCallDelta(index=1, arguments_delta='{"k": 1}'),
]
),
StreamChunk(finish_reason="tool_calls"),
]
)
)
assert [tc["id"] for tc in result.tool_calls] == ["a", "b"]
assert result.tool_calls[1]["function"]["arguments"] == '{"k": 1}'
def test_blank_id_preserved_for_downstream_repair(self):
# Google compat can stream blank tool ids — the drain must hand them
# through untouched so model_turn's pairwise blank-id repair sees them.
result = drain_stream(
iter(
[
StreamChunk(tool_call_deltas=[ToolCallDelta(index=0, name="f")]),
StreamChunk(finish_reason="tool_calls"),
]
)
)
assert result.tool_calls[0]["id"] == ""
# Index-degenerate parallel-call de-fusion lives in the CHAT ADAPTER's
# iterator (so the interactive loop is fixed too) — pinned in
# test_providers.py::TestOpenAIProvider::
# test_streaming_remaps_index_degenerate_parallel_calls. The drain
# accumulates by index verbatim; adapters own index sanity.
class TestUsageMerge:
def test_anthropic_split_emission_max_merges(self):
# message_start carries prompt tokens (completion 0); message_delta
# carries completion tokens (prompt possibly absent → 0). Neither
# first-wins nor last-wins sees both — the max-merge does.
result = drain_stream(
iter(
[
StreamChunk(
usage=UsageInfo(
prompt_tokens=120,
completion_tokens=0,
total_tokens=120,
cache_read_tokens=100,
)
),
StreamChunk(content_delta="hi"),
StreamChunk(
usage=UsageInfo(prompt_tokens=0, completion_tokens=42, total_tokens=42),
finish_reason="stop",
),
]
)
)
assert result.usage.prompt_tokens == 120
assert result.usage.completion_tokens == 42
assert result.usage.total_tokens == 162
assert result.usage.cache_read_tokens == 100
def test_single_terminal_usage_passes_through(self):
result = drain_stream(
iter(
[
StreamChunk(content_delta="x"),
StreamChunk(finish_reason="stop"),
StreamChunk(
usage=UsageInfo(prompt_tokens=10, completion_tokens=5, total_tokens=15)
),
]
)
)
assert result.usage.total_tokens == 15
class TestFinishAndBlocks:
def test_finish_reason_last_non_none_wins(self):
result = drain_stream(
iter(
[
StreamChunk(finish_reason="tool_calls"),
StreamChunk(content_delta="tail"),
StreamChunk(finish_reason="stop"),
]
)
)
assert result.finish_reason == "stop"
def test_provider_blocks_taken_from_terminal_emission(self):
# Every adapter attaches its full block list exactly once (on or
# after the terminal chunk); replace-on-nonempty keeps the last set.
blocks = [{"type": "thinking", "thinking": "t", "signature": "s"}]
result = drain_stream(
iter(
[
StreamChunk(content_delta="a"),
StreamChunk(finish_reason="stop", provider_blocks=blocks),
]
)
)
assert result.provider_blocks == blocks
class TestInfoDelta:
def test_mid_stream_status_pings_dropped(self):
# "[Searching…]" style transient status — the non-streaming lane
# never surfaced these, so the drain must not leak them into content.
result = drain_stream(
iter(
[
StreamChunk(info_delta="[Searching: quakes]"),
StreamChunk(content_delta="answer"),
StreamChunk(finish_reason="stop"),
]
)
)
assert result.content == "answer"
def test_trailing_citations_fold_matches_format_citations(self):
# The chat/responses adapters emit format_citations("", anns).strip()
# as a final info chunk after the finish reason. Folding it back as
# content + "\n\n" + info must byte-match the old non-streaming
# format_citations(content, anns) append.
from turnstone.core.providers._openai_common import format_citations
class _Ann:
type = "url_citation"
url = "https://example.com"
title = "Example"
url_citation = None
anns = [_Ann()]
trailing = format_citations("", anns).strip()
result = drain_stream(
iter(
[
StreamChunk(content_delta="body"),
StreamChunk(finish_reason="stop"),
StreamChunk(info_delta=trailing),
]
)
)
assert result.content == format_citations("body", anns)
def test_trailing_fold_with_empty_content_matches_too(self):
result = drain_stream(
iter(
[
StreamChunk(finish_reason="tool_calls"),
StreamChunk(info_delta="Sources:\n- x"),
]
)
)
assert result.content == "\n\nSources:\n- x"
def test_finishless_stream_raises_even_with_trailing_info(self):
# A stream that dies after a status ping must NOT return the ping
# as content (nor the partial body as a clean result) — the
# complete-or-error gate turns the whole stream into a retryable
# error instead of guessing which trailing info was a citation.
with pytest.raises(IncompleteStreamError):
drain_stream(
iter(
[
StreamChunk(content_delta="body"),
StreamChunk(info_delta="[Searching: kubernetes CVEs]"),
]
)
)
class TestErrorPropagation:
def test_httpx_transport_error_becomes_retryable_incomplete(self):
# Streaming moves the body read out of the SDK's wrapped request:
# a mid-body wire failure surfaces as a raw httpx.TransportError
# no retry predicate recognizes. The drain re-raises it (chained,
# message preserved) as the retryable IncompleteStreamError.
import httpx
def chunks():
yield StreamChunk(content_delta="partial")
raise httpx.RemoteProtocolError("peer closed connection")
with pytest.raises(IncompleteStreamError, match="RemoteProtocolError") as excinfo:
drain_stream(chunks())
assert isinstance(excinfo.value.__cause__, httpx.RemoteProtocolError)
def test_mid_stream_exception_propagates_verbatim(self):
# Retry/deadline/fallback policy is the caller's — the drain adds
# no exception translation, exactly like the old transport.
def chunks():
yield StreamChunk(content_delta="partial")
raise RuntimeError("upstream broke")
with pytest.raises(RuntimeError, match="upstream broke"):
drain_stream(chunks())