Remove the blanket .agent/ gitignore so the phase roadmap, user stories, reports, and PLAN.md are versioned with the code. Only runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log) remain ignored. Update AGENTS.md git protocol rule to match.
13 KiB
Phase 17 — Model "Thinking" in the Chat UI
Story: .agent/user_stories/thinking-display.md (created by task 04)
Context: PLAN §3/§4 (SSE chat transport, A15), §6 (locked prompt),
§7.4/§7.5 (feedback contract + component inventory), §9 (per-turn log line);
app/rag/llm.py (streaming client), app/api/chat.py (SSE mapping),
frontend/assets/app.js (turn handler + phase-14 persistence),
tests/e2e/mock_llm.py (deterministic mock).
Objective
The web interface supports and shows the model's "thinking": the aipi
turbo model streams its reasoning in delta.reasoning_content chunks
before the answer (verified live 2026-08-23 — the app currently discards
those tokens, so the user stares at "Thinking…" with no visibility). This
phase plumbs reasoning through the SSE contract as a new thinking event
type and renders it in a collapsible "Thinking" block above the answer
bubble — streaming open, auto-collapsing when the answer starts,
user-toggleable afterwards, and persisted with the message (phase 14).
Models/turns that emit no reasoning render exactly as before.
Dependencies
- All of
01–16(complete). Specifically:03_story_chat_rag(the SSE turn pipeline this extends),06_story_loading_feedback(theidle → thinking → streaming → done | error → idlestate machine + 120s guard the thinking display plugs into),14_chat_persistence(localStorage record shape gains an optionalthinkingfield),08_story_dark_tech_theme(Phase-08 design tokens for the block),16_admin_auth(chat stays public — no gating change).
Verified facts (probe, 2026-08-23, live aipi endpoint)
POST /v1/chat/completionswithstream=Trueagainstturboemitschoices[0].delta.reasoning_contentchunks before the firstdelta.contentchunk (deepseek/litellm wire convention). No request-side flag is needed — the model thinks on its own; how much it thinks is the model's call.- Reasoning counts against
max_tokens: atmax_tokens=300a probe produced 300 reasoning tokens and no answer content. With the locked defaultBOR_MAX_OUTPUT_TOKENS=32768there is ample headroom, but an answer can in principle be empty — the UI must handle thinking-without-answer gracefully (existing empty-answer fallback). openaiSDK 2.54 (this repo's version) preserves unknown delta fields:ChatCompletionChunk.model_validate(...)keepsreasoning_contentinmodel_extra, reachable viagetattr(delta, "reasoning_content", None). The design therefore needs no raw-HTTP parsing.
Design
- SSE contract (PLAN §4 extension, owner permission 2026-08-23 = this
request): new event type
{"type":"thinking","text":"…"}. Frames arrive beforedeltaframes in practice (the model reasons first); the client must tolerate a late/interleavedthinkingevent defensively (append to the block, never reopen it once the answer started). Thedoneevent shape is unchanged (deflected,sources,suggestions) — thinking text never needs to travel again ondone. - Backend:
app/rag/llm.py— newStreamPiece(frozen dataclass:kind: "content" | "thinking",text: str);chat_streamyieldsStreamPieceinstead ofstr. Per chunk:delta.reasoning_content(verified aipi field) → thinking piece; fallbackdelta.reasoning(future-proofing, same getattr pattern);delta.content→ content piece. A chunk carrying both yields thinking before content.LLMErrorwrapping and generation params (model,temperature=0.4,max_tokens,stream=True) unchanged.app/schemas.py—ChatThinkingEvent(type="thinking",text), sibling ofChatErrorEvent/ChatDoneEvent.app/config.py—stream_thinking: bool = True(BOR_STREAM_THINKING;0/falsedisables) — operator kill-switch. When off, thinking pieces are still counted for the log line but never emitted. (Reasoning is otherwise always on: the model emits it, and the whole point of this phase is to show it.)app/api/chat.py— maps pieces tothinking/deltaevents; accumulatesthinking_charsper turn; per-turn log line (PLAN §9) gainsthinking_chars=Ninserted immediately beforetotal_ms=N. No schema change (A13 untouched), no new packages (A12 untouched).
- Frontend (
frontend/assets/app.js,styles.css):- Block DOM (dynamic —
index.htmlunchanged): inside.msg-body, before.bubble:<details class="thinking" open> <summary>Thinking</summary> <div class="thinking-text"></div> </details>renderStoredMessagerenders the same block collapsed for restored messages carryingthinking. - Turn handler (
handleSend): new turn-local statethinkingAcc,sawThinking,sawDone.- First
thinkingevent:clearTurnTimeout()(the stream is alive — the 120s pre-token guard also clears on the firstdelta, as today); if no wrap exists yet, create it (addMessage("brain", ""));ensureThinkingBlock(wrap)(idempotent; creates the open<details>and returns it); the typing indicator is removed (the live block replaces it as the visible "thinking" feedback — the UI state staysthinking: button still disabled, label "Thinking…",#send-statusstill "Brain of Reese is thinking" — no state machine change); render.thinking-text.innerHTML = renderMarkdown(thinkingAcc)(escape- first renderer — XSS-safe; the model's scratchpad may contain markdown-ish formatting); while the block is open, pin.thinking-textscrolled to the bottom on each update. - First
delta: if the UI state is stillthinking, transition tostreaming(replaces today'sif (!wrap)-only transition so the label/status flip even when thinking created the wrap first);closeThinkingBlock(wrap)(idempotent — never reopens a block once the answer started); existing delta logic unchanged. done:sawDone = true; existing logic (deflected styling, source chips, tune button) unchanged; the thinking block is closed if open; thinking-without-answer: whenaccis empty butsawThinking, the bubble receives the existing empty-answer fallback string and that is what gets persisted (what the user saw is what is stored); persistence record gainsthinking: thinkingAcc(only when non-empty —bor.chat.v1shape: brain messages may carry an optionalthinkingfield; no version bump: old records without it restore exactly as before).- Stream-drop guard (new, required now that streams run longer):
after
readSSEcompletes, if!sawDone && !abortedand at least onethinking/deltaframe arrived →setUiState(error, "The stream ended before my answer finished — try again?")(previously a severed stream settled silently into idle with a half bubble). Zero frames + no wrap keeps today's "came back empty" fallback.
- First
- No live region on the thinking text (it is a scratchpad —
announcing every chunk would be hostile to screen readers); the
existing
#send-statusregion + native<details>open/closed announcements cover accessibility. - Styling (Phase-08 tokens, WCAG AA):
details.thinking—background: var(--surface),border: 1px solid var(--line),border-left: 3px solid var(--brand-soft),border-radius: var(--radius-sm),margin-bottom: 0.5rem,overflow: hidden.summary— flex row,padding: 0.5rem 0.75rem,min-height: 44px(mobile touch target),color: var(--brand-ink)(8.7:1 on surface),font-size: 0.9rem,cursor: pointer,list-style: none(+::-webkit-details-marker {display:none}), CSS chevron::before("▸", rotates 90° when open, 0.15s transform disabled underprefers-reduced-motion),:focus-visible3pxvar(--brand)outline offset 2px..thinking-text—padding: 0 0.75rem 0.75rem,color: var(--ink-soft)(6.9:1 on surface),font-size: 0.875rem,line-height: 1.55,max-height: 320px,overflow-y: auto; its inner paragraphs get reduced margins. No background animation —prefers-reduced-motionrespected by construction (only the chevron transition, gated).
- Block DOM (dynamic —
- E2E mock (
tests/e2e/mock_llm.py): deterministic thinking via a new user-message triggerTHINKING_TRIGGER = "think out loud"(same convention as the existing"write a long answer"/"pretend to think slowly"triggers). When the trigger is present the mock streams ~800 chars of deterministicreasoning_contentchunks (a fixed "Step 1… Step 4…" scratchpad) before the normalcontentchunks; the non-streaming path includesreasoning_contentin the message. Without the trigger the mock is byte-identical to today — every existing story suite is unaffected. (The suite is mock-only by design:E2E_REAL_LLM=1against the realturbo— which thinks on every turn — would break the "no thinking block" regression test. Note that in the file header.) - Non-goals: no thinking-budget/effort request parameters (the model
decides;
BOR_MAX_OUTPUT_TOKENSalready bounds total output); no DB schema change (thinking is not stored server-side —query_logkeeps its shape; only the log line counts chars); no in-UI toggle (the kill-switch isBOR_STREAM_THINKING); nodone-event change; no changes to steering/suggestions/viewer.
Tasks
01_backend_thinking_stream.md—StreamPiecein the LLM client,thinkingSSE event end-to-end (schema, settings, chat mapping, log line), backend tests.02_frontend_thinking_block.md— the collapsible thinking block inapp.js/styles.css(streaming, auto-collapse, persistence, stream-drop guard), frontend unit pins.03_e2e_mock_and_story_suite.md— mock thinking trigger + the dedicatedtest_thinking_display.pyPlaywright suite (5 scenarios), run in isolation.04_story_docs_plan_commit.md— user story file, README section,.env.example, PLAN §2/§4/§7/§9/§12 revisions (owner permission recorded), the single atomic commit, phase move to complete/.
Locked decisions
- A15 extended with owner permission (2026-08-23): the SSE contract
gains the
thinkingevent type;delta+doneshapes unchanged. Recorded as a PLAN §4 revision (owner permission noted), not a silent deviation. - A5 untouched — no new models/params sent to aipi; we only read a
field the model already emits. A11 untouched — vanilla JS/CSS, no
CDN (native
<details>/<summary>). A12/A13 untouched — no new services, no migration. A16 untouched — one new story E2E suite + unit/integration extensions. A10 untouched — chat stays public; thinking is visible to everyone (homelab scope). No other anchor changed.
Testing & Quality
- Unit:
tests/unit/test_llm_client.py(StreamPiece mapping: content,reasoning_content,reasoningfallback, both-in-one-chunk ordering, empty-skip, failure wrapping),tests/unit/test_sse_events.py(thinking frame shape),tests/unit/test_config.py(default + env parse ofstream_thinking),tests/unit/test_frontend_feedback.py+tests/unit/test_chat_persistence.py(source-level pins: thinking event handling, block markers, auto-collapse,sawDoneguard, persistedthinkingfield,.thinkingCSS rules). - Integration:
tests/integration/test_chat_api.py—FakeRagLLMgains optional thinking; thinking frames precede all delta frames and reassemble;BOR_STREAM_THINKING=0suppresses thinking frames while deltas are unchanged; existing suites stay green. - Coverage:
uv run pytest --cov=app --cov-report=term-missing>90% onapp/. - E2E:
tests/e2e/test_thinking_display.py— five scenarios (see task 03), mock-only, green in isolation (uv run pytest tests/e2e/test_thinking_display.py -v --no-cov). - Lint/types:
uv run ruff check . && uv run pyrightclean.
Completion Criteria
uv run pytestgreen (unit + integration);uv run pytest --cov=app --cov-report=term-missing> 90%.uv run pytest tests/e2e/test_thinking_display.py -v --no-covgreen in isolation (prereq:podman compose up -d db).- Regression suites green in isolation:
test_chat_rag.py,test_loading_feedback.py,test_chat_persistence.py,test_honest_deflection.py. uv run ruff check . && uv run pyrightclean.- Manual live check (dev server, real aipi): a chat turn shows the
thinking block streaming, collapsing when the answer starts,
toggleable afterwards; with
BOR_STREAM_THINKING=0no block. - UI Structure Check (AGENTS.md rule 5): block uses Phase-08 tokens,
all text contrast ≥4.5:1, summary is a real focusable control with
≥44px target,
prefers-reduced-motionrespected, no CDN tags, chat column still 46rem. .agent/user_stories/thinking-display.mdexists; PLAN §2/§4/§7.4/ §7.5/§9/§12 carry the revision notes (owner permission 2026-08-23).- One
--no-gpg-signcommit (below);.agent/phases/todo/17_thinking_display/moved to.agent/phases/complete/.
Commit
git add -A .agent/ app/ frontend/ tests/ README.md .env.example && git commit --no-gpg-sign -m "feat(chat): stream model thinking over SSE and show it in a collapsible block"