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
+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