chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

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.
This commit is contained in:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
@@ -0,0 +1,232 @@
# 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` (the
`idle → thinking → streaming → done | error → idle` state machine +
120s guard the thinking display plugs into), `14_chat_persistence`
(localStorage record shape gains an optional `thinking` field),
`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/completions` with `stream=True` against `turbo` emits
`choices[0].delta.reasoning_content` chunks **before** the first
`delta.content` chunk (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`: at `max_tokens=300` a probe
produced 300 reasoning tokens and **no answer content**. With the locked
default `BOR_MAX_OUTPUT_TOKENS=32768` there is ample headroom, but an
answer can in principle be empty — the UI must handle
thinking-without-answer gracefully (existing empty-answer fallback).
- `openai` SDK 2.54 (this repo's version) preserves unknown delta fields:
`ChatCompletionChunk.model_validate(...)` keeps `reasoning_content` in
`model_extra`, reachable via `getattr(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 before `delta` frames in practice (the model reasons first); the
client must tolerate a late/interleaved `thinking` event defensively
(append to the block, never reopen it once the answer started). The
`done` event shape is **unchanged** (`deflected`, `sources`,
`suggestions`) — thinking text never needs to travel again on `done`.
- **Backend:**
- `app/rag/llm.py` — new `StreamPiece` (frozen dataclass:
`kind: "content" | "thinking"`, `text: str`); `chat_stream` yields
`StreamPiece` instead of `str`. Per chunk: `delta.reasoning_content`
(verified aipi field) → thinking piece; fallback `delta.reasoning`
(future-proofing, same getattr pattern); `delta.content` → content
piece. A chunk carrying both yields thinking **before** content.
`LLMError` wrapping and generation params (model, `temperature=0.4`,
`max_tokens`, `stream=True`) unchanged.
- `app/schemas.py` — `ChatThinkingEvent` (`type="thinking"`, `text`),
sibling of `ChatErrorEvent`/`ChatDoneEvent`.
- `app/config.py` — `stream_thinking: bool = True`
(`BOR_STREAM_THINKING`; `0`/`false` disables) — 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 to `thinking`/`delta` events;
accumulates `thinking_chars` per turn; per-turn log line (PLAN §9)
gains `thinking_chars=N` inserted immediately before `total_ms=N`.
No schema change (A13 untouched), no new packages (A12 untouched).
- **Frontend (`frontend/assets/app.js`, `styles.css`):**
- **Block DOM** (dynamic — `index.html` unchanged): inside `.msg-body`,
**before** `.bubble`:
```html
<details class="thinking" open>
<summary>Thinking</summary>
<div class="thinking-text"></div>
</details>
```
`renderStoredMessage` renders the same block **collapsed** for
restored messages carrying `thinking`.
- **Turn handler** (`handleSend`): new turn-local state
`thinkingAcc`, `sawThinking`, `sawDone`.
- First `thinking` event: `clearTurnTimeout()` (the stream is alive —
the 120s pre-token guard also clears on the first `delta`, 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 stays `thinking`: button still disabled, label
"Thinking…", `#send-status` still "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-text` scrolled to the bottom on each update.
- First `delta`: if the UI state is still `thinking`, transition to
`streaming` (replaces today's `if (!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:** when `acc` is empty but `sawThinking`,
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 gains `thinking: thinkingAcc` (only when
non-empty — `bor.chat.v1` shape: brain messages may carry an
optional `thinking` field; **no version bump**: old records without
it restore exactly as before).
- **Stream-drop guard (new, required now that streams run longer):**
after `readSSE` completes, if `!sawDone && !aborted` and at least
one `thinking`/`delta` frame 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.
- **No live region on the thinking text** (it is a scratchpad —
announcing every chunk would be hostile to screen readers); the
existing `#send-status` region + 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 under `prefers-reduced-motion`), `:focus-visible`
3px `var(--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-motion`
respected by construction (only the chevron transition, gated).
- **E2E mock (`tests/e2e/mock_llm.py`):** deterministic thinking via a new
user-message trigger `THINKING_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 deterministic `reasoning_content` chunks
(a fixed "Step 1… Step 4…" scratchpad) **before** the normal
`content` chunks; the non-streaming path includes `reasoning_content`
in 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=1` against the real `turbo` — 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_TOKENS` already bounds total output); no DB
schema change (thinking is not stored server-side — `query_log` keeps
its shape; only the log line counts chars); no in-UI toggle (the
kill-switch is `BOR_STREAM_THINKING`); no `done`-event change; no
changes to steering/suggestions/viewer.
## Tasks
1. `01_backend_thinking_stream.md` — `StreamPiece` in the LLM client,
`thinking` SSE event end-to-end (schema, settings, chat mapping,
log line), backend tests.
2. `02_frontend_thinking_block.md` — the collapsible thinking block in
`app.js`/`styles.css` (streaming, auto-collapse, persistence,
stream-drop guard), frontend unit pins.
3. `03_e2e_mock_and_story_suite.md` — mock thinking trigger + the
dedicated `test_thinking_display.py` Playwright suite (5 scenarios),
run in isolation.
4. `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 `thinking` event type; `delta` + `done` shapes 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`, `reasoning` fallback, 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 of `stream_thinking`), `tests/unit/test_frontend_feedback.py` +
`tests/unit/test_chat_persistence.py` (source-level pins: thinking event
handling, block markers, auto-collapse, `sawDone` guard, persisted
`thinking` field, `.thinking` CSS rules).
- **Integration:** `tests/integration/test_chat_api.py` — `FakeRagLLM`
gains optional thinking; thinking frames precede all delta frames and
reassemble; `BOR_STREAM_THINKING=0` suppresses thinking frames while
deltas are unchanged; existing suites stay green.
- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing`
**>90%** on `app/`.
- **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 pyright` clean.
## Completion Criteria
- [ ] `uv run pytest` green (unit + integration);
`uv run pytest --cov=app --cov-report=term-missing` > 90%.
- [ ] `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
green 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 pyright` clean.
- [ ] 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=0` no 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-motion` respected, no CDN tags,
chat column still 46rem.
- [ ] `.agent/user_stories/thinking-display.md` exists; PLAN §2/§4/§7.4/
§7.5/§9/§12 carry the revision notes (owner permission
2026-08-23).
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/17_thinking_display/` moved to
`.agent/phases/complete/`.
## Commit
```bash
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"
```