feat(chat): stream model thinking over SSE and show it in a collapsible block

This commit is contained in:
2026-08-24 09:52:27 -04:00
parent cbc263a4b2
commit b16deb2b1d
18 changed files with 1045 additions and 63 deletions
+2 -1
View File
@@ -20,6 +20,7 @@ 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, QueryLog
from app.rag.llm import StreamPiece
from app.rag.retriever import RetrievedChunk, weak_hit_titles
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
@@ -276,7 +277,7 @@ class _CannedLLM:
async def chat_stream(self, messages: list[dict[str, str]]):
self.seen.append(messages)
for i in range(0, len(self.answer), 12):
yield self.answer[i : i + 12]
yield StreamPiece("content", self.answer[i : i + 12])
class _FakeSteeringResult:
+70 -3
View File
@@ -105,11 +105,15 @@ def test_save_points_user_on_send_and_brain_on_done() -> None:
assert user_push < js.find('fetch("/api/chat"'), (
"the user message must be saved before the turn starts"
)
# Brain save point is wired into the done handler with full metadata.
# Brain save point is wired into the done handler with full metadata
# (phase 17: the persisted text is finalText — the empty-answer
# fallback substitution — and the optional thinking field rides along
# in the same meta object).
done_idx = js.find('ev.type === "done"')
assert done_idx != -1
done_block = js[done_idx : done_idx + 900]
assert "rememberBrainTurn(acc" in done_block
done_block = js[done_idx : done_idx + 1300]
assert "rememberBrainTurn(finalText || acc" in done_block
assert "thinking: thinkingAcc || undefined" in done_block
assert "deflected: !!ev.deflected" in done_block
assert "sources: ev.sources" in done_block
assert "suggestions: ev.suggestions" in done_block
@@ -181,3 +185,66 @@ def test_new_chat_button_style_contract() -> None:
assert mobile, "mobile media query missing"
assert ".new-chat-label { display: none; }" in mobile.group(1)
assert ".new-chat-btn svg { display: block; }" in mobile.group(1)
def test_brain_turn_persists_optional_thinking_field() -> None:
"""Phase 17: the done save point carries `thinking: thinkingAcc ||
undefined` — `undefined` drops the key from the JSON, so turns without
thinking persist byte-identical to before (no version bump). A
thinking-without-answer turn (reasoning exhausts max_tokens) renders
+ persists the shared empty-answer fallback: what the user saw is what
is stored."""
js = _js()
done_idx = js.find('ev.type === "done"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < error_idx, "done branch missing from the turn handler"
branch = js[done_idx:error_idx]
assert "thinking: thinkingAcc || undefined" in branch
assert (
'const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "")'
in branch
)
assert "renderMarkdown(finalText)" in branch, (
"the substituted fallback must render into the bubble"
)
def test_restore_renders_collapsed_thinking_block() -> None:
"""Phase 17: a stored brain message carrying `thinking` re-renders the
block COLLAPSED above its bubble (escape-first markdown, as everywhere
else in the persistence contract); messages without the field render
exactly as before — no block."""
js = _js()
fn_start = js.find("function renderStoredMessage")
assert fn_start != -1
body = js[fn_start : js.find("\n}\n", fn_start)]
assert "if (m.thinking)" in body
assert "ensureThinkingBlock(wrap)" in body
assert "block.open = false" in body, "restored blocks must be collapsed"
assert "renderMarkdown(m.thinking)" in body
def test_thinking_block_css_uses_phase08_tokens() -> None:
"""Phase 17 styling (Phase-08 tokens, WCAG AA): the block frame, the
≥44px summary control (brand-ink ≈8.7:1 on surface) and the scrollable
scratchpad (ink-soft ≈6.9:1 on surface, 320px cap)."""
css = _css()
block = re.search(r"details\.thinking \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style details.thinking"
body = block.group(1)
assert "var(--surface)" in body
assert "var(--line)" in body
assert "var(--brand-soft)" in body
assert "var(--radius-sm)" in body
summary = re.search(r"details\.thinking summary \{([\s\S]*?)\n\}", css)
assert summary, "the summary must be a styled focusable control"
sbody = summary.group(1)
assert "min-height: 44px" in sbody
assert "var(--brand-ink)" in sbody
assert "cursor: pointer" in sbody
text = re.search(r"details\.thinking \.thinking-text \{([\s\S]*?)\n\}", css)
assert text, "the .thinking-text scroll area must be styled"
tbody = text.group(1)
assert "var(--ink-soft)" in tbody
assert "max-height: 320px" in tbody
assert "overflow-y: auto" in tbody
+15
View File
@@ -36,6 +36,8 @@ def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> Non
assert s.top_n_docs >= 1
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
assert s.max_output_tokens == 32_768
# Phase 17: the model's thinking streams by default (kill-switch off).
assert s.stream_thinking is True
assert len(s.suggestions) >= 3
# A9 (revised): the import scope covers the seven A9 formats.
assert s.import_extension_set == {
@@ -57,6 +59,19 @@ def test_max_output_tokens_env_override(monkeypatch) -> None:
assert s.max_output_tokens == 1234
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
``0``/``false`` turn the ``thinking`` SSE frames off."""
assert _settings().stream_thinking is True
assert _settings(stream_thinking=False).stream_thinking is False
monkeypatch.setenv("BOR_STREAM_THINKING", "0")
assert _settings().stream_thinking is False
monkeypatch.setenv("BOR_STREAM_THINKING", "false")
assert _settings().stream_thinking is False
monkeypatch.setenv("BOR_STREAM_THINKING", "1")
assert _settings().stream_thinking is True
def test_import_extensions_env_override_is_a_csv_list(monkeypatch) -> None:
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,yml")
s = _settings()
+98
View File
@@ -91,3 +91,101 @@ def test_busy_button_style_tokens() -> None:
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
assert "Thinking…" in js
assert 'sendLabel.textContent' in js
# ---------- thinking display (phase 17) ----------
def test_thinking_event_is_a_first_class_turn_branch() -> None:
"""Phase 17: `thinking` SSE frames stream live into the collapsible
Thinking block — the typing dots make way, the 120s pre-token guard
clears (the stream is alive), and the text renders through the
escape-first markdown renderer (XSS-safe). While open, the stream is
pinned to the bottom of the block."""
js = _js()
thinking_idx = js.find('ev.type === "thinking"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < thinking_idx < delta_idx, "the turn handler must branch on thinking frames"
branch = js[thinking_idx:delta_idx]
assert "thinkingAcc += ev.text" in branch
assert "sawThinking = true" in branch
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 120s guard"
assert "removeTyping()" in branch, "the live block replaces the typing dots"
assert "ensureThinkingBlock(wrap)" in branch
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
assert "textEl.scrollTop = textEl.scrollHeight" in branch, "bottom-pinned while open"
def test_thinking_block_helpers_are_idempotent() -> None:
"""ensureThinkingBlock returns the existing `.thinking` details or
creates it OPEN above the .bubble; closeThinkingBlock is a no-op
without a block and never reopens one once the answer started."""
js = _js()
fn = js.find("function ensureThinkingBlock")
assert fn != -1, "ensureThinkingBlock must exist (near addTyping/removeTyping)"
body = js[fn : js.find("\n}\n", fn)]
assert "block.open = true" in body, "created open — the stream is the show"
assert "insertBefore" in body
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
fn2 = js.find("function closeThinkingBlock")
assert fn2 != -1, "closeThinkingBlock must exist"
body2 = js[fn2 : js.find("\n}\n", fn2)]
assert "block.open = false" in body2
def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
"""The first answer delta transitions thinking → streaming (even when
thinking created the wrap first) and auto-collapses the block —
idempotent, and it never reopens once the answer started."""
js = _js()
delta_idx = js.find('ev.type === "delta"')
done_idx = js.find('ev.type === "done"')
assert -1 < delta_idx < done_idx
branch = js[delta_idx:done_idx]
assert "uiState === UI_STATE.thinking" in branch
assert "setUiState(UI_STATE.streaming)" in branch
assert "closeThinkingBlock(wrap)" in branch
def test_done_branch_sets_sawdone_and_closes_block() -> None:
"""On `done` the turn marks itself complete (sawDone — the stream-drop
guard keys off it) and settles the thinking block closed."""
js = _js()
done_idx = js.find('ev.type === "done"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < error_idx
branch = js[done_idx:error_idx]
assert "sawDone = true" in branch
assert "closeThinkingBlock(wrap)" in branch
def test_stream_drop_guard_reports_severed_stream() -> None:
"""A stream that delivered frames but no `done` event ends in the error
state (never a silent idle with a half bubble); the zero-frame case
falls through to the existing empty-answer fallback. The guard runs
after readSSE, before that fallback."""
js = _js()
assert "let sawDone = false" in js
assert re.search(r"if \(!sawDone && !aborted && \(acc \|\| thinkingAcc\)\)", js), (
"sawDone stream-drop guard missing after readSSE"
)
assert "The stream ended before my answer finished" in js
sse_idx = js.find("await readSSE(res,")
guard_idx = js.find("!sawDone && !aborted")
fallback_idx = js.find("!aborted && !wrap")
assert -1 < sse_idx < guard_idx < fallback_idx, (
"guard must sit between readSSE and the zero-frame fallback"
)
def test_thinking_chevron_stills_under_reduced_motion() -> None:
"""Phase 17: the only motion in the thinking block (the summary
chevron rotation) is disabled under prefers-reduced-motion."""
css = _css()
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert any(
"details.thinking summary::before" in b and "transition: none" in b
for b in blocks
), "chevron transition must still under reduced motion"
+95 -7
View File
@@ -16,7 +16,13 @@ from typing import Any
import pytest
from app.config import Settings
from app.rag.llm import EmbeddingDimensionError, EmbeddingError, LLMClient, LLMError
from app.rag.llm import (
EmbeddingDimensionError,
EmbeddingError,
LLMClient,
LLMError,
StreamPiece,
)
def _settings(**kwargs: Any) -> Settings:
@@ -236,11 +242,21 @@ def test_single_oversized_text_fails_actionably() -> None:
# ---------- chat streaming (phase 03) ----------
def _chunk(content: str | None = "text", empty: bool = False):
"""One fake ChatCompletionChunk (``choices[].delta.content`` shape)."""
def _chunk(
content: str | None = "text", empty: bool = False, reasoning: str | None = None
):
"""One fake ChatCompletionChunk (``choices[].delta`` shape).
``reasoning_content`` is present on the delta only when *reasoning*
is not None — mirroring the real wire, where the field exists only
when the model sends it.
"""
if empty:
return SimpleNamespace(choices=[])
return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=content))])
delta: SimpleNamespace = SimpleNamespace(content=content)
if reasoning is not None:
delta.reasoning_content = reasoning
return SimpleNamespace(choices=[SimpleNamespace(delta=delta)])
class _FakeChatStream:
@@ -284,7 +300,7 @@ def _make_stream_client(
return llm, completions
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[str]:
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[StreamPiece]:
return [p async for p in llm.chat_stream(messages)]
@@ -293,7 +309,13 @@ def test_chat_stream_yields_deltas_in_order() -> None:
[_chunk("Hey "), _chunk("you've "), _chunk("got this! 🧠")]
)
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert pieces == ["Hey ", "you've ", "got this! 🧠"]
# Content-only chunks yield content pieces in wire order.
assert [(p.kind, p.text) for p in pieces] == [
("content", "Hey "),
("content", "you've "),
("content", "got this! 🧠"),
]
assert all(isinstance(p, StreamPiece) for p in pieces)
def test_chat_stream_uses_locked_generation_params() -> None:
@@ -322,7 +344,73 @@ def test_chat_stream_max_tokens_comes_from_settings() -> None:
def test_chat_stream_skips_empty_deltas_and_choiceless_chunks() -> None:
llm, _ = _make_stream_client([_chunk("a"), _chunk(empty=True), _chunk(None), _chunk("b")])
assert asyncio.run(_collect(llm, [{"role": "user", "content": "q"}])) == ["a", "b"]
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert [(p.kind, p.text) for p in pieces] == [("content", "a"), ("content", "b")]
def test_chat_stream_maps_reasoning_content_to_thinking_pieces() -> None:
"""The verified aipi wire field (``delta.reasoning_content``) maps to
``thinking`` pieces; content chunks are untouched by the presence of
reasoning elsewhere in the stream."""
llm, _ = _make_stream_client(
[
_chunk("", reasoning="Step 1: parse the question."),
_chunk("", reasoning="Step 2: cite the doc."),
_chunk("Talos."),
]
)
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert [(p.kind, p.text) for p in pieces] == [
("thinking", "Step 1: parse the question."),
("thinking", "Step 2: cite the doc."),
("content", "Talos."),
]
def test_chat_stream_falls_back_to_reasoning_field() -> None:
"""Future-proofing: a bare ``delta.reasoning`` field (no
``reasoning_content``) is picked up by the fallback getattr."""
chunk = SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(content="ans", reasoning="why not"))
]
)
llm, _ = _make_stream_client([chunk])
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert [(p.kind, p.text) for p in pieces] == [
("thinking", "why not"),
("content", "ans"),
]
def test_chat_stream_thinking_yields_before_content_in_chunk() -> None:
"""One chunk carrying both fields yields the thinking piece first."""
llm, _ = _make_stream_client([_chunk("answer", reasoning="hmm")])
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert [(p.kind, p.text) for p in pieces] == [
("thinking", "hmm"),
("content", "answer"),
]
def test_chat_stream_interleaved_thinking_and_content_order_preserved() -> None:
"""The piece sequence must match the chunk sequence exactly — a late
or interleaved thinking chunk is emitted at its wire position."""
llm, _ = _make_stream_client(
[
_chunk("", reasoning="t1"),
_chunk("c1"),
_chunk("", reasoning="t2"),
_chunk("c2"),
]
)
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
assert [(p.kind, p.text) for p in pieces] == [
("thinking", "t1"),
("content", "c1"),
("thinking", "t2"),
("content", "c2"),
]
def test_chat_stream_wraps_failures_as_llm_error() -> None:
+16 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
from app.api.chat import sse_event
from app.schemas import ChatErrorEvent
from app.schemas import ChatErrorEvent, ChatThinkingEvent
def _payload(frame: str) -> dict:
@@ -61,3 +61,18 @@ def test_error_event_shape_is_type_and_detail_only() -> None:
dumped = ChatErrorEvent(detail="The chat model dropped the connection").model_dump()
assert set(dumped.keys()) == {"type", "detail"}
assert dumped["type"] == "error" # default — call sites never spell it out
def test_thinking_frame_serializes_exactly() -> None:
"""Phase 17 (PLAN §4 extension): the ``thinking`` frame is exactly
``{type: "thinking", text: str}`` — the sibling shape of ``delta``
the client's readSSE handler will branch on."""
frame = sse_event(ChatThinkingEvent(text="Step 1: check the docs…").model_dump())
assert frame == 'data: {"type": "thinking", "text": "Step 1: check the docs…"}\n\n'
assert _payload(frame) == {"type": "thinking", "text": "Step 1: check the docs…"}
def test_thinking_event_shape_is_type_and_text_only() -> None:
dumped = ChatThinkingEvent(text="hmm").model_dump()
assert set(dumped.keys()) == {"type", "text"}
assert dumped["type"] == "thinking" # default — call sites never spell it out