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