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:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
+419
View File
@@ -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
)
+174 -6
View File
@@ -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)"
)
+3 -1
View File
@@ -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
+10 -5
View File
@@ -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, (
+6
View File
@@ -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).
+203
View File
@@ -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