feat(chat): stream model thinking over SSE and show it in a collapsible block
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user