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,419 @@
|
||||
"""Unit: client-disconnect teardown of a chat turn (phase 48, task 01).
|
||||
|
||||
Drives ``POST /api/chat`` through the real ASGI app — a real
|
||||
``LLMClient`` with a slow, recording fake SDK stream behind it, plus the
|
||||
fake DB session / retriever pattern from ``tests/unit/test_chat_gate.py``
|
||||
— with an ASGI-level client disconnect: after a few SSE frames the
|
||||
``receive`` channel starts returning ``http.disconnect``, the ASGI 2.0
|
||||
contract this Starlette's ``StreamingResponse`` listens for (its task
|
||||
group cancels the body task, and the abandoned body generator chain is
|
||||
finalized by the loop's PEP 525 asyncgen hooks). The ``TestClient``
|
||||
transport buffers the whole body and cannot drop a connection
|
||||
mid-stream, so the disconnect is emulated at the ASGI boundary —
|
||||
exactly where a real server hands it over.
|
||||
|
||||
Owner-locked contract (2026-08-29): on a cancelled turn the model's
|
||||
stream is closed promptly, one ``chat: turn cancelled`` log line is
|
||||
written, **no** ``query_log`` row exists, and no ``done``/``error``
|
||||
frame follows the disconnect — while completed turns and the
|
||||
mid-stream ``LLMError`` path settle exactly as before (``done`` frame +
|
||||
query_log row; structured ``error`` frame, not logged as cancelled).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, MutableMapping
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api import chat as chat_api
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Document, KbOverview, QueryLog
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.retriever import RetrievedChunk
|
||||
|
||||
|
||||
def _doc(title: str, content: str) -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
source="Homelab",
|
||||
path=f"{title.lower().replace(' ', '-')}.md",
|
||||
full_path="/tmp/doc.md",
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _chunk(doc: Document, cosine: float, fts_hit: bool = False) -> RetrievedChunk:
|
||||
return RetrievedChunk(
|
||||
chunk_id=uuid.uuid4(),
|
||||
position=0,
|
||||
content=doc.content[:32],
|
||||
score=cosine,
|
||||
document=doc,
|
||||
cosine=cosine,
|
||||
fts_hit=fts_hit,
|
||||
)
|
||||
|
||||
|
||||
def _fake_retriever(chunks: list[RetrievedChunk]) -> Any:
|
||||
def retrieve(_db: Any, _question: str, _vec: list[float]) -> list[RetrievedChunk]:
|
||||
return chunks
|
||||
|
||||
return retrieve
|
||||
|
||||
|
||||
# ---------- fakes: slow SDK stream behind a real LLMClient ----------
|
||||
|
||||
|
||||
def _sse_chunk(text: str) -> SimpleNamespace:
|
||||
"""One fake ChatCompletionChunk (``choices[].delta.content`` shape)."""
|
||||
return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=text))])
|
||||
|
||||
|
||||
class _SlowStream:
|
||||
"""A fake aipi SSE stream (the openai SDK ``AsyncStream`` shape):
|
||||
yields *chunks* with a small sleep between them (so a disconnect can
|
||||
land mid-iteration) and records ``close()`` calls — the SDK stream's
|
||||
deterministic teardown. ``fail_after`` makes ``__anext__`` raise a
|
||||
transport error after that many chunks (the mid-stream
|
||||
``LLMError`` path)."""
|
||||
|
||||
def __init__(
|
||||
self, chunks: list, fail_after: int | None = None, delay: float = 0.005
|
||||
) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self._fail_after = fail_after
|
||||
self._delay = delay
|
||||
self._i = 0
|
||||
self.closed = False
|
||||
|
||||
def __aiter__(self) -> _SlowStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> SimpleNamespace:
|
||||
self._i += 1
|
||||
if self._fail_after is not None and self._i > self._fail_after:
|
||||
raise ConnectionError("simulated mid-stream drop")
|
||||
if self._i > len(self._chunks):
|
||||
raise StopAsyncIteration
|
||||
await asyncio.sleep(self._delay)
|
||||
return self._chunks[self._i - 1]
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self, stream: _SlowStream) -> None:
|
||||
self._stream = stream
|
||||
self.kwargs: dict | None = None
|
||||
|
||||
async def create(self, **kwargs: Any):
|
||||
self.kwargs = kwargs
|
||||
assert kwargs.get("stream") is True
|
||||
return self._stream
|
||||
|
||||
|
||||
def _make_llm(monkeypatch: pytest.MonkeyPatch, stream: _SlowStream) -> LLMClient:
|
||||
"""A real ``LLMClient`` (so the production ``chat_stream`` teardown
|
||||
runs) with the fake SDK stream behind it and a deterministic
|
||||
``embed_one`` (no embeddings HTTP)."""
|
||||
llm = LLMClient(Settings(_env_file=None)) # pyright: ignore[reportCallIssue]
|
||||
llm._client = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue]
|
||||
chat=SimpleNamespace(completions=_FakeCompletions(stream))
|
||||
)
|
||||
|
||||
async def _embed_one(self: Any, _text: str) -> list[float]:
|
||||
return [0.0] * 768
|
||||
|
||||
monkeypatch.setattr(LLMClient, "embed_one", _embed_one)
|
||||
return llm
|
||||
|
||||
|
||||
# ---------- fake DB session (test_chat_gate.py pattern) ----------
|
||||
|
||||
|
||||
class _FakeSteeringResult:
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Records the QueryLog rows it is given; no steering notes, no
|
||||
stored KB overview."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.added: list[Any] = []
|
||||
self.commits = 0
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def scalars(self, _stmt: Any) -> _FakeSteeringResult:
|
||||
return _FakeSteeringResult()
|
||||
|
||||
def get(self, model: Any, pk: Any) -> Any:
|
||||
if model is KbOverview:
|
||||
return KbOverview(id=1, content="")
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch: pytest.MonkeyPatch) -> Iterator[_FakeSession]:
|
||||
"""``POST /api/chat`` with the DB session, retriever settings, and
|
||||
availability faked (the gate tests' wiring)."""
|
||||
monkeypatch.setattr(chat_api, "db_available", lambda: True)
|
||||
session = _FakeSession()
|
||||
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_db, lambda: session)
|
||||
# A stable gate threshold, independent of the production default.
|
||||
monkeypatch.setattr(
|
||||
chat_api,
|
||||
"get_settings",
|
||||
lambda: Settings(_env_file=None, relevance_threshold=0.30), # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
yield session
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _install_llm(monkeypatch: pytest.MonkeyPatch, llm: LLMClient) -> None:
|
||||
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm)
|
||||
|
||||
|
||||
# ---------- the ASGI driver (client disconnect at the ASGI boundary) ----------
|
||||
|
||||
|
||||
def _scope() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"path": "/api/chat",
|
||||
"raw_path": b"/api/chat",
|
||||
"root_path": "",
|
||||
"scheme": "http",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"content-type", b"application/json"),
|
||||
],
|
||||
"client": ("testclient", 50000),
|
||||
"server": ("testserver", 80),
|
||||
"state": {},
|
||||
}
|
||||
|
||||
|
||||
async def _drive(
|
||||
question: str,
|
||||
stop_after: int | None,
|
||||
settle: Callable[[], bool] | None = None,
|
||||
) -> list[bytes]:
|
||||
"""Run one ``POST /api/chat`` through the real ASGI app.
|
||||
|
||||
``stop_after=None`` lets the turn complete; otherwise the client
|
||||
"disconnects" after ``stop_after`` body chunks (SSE frames) have
|
||||
been written — the ``receive`` channel starts returning
|
||||
``http.disconnect`` (the ASGI 2.0 contract; no ``spec_version`` in
|
||||
the scope, so ``StreamingResponse`` runs its task-group
|
||||
listen-for-disconnect path). When *settle* is given, spins the loop
|
||||
until it holds (the PEP 525 asyncgen finalizers run the abandoned
|
||||
generator chain's ``finally`` blocks a few loop turns after their
|
||||
frames are released) or a 5 s timeout runs out. Returns the body
|
||||
chunks written.
|
||||
"""
|
||||
body = json.dumps({"message": question}).encode()
|
||||
chunks: list[bytes] = []
|
||||
disconnect = asyncio.Event()
|
||||
request_done = False
|
||||
|
||||
async def receive() -> dict[str, Any]:
|
||||
nonlocal request_done
|
||||
if not request_done:
|
||||
request_done = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
await disconnect.wait()
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def send(message: MutableMapping[str, Any]) -> None:
|
||||
if message["type"] != "http.response.body":
|
||||
return
|
||||
chunk = message.get("body", b"")
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
if stop_after is not None and len(chunks) >= stop_after:
|
||||
disconnect.set() # the client goes away
|
||||
|
||||
await fastapi_app(_scope(), receive, send)
|
||||
if settle is not None:
|
||||
gc.collect() # release any cycle-held frames up front
|
||||
deadline = time.monotonic() + 5.0
|
||||
while not settle():
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
return chunks
|
||||
|
||||
|
||||
def _frames(chunks: list[bytes]) -> list[dict[str, Any]]:
|
||||
"""Parse the SSE frames out of the written body chunks."""
|
||||
frames: list[dict[str, Any]] = []
|
||||
for raw in chunks:
|
||||
for frame in raw.decode("utf-8").split("\n\n"):
|
||||
frame = frame.strip()
|
||||
if frame.startswith("data:"):
|
||||
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||||
return frames
|
||||
|
||||
|
||||
# ---------- cancelled turns (the phase-48 contract) ----------
|
||||
|
||||
|
||||
def test_cancelled_grounded_turn_closes_stream_logs_cancel_and_skips_query_log(
|
||||
env: _FakeSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Grounded turn (the agent loop): a mid-stream disconnect closes
|
||||
the model's stream, logs one cancel line, writes no query_log row,
|
||||
and emits no done/error frame after the disconnect."""
|
||||
stream = _SlowStream([_sse_chunk(f"word{i} ") for i in range(60)])
|
||||
llm = _make_llm(monkeypatch, stream)
|
||||
_install_llm(monkeypatch, llm)
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="app.chat"):
|
||||
chunks = asyncio.run(
|
||||
_drive(
|
||||
"How is my Kubernetes cluster set up?",
|
||||
stop_after=2,
|
||||
settle=lambda: stream.closed,
|
||||
)
|
||||
)
|
||||
|
||||
# The model's stream was closed promptly on abandon.
|
||||
assert stream.closed
|
||||
# The cancel log line — exactly once, with the question.
|
||||
cancel_lines = [
|
||||
r.getMessage() for r in caplog.records if "turn cancelled" in r.getMessage()
|
||||
]
|
||||
assert len(cancel_lines) == 1
|
||||
assert "How is my Kubernetes cluster set up?" in cancel_lines[0]
|
||||
assert "total_ms=" in cancel_lines[0]
|
||||
# No durable record for a cancelled turn.
|
||||
assert env.added == []
|
||||
# Frames: the streamed deltas only — no done, no error, after the
|
||||
# disconnect (a third delta may race the teardown; all deltas).
|
||||
frames = _frames(chunks)
|
||||
assert len(frames) >= 2
|
||||
assert all(f["type"] == "delta" for f in frames)
|
||||
|
||||
|
||||
def test_cancelled_deflected_turn_closes_stream(
|
||||
env: _FakeSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Deflected turn (the direct ``chat_stream`` path — A8): the same
|
||||
teardown contract holds without the agent loop."""
|
||||
stream = _SlowStream([_sse_chunk(f"word{i} ") for i in range(60)])
|
||||
llm = _make_llm(monkeypatch, stream)
|
||||
_install_llm(monkeypatch, llm)
|
||||
doc = _doc("Deploying a New Service", "DOC_CONTENT_NEVER_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.10)]))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.chat"):
|
||||
chunks = asyncio.run(
|
||||
_drive(
|
||||
"How do I bake sourdough bread?",
|
||||
stop_after=2,
|
||||
settle=lambda: stream.closed,
|
||||
)
|
||||
)
|
||||
|
||||
assert stream.closed
|
||||
cancel_lines = [
|
||||
r.getMessage() for r in caplog.records if "turn cancelled" in r.getMessage()
|
||||
]
|
||||
assert len(cancel_lines) == 1
|
||||
assert env.added == []
|
||||
frames = _frames(chunks)
|
||||
assert len(frames) >= 2
|
||||
assert all(f["type"] == "delta" for f in frames)
|
||||
|
||||
|
||||
# ---------- regressions: settled turns behave exactly as before ----------
|
||||
|
||||
|
||||
def test_completed_turn_still_emits_done_and_writes_query_log(
|
||||
env: _FakeSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A completed turn: the ``done`` frame, the query_log row, and the
|
||||
per-turn log line — and NO cancel line (it settled)."""
|
||||
stream = _SlowStream([_sse_chunk(f"word{i} ") for i in range(3)], delay=0.001)
|
||||
llm = _make_llm(monkeypatch, stream)
|
||||
_install_llm(monkeypatch, llm)
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="app.chat"):
|
||||
chunks = asyncio.run(_drive("How is my Kubernetes cluster set up?", None))
|
||||
|
||||
frames = _frames(chunks)
|
||||
assert [f["type"] for f in frames] == ["delta", "delta", "delta", "done"]
|
||||
assert frames[-1]["deflected"] is False
|
||||
assert frames[-1]["sources"][0]["title"] == "Kubernetes Homelab Cluster"
|
||||
(row,) = env.added
|
||||
assert isinstance(row, QueryLog)
|
||||
assert row.question == "How is my Kubernetes cluster set up?"
|
||||
assert env.commits == 1
|
||||
# The per-turn line still goes out; no cancel line for a settled turn.
|
||||
assert any("question=" in r.getMessage() for r in caplog.records)
|
||||
assert not any("turn cancelled" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_mid_stream_llm_error_settles_not_cancelled(
|
||||
env: _FakeSession,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The mid-stream ``LLMError`` path (the fake stream drops after two
|
||||
pieces): the structured ``error`` frame is emitted, the turn is NOT
|
||||
logged as cancelled (it settled), the stream is closed on the
|
||||
exception path, and — as before — no query_log row is written."""
|
||||
stream = _SlowStream([_sse_chunk(f"word{i} ") for i in range(60)], fail_after=2)
|
||||
llm = _make_llm(monkeypatch, stream)
|
||||
_install_llm(monkeypatch, llm)
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="app.chat"):
|
||||
chunks = asyncio.run(_drive("How is my Kubernetes cluster set up?", None))
|
||||
|
||||
frames = _frames(chunks)
|
||||
assert [f["type"] for f in frames] == ["delta", "delta", "error"]
|
||||
assert frames[-1]["detail"] == "The chat model dropped the connection — try again?"
|
||||
# The exception path closes the model's stream too (synchronously —
|
||||
# no settle needed).
|
||||
assert stream.closed
|
||||
assert env.added == []
|
||||
# Settled: no cancel line (the LLM stream failure IS logged, though).
|
||||
assert not any("turn cancelled" in r.getMessage() for r in caplog.records)
|
||||
assert any(
|
||||
"LLM stream failed" in r.getMessage() for r in caplog.records
|
||||
)
|
||||
@@ -23,6 +23,10 @@ def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_turn_timeout_constant_exported_at_120s() -> None:
|
||||
"""The 120s client-side guard (PLAN §7.4) must be an *exported*
|
||||
constant — testable, and the single value the E2E timeout story keys
|
||||
@@ -91,15 +95,22 @@ def test_reduced_motion_calm_not_removed() -> None:
|
||||
|
||||
|
||||
def test_busy_button_style_tokens() -> None:
|
||||
"""Story spec: busy send button is #a5b4fc with the 16px dark-arc
|
||||
spinner (--bg on #a5b4fc = 9.7:1, phase 08); label swaps Send ↔ Thinking…."""
|
||||
"""Phase 48 (revised contract, owner-locked 2026-08-29): in flight
|
||||
the button is the enabled Stop control — "Stop" label, .is-stop
|
||||
class (rose treatment, 6.3:1 with the #fff label), spinner hidden;
|
||||
idle/error keep the brand Send button (dark ink on brand 5.2:1).
|
||||
The spinner element stays in the markup + CSS (16px dark arc — the
|
||||
reduced-motion pin below) but the state machine never shows it: the
|
||||
Stop label + treatment carry the in-flight state."""
|
||||
css = _css()
|
||||
js = _js()
|
||||
assert ".send-btn:disabled" in css
|
||||
assert "#a5b4fc" in css
|
||||
assert ".send-btn.is-stop" in css
|
||||
assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)"
|
||||
assert ".send-btn.is-stop:hover" in css, "the darker hover step"
|
||||
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
|
||||
assert "Thinking…" in js
|
||||
assert 'sendLabel.textContent' in js
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
|
||||
assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js
|
||||
assert "sendBtn.disabled = false" in js, "the button is a control, never disabled"
|
||||
|
||||
|
||||
# ---------- thinking display (phase 17) ----------
|
||||
@@ -198,3 +209,160 @@ def test_thinking_chevron_stills_under_reduced_motion() -> None:
|
||||
"details.thinking summary::before" in b and "transition: none" in b
|
||||
for b in blocks
|
||||
), "chevron transition must still under reduced motion"
|
||||
|
||||
|
||||
# ---------- stop generation (phase 48, task 02) ----------
|
||||
|
||||
|
||||
def test_in_flight_button_is_the_stop_control() -> None:
|
||||
"""Phase 48 (owner-locked 2026-08-29): in flight the button is the
|
||||
enabled Stop control — "Stop" label, .is-stop class, spinner hidden
|
||||
(the label + the rose treatment carry the state); idle/error keep
|
||||
the Send label with the class removed. The state machine otherwise
|
||||
stays unchanged (same four states, same single entry point)."""
|
||||
js = _js()
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
|
||||
assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js
|
||||
assert 'sendBtn.querySelector(".spinner").hidden = true' in js, (
|
||||
"the spinner never shows — the Stop label carries the state"
|
||||
)
|
||||
assert "sendBtn.disabled = false" in js, "enabled in every state"
|
||||
|
||||
|
||||
def test_abort_plumbing_owns_the_fetch() -> None:
|
||||
"""The in-flight fetch is owned by an AbortController created at
|
||||
turn start (module scope, cleared in the finally), passed to the
|
||||
fetch as its signal; the 120s guard aborts the same controller as
|
||||
its backstop — with `aborted = true` FIRST, so the catch never reads
|
||||
the guard's abort as a user stop (one owner, same outcome)."""
|
||||
js = _js()
|
||||
assert "let turnAbort = null" in js, "module-scope abort owner"
|
||||
assert "turnAbort = new AbortController()" in js, "fresh controller per turn"
|
||||
assert "signal: turnAbort.signal" in js, "the fetch carries the signal"
|
||||
guard_start = js.find("armTurnTimeout(() => {")
|
||||
guard = js[guard_start : js.find("});", guard_start)]
|
||||
assert "aborted = true" in guard and "turnAbort?.abort()" in guard, (
|
||||
"the guard keeps cancelStream + the abort as backstops"
|
||||
)
|
||||
assert guard.index("aborted = true") < guard.index("turnAbort?.abort()"), (
|
||||
"aborted must be set before the guard's abort"
|
||||
)
|
||||
handle = js.find("async function handleSend")
|
||||
finally_idx = js.find("} finally {", handle)
|
||||
finally_block = js[finally_idx : finally_idx + 700]
|
||||
assert "turnAbort = null" in finally_block, "the abort owner is spent after the turn"
|
||||
|
||||
|
||||
def test_stop_turn_is_the_user_abort() -> None:
|
||||
"""stopTurn: a no-op unless a turn is in flight (thinking/streaming);
|
||||
it marks the turn as user-stopped and aborts. The in-flight guard at
|
||||
the top of handleSend routes a click / Enter-to-submit to it BEFORE
|
||||
the !text guard — the enabled in-flight button can never start a
|
||||
second turn."""
|
||||
js = _js()
|
||||
fn = js.find("function stopTurn")
|
||||
assert fn != -1, "stopTurn must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "uiState !== UI_STATE.thinking" in body
|
||||
assert "uiState !== UI_STATE.streaming" in body
|
||||
assert "stoppedByUser = true" in body
|
||||
assert "turnAbort?.abort()" in body
|
||||
handle = js.find("async function handleSend")
|
||||
guard_idx = js.find("stopTurn();", handle)
|
||||
text_idx = js.find("const text = input.value.trim()", handle)
|
||||
assert handle < guard_idx < text_idx, (
|
||||
"the in-flight guard (→ stopTurn) must precede the !text guard"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
"""The stop path in handleSend's catch: no error state, no error
|
||||
banner; when answer text streamed the partial is kept on screen
|
||||
(thinking block closed, Tune + Stopped note appended — admin parity
|
||||
with the restore path) and persisted with the owner-locked optional
|
||||
`stopped: true` marker (+ optional thinking/tools); a pre-token stop
|
||||
persists nothing brain-side (phase-20 convention). The "Answer
|
||||
stopped." live-region confirmation is set in the finally, AFTER the
|
||||
single settle, so setUiState(idle) can't overwrite it."""
|
||||
js = _js()
|
||||
handle = js.find("async function handleSend")
|
||||
catch_idx = js.find("} catch (err) {", handle)
|
||||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch_idx)
|
||||
finally_idx = js.find("} finally {", catch_idx)
|
||||
assert catch_idx < stop_idx < finally_idx, "the stop branch must live in the catch"
|
||||
# The stop branch only (the error `else` follows it and is not pinned here).
|
||||
branch = js[stop_idx : js.find("} else {", stop_idx)]
|
||||
assert "setUiState(UI_STATE.error" not in branch, "no error state on the stop path"
|
||||
assert "showErrorBanner" not in branch, "no error banner on the stop path"
|
||||
assert "if (wrap && acc && !persistedOnLeave)" in branch, (
|
||||
"only a partial WITH answer text is persisted (phase-20 dedupe)"
|
||||
)
|
||||
assert "closeThinkingBlock(wrap)" in branch
|
||||
assert "appendTuneButton(wrap)" in branch, "admin parity with the restore path"
|
||||
assert "appendStoppedNote(wrap)" in branch
|
||||
assert "stopped: true" in branch, "the owner-locked optional marker"
|
||||
assert "thinking: thinkingAcc || undefined" in branch
|
||||
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
|
||||
# The confirmation rides the single settle in the finally.
|
||||
finally_block = js[finally_idx : finally_idx + 900]
|
||||
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
|
||||
|
||||
|
||||
def test_stopped_note_helper_and_restore_path() -> None:
|
||||
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like
|
||||
appendTuneButton (role=list → the span joins as a listitem), one
|
||||
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the
|
||||
"Stopped" text (the accessible meaning). The restore path renders it
|
||||
for records with `m.stopped` (phase-14 optional-field convention —
|
||||
no version bump)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendStoppedNote")
|
||||
assert fn != -1, "appendStoppedNote must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
|
||||
assert 'className = "msg-meta"' in body, "creates it otherwise"
|
||||
assert 'className = "stopped-note"' in body
|
||||
assert 'querySelector(".stopped-note")' in body, "one note per bubble"
|
||||
assert 'note.setAttribute("role", "listitem")' in body
|
||||
assert 'aria-hidden="true"' in body, "the glyph is decoration"
|
||||
assert '"Stopped"' in body, "the text carries the accessible meaning"
|
||||
# Restore path: the same helper, gated on the stored marker.
|
||||
rfn = js.find("function renderStoredMessage")
|
||||
rbody = js[rfn : js.find("\n}\n", rfn)]
|
||||
assert "if (m.stopped) appendStoppedNote(wrap)" in rbody
|
||||
|
||||
|
||||
def test_tool_branch_no_longer_writes_the_button_label() -> None:
|
||||
"""Phase 48 (owner-locked): the `tool` frame no longer relabels the
|
||||
button — it stays "Stop" for the whole in-flight turn; the
|
||||
calling-tool status lives in #send-status + the typing indicator's
|
||||
aria-label only (exactly where the phase-37 state used to write)."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert "sendLabel" not in branch, "the button keeps its Stop label"
|
||||
assert "sendStatus.textContent = toolStatus" in branch
|
||||
assert 'setAttribute("aria-label", toolStatus)' in branch
|
||||
|
||||
|
||||
def test_composer_form_is_novalidate() -> None:
|
||||
"""Phase 48 (latent-defect fix, 2026-08-29): the composer form must
|
||||
skip browser constraint validation. The input is cleared after every
|
||||
send, so a `required` textarea would fail validation on the Stop
|
||||
click/Enter — the `submit` event never fires and handleSend's
|
||||
in-flight guard never runs, so the Stop control is dead. The `!text`
|
||||
guard in app.js is the real empty-input check (same precedent as the
|
||||
tuning form's noValidate)."""
|
||||
html = _html()
|
||||
composer = html.find('id="composer"')
|
||||
assert composer != -1, "index.html must contain #composer"
|
||||
form_tag = html[html.rfind("<form", 0, composer) : html.find(">", composer) + 1]
|
||||
assert "novalidate" in form_tag.lower(), (
|
||||
"the composer form must carry novalidate — a `required` input that "
|
||||
"is empty in flight would silently block the Stop submit"
|
||||
)
|
||||
textarea = html[composer: html.find("</textarea>", composer)]
|
||||
assert not re.search(r"\brequired\b", textarea), (
|
||||
"the composer textarea must not carry `required` (see novalidate)"
|
||||
)
|
||||
|
||||
@@ -193,7 +193,9 @@ def test_turn_end_focus_does_not_scroll() -> None:
|
||||
up would yank them to the bottom at the moment the turn ends.
|
||||
preventScroll keeps the keyboard flow without the scroll."""
|
||||
js = _js()
|
||||
finally_idx = js.find("// done | error → idle: always settle, always focus back")
|
||||
# Phase 48: the settle line gained the user-stop terminal (stop →
|
||||
# idle, no banner) — the focus-back contract is unchanged.
|
||||
finally_idx = js.find("// done | error | stop → idle: always settle, always focus back")
|
||||
assert finally_idx != -1, "the turn's finally block must exist"
|
||||
block = js[finally_idx : js.find("\n}", finally_idx)]
|
||||
assert 'input.focus({ preventScroll: true })' in block
|
||||
|
||||
@@ -53,15 +53,20 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
|
||||
|
||||
|
||||
def test_calling_tool_label_strings() -> None:
|
||||
"""The 'calling tool' label strings the story keys off: the button
|
||||
text and the status/typing-indicator labels. Phase 39 centralizes
|
||||
the brand prefix: the name resolves from window.BOR_BRAND at call
|
||||
time via brand() (the default name renders the same bytes)."""
|
||||
"""The 'calling tool' label strings the story keys off: the
|
||||
status/typing-indicator labels. Phase 48 (owner-locked 2026-08-29)
|
||||
revised the phase-37 contract: the button no longer relabels to
|
||||
"Calling tool…" — it stays the enabled "Stop" control for the whole
|
||||
in-flight turn (no sendLabel write in the branch); the calling-tool
|
||||
status lives in #send-status + the typing-indicator aria-label only.
|
||||
Phase 39 centralizes the brand prefix: the name resolves from
|
||||
window.BOR_BRAND at call time via brand() (the default name renders
|
||||
the same bytes)."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert '"Calling tool…"' in branch, "the button carries the calling-tool text"
|
||||
assert "sendLabel" not in branch, "phase 48: the button keeps its Stop label"
|
||||
assert "`${brand()} is listing documents`" in branch
|
||||
assert "`${brand()} is reading ${argument}`" in branch
|
||||
assert "sendStatus.textContent = toolStatus" in branch, (
|
||||
|
||||
@@ -289,6 +289,7 @@ def _chunk(
|
||||
class _FakeChatStream:
|
||||
def __init__(self, chunks: list) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self.close_calls = 0
|
||||
|
||||
def __aiter__(self):
|
||||
self._i = 0
|
||||
@@ -301,6 +302,11 @@ class _FakeChatStream:
|
||||
self._i += 1
|
||||
return chunk
|
||||
|
||||
async def close(self) -> None:
|
||||
"""The openai SDK ``AsyncStream.close()`` (phase 48): ``chat_stream``
|
||||
awaits it on every exit after a successful ``create()``."""
|
||||
self.close_calls += 1
|
||||
|
||||
|
||||
class _FakeCompletion:
|
||||
"""One fake non-streaming ChatCompletion (``choices[].message`` shape).
|
||||
|
||||
@@ -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