feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"""Unit: deterministic model-stream teardown (phase 48, task 01).
|
||||
|
||||
The openai SDK stream must be closed on **every** exit of
|
||||
``LLMClient.chat_stream`` after a successful ``create()`` — normal
|
||||
exhaustion, a wrapped mid-stream failure, and consumer abandon
|
||||
(``aclose()`` on the ``chat_stream`` generator — the stop/cancel path,
|
||||
2026-08-29, ``TODO.md`` L3). The fakes stand in at the SDK boundary: a
|
||||
recording async stream (small sleeps between chunks so an abandon can
|
||||
land mid-iteration) behind an ``AsyncOpenAI``-shaped client, following
|
||||
the fake patterns of ``tests/unit/test_llm_client.py``.
|
||||
|
||||
Note on the close method: the phase text says ``aclose()`` — the
|
||||
installed openai SDK's ``AsyncStream`` exposes the async ``close()``,
|
||||
which awaits the underlying httpx response's ``aclose()``; that is the
|
||||
method under test (a quiet no-op on an already-ended SDK stream, so the
|
||||
completed path stays byte-identical).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.rag.llm import LLMClient, LLMError, StreamPiece
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _chunk(content: str) -> SimpleNamespace:
|
||||
"""One fake ChatCompletionChunk (``choices[].delta.content`` shape)."""
|
||||
return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=content))])
|
||||
|
||||
|
||||
def _tool_chunk() -> SimpleNamespace:
|
||||
"""One chunk carrying a malformed-arguments tool call (index 0)."""
|
||||
fn = SimpleNamespace(name="read_document", arguments='{"source": "Homelab",')
|
||||
tc = SimpleNamespace(index=0, id="call_x", function=fn)
|
||||
delta = SimpleNamespace(content=None, tool_calls=[tc])
|
||||
return SimpleNamespace(choices=[SimpleNamespace(delta=delta)])
|
||||
|
||||
|
||||
class _RecordingStream:
|
||||
"""A fake SDK stream: yields *chunks* (small sleeps between them, so
|
||||
an abandon can land mid-iteration) and records ``close()`` calls."""
|
||||
|
||||
def __init__(self, chunks: list, delay: float = 0.005) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self._delay = delay
|
||||
self._i = 0
|
||||
self.close_calls = 0
|
||||
|
||||
def __aiter__(self) -> _RecordingStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> SimpleNamespace:
|
||||
if self._i >= len(self._chunks):
|
||||
raise StopAsyncIteration
|
||||
await asyncio.sleep(self._delay)
|
||||
chunk = self._chunks[self._i]
|
||||
self._i += 1
|
||||
return chunk
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
|
||||
|
||||
class _FailingStream(_RecordingStream):
|
||||
"""Same shape, but ``__anext__`` raises a transport error after
|
||||
*fail_after* chunks (the mid-iteration failure path)."""
|
||||
|
||||
def __init__(self, chunks: list, fail_after: int, delay: float = 0.005) -> None:
|
||||
super().__init__(chunks, delay)
|
||||
self._fail_after = fail_after
|
||||
|
||||
async def __anext__(self) -> SimpleNamespace:
|
||||
self._i += 1
|
||||
if self._i > self._fail_after:
|
||||
raise ConnectionError("simulated mid-stream drop")
|
||||
if self._i > len(self._chunks):
|
||||
raise StopAsyncIteration
|
||||
await asyncio.sleep(self._delay)
|
||||
chunk = self._chunks[self._i - 1]
|
||||
return chunk
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
"""``chat.completions.create(stream=True)`` → the fake stream (or a
|
||||
create-level failure)."""
|
||||
|
||||
def __init__(self, stream: _RecordingStream | Exception) -> None:
|
||||
self._stream = stream
|
||||
self.kwargs: dict | None = None
|
||||
|
||||
async def create(self, **kwargs: Any):
|
||||
self.kwargs = kwargs
|
||||
assert kwargs.get("stream") is True
|
||||
if isinstance(self._stream, Exception):
|
||||
raise self._stream
|
||||
return self._stream
|
||||
|
||||
|
||||
def _make_client(
|
||||
stream: _RecordingStream | Exception,
|
||||
) -> tuple[LLMClient, _FakeCompletions]:
|
||||
completions = _FakeCompletions(stream)
|
||||
llm = LLMClient(_settings())
|
||||
llm._client = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue]
|
||||
chat=SimpleNamespace(completions=completions)
|
||||
)
|
||||
return llm, completions
|
||||
|
||||
|
||||
def _content_chunks(n: int) -> list:
|
||||
return [_chunk(f"piece {i} ") for i in range(1, n + 1)]
|
||||
|
||||
|
||||
async def _drain(llm: LLMClient) -> list[StreamPiece]:
|
||||
"""Tools-less drain: without a ``tools`` list no ToolCallPiece can
|
||||
appear (the phase-37 contract)."""
|
||||
pieces = [p async for p in llm.chat_stream([{"role": "user", "content": "q"}])]
|
||||
assert all(isinstance(p, StreamPiece) for p in pieces)
|
||||
return cast("list[StreamPiece]", pieces)
|
||||
|
||||
|
||||
def test_full_consumption_closes_stream_exactly_once() -> None:
|
||||
"""(a) A fully consumed stream still gets the explicit close — the
|
||||
completed path keeps its behavior (a quiet no-op on the real SDK
|
||||
stream) while the teardown is pinned."""
|
||||
stream = _RecordingStream(_content_chunks(3))
|
||||
llm, _ = _make_client(stream)
|
||||
pieces = asyncio.run(_drain(llm))
|
||||
assert [p.text for p in pieces] == ["piece 1 ", "piece 2 ", "piece 3 "]
|
||||
assert stream.close_calls == 1
|
||||
|
||||
|
||||
def test_mid_iteration_abandon_closes_stream_before_close_completes() -> None:
|
||||
"""(b) Abandoning the ``chat_stream`` generator after the first
|
||||
piece (``await gen.aclose()`` — the consumer-stop path) must await
|
||||
the SDK stream's close before the generator's close completes."""
|
||||
stream = _RecordingStream(_content_chunks(10))
|
||||
llm, _ = _make_client(stream)
|
||||
|
||||
async def abandon_after_first() -> None:
|
||||
gen = llm.chat_stream([{"role": "user", "content": "q"}])
|
||||
first = await gen.__anext__()
|
||||
assert isinstance(first, StreamPiece)
|
||||
assert first.text == "piece 1 "
|
||||
# The generator is suspended at its first yield; aclose must run
|
||||
# the finally (the SDK stream's close) before it returns.
|
||||
await gen.aclose()
|
||||
|
||||
asyncio.run(abandon_after_first())
|
||||
assert stream.close_calls == 1
|
||||
|
||||
|
||||
def test_create_failure_wraps_and_never_closes() -> None:
|
||||
"""A ``create()`` failure keeps today's wrap — generic exception →
|
||||
``LLMError`` with the base URL — and no stream exists to close."""
|
||||
llm, _ = _make_client(ConnectionError("connection reset by peer"))
|
||||
with pytest.raises(LLMError, match="chat stream from .* failed") as exc:
|
||||
asyncio.run(_drain(llm))
|
||||
assert "connection reset by peer" in str(exc.value)
|
||||
|
||||
|
||||
def test_mid_iteration_failure_wraps_and_closes() -> None:
|
||||
"""A failure inside the ``async for`` wraps exactly as before
|
||||
(``LLMError`` with the original message) — and the stream is closed
|
||||
on the exception path."""
|
||||
stream = _FailingStream(_content_chunks(10), fail_after=2)
|
||||
llm, _ = _make_client(stream)
|
||||
|
||||
async def drain() -> None:
|
||||
async for _ in llm.chat_stream([{"role": "user", "content": "q"}]):
|
||||
pass
|
||||
|
||||
with pytest.raises(LLMError, match="simulated mid-stream drop"):
|
||||
asyncio.run(drain())
|
||||
assert stream.close_calls == 1
|
||||
|
||||
|
||||
def test_llm_error_materialization_passes_through_and_closes() -> None:
|
||||
"""An ``LLMError`` from tool-call materialization (after the loop,
|
||||
before normal exhaustion) re-raises unwrapped — and the stream is
|
||||
still closed on the way out."""
|
||||
stream = _RecordingStream([_tool_chunk()])
|
||||
llm, _ = _make_client(stream)
|
||||
|
||||
async def drain() -> None:
|
||||
async for _ in llm.chat_stream(
|
||||
[{"role": "user", "content": "q"}],
|
||||
tools=[{"type": "function", "function": {"name": "read_document"}}],
|
||||
):
|
||||
pass
|
||||
|
||||
with pytest.raises(LLMError, match="malformed tool-call arguments"):
|
||||
asyncio.run(drain())
|
||||
assert stream.close_calls == 1
|
||||
Reference in New Issue
Block a user