phase: 153_document_viewer_context
Build and Push Containers / build-and-push-app (push) Successful in 2m45s
Build and Push Containers / build-and-push-db (push) Successful in 19s

That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.

## Phase 153 — final verification pass report

**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched

**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.

**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.

**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.

**Next pending phase:** `154_sync_status_freshness`.
This commit is contained in:
2026-10-04 09:23:54 -04:00
parent 12fa69b7ea
commit 966c4b0540
59 changed files with 4304 additions and 34 deletions
@@ -0,0 +1,427 @@
# Phase 158 — Chronological thinking / tool-call timeline
**Source:** owner report (live interactive-browser session, 2026-10-04, shared page
`https://brain.reeseapps.com/shared/95dd7a2a-8cda-478b-b694-53936070b9fb` — a real
`turbo` turn with 6 tool calls): "the thinking block happens first but LLM sent a
normal response, not a thinking response, and then went back to thinking. This is
awkward. Then the tool calling happens, and the thinking block reads strangely
because the tool calls are shown below the thinking block. … show the thinking /
tool call / responses in order, rather than in single big blocks each."
**Story:** n/a (owner bug report — live site, 2026-10-04)
**Context:** `app/rag/agent.py::run_agent` (yields `thinking` / `content` /
`ToolCallPiece` / `ToolResultPiece` in TRUE chronological order per round —
thinking → tool call → thinking → … → answer; the SSE mapping in
`app/api/chat.py` preserves that order, so NO backend stream change is needed);
`frontend/assets/app.js` — `ensureThinkingBlock` (~L1053, ONE `details.thinking`
per message, idempotent; phase 109 D15 re-opens it on every later thinking frame),
`closeThinkingBlock` (~L1075), `closeToolCalls` (~L1086), `appendToolLine`
(~L1136, ONE `details.tool-calls-disclosure` per message — phase 117 D3
open-live/fold-at-rest), `armToolLineClock` (~L1479, the phase-87 clock queries
`.tool-calls .tool-call:last-child`), the turn handler in `runTurn`
(thinking ~L3148 / tool ~L3193 / delta ~L3300 / done ~L3313 branches;
`thinkingAcc` ~L3071, `toolAcc` ~L3080, `segRuns` — NEW — module scope beside
them; the done save point ~L3355, the `tool_result` branch ~L3389, the
stream-drop `finalizeFailedTurn` call ~L3417), `rememberBrainTurn` (L2589,
`meta` spread — the pagehide partial save reuses it), `finalizeFailedTurn`
(L2983); `renderStoredMessage` (~L2089: the `m.thinking` block + the `m.tools`
loop, both rendered collapsed); `frontend/assets/shared.js` — `addThinkingBlock`
(L126) + `addToolLines` (L175, the byte-parity copy, rendered closed);
`app/schemas.py` — `ChatMessage` (L1103, `extra="forbid"`, phase-83 value caps,
the wrap serializer that omits absent image keys), `ToolCall` (L1017),
`HistoryTurn` (L58 — the history wire, UNCHANGED); `tests/e2e/mock_llm.py`
(`_tool_flow` L1820, `_tool_call_stream` L3813, `_sse_stream` L3727,
`compose_thinking` L3589, the trigger-dispatch order in `chat_completions`
L3872 — the more-specific-phrase-wins convention); the E2E suites whose pins
this phase updates (task 05): `test_turn_progress_loader.py` (the phase-109 D15
re-open pin), plus the unit source pins in `test_frontend_feedback.py`,
`test_frontend_turn_loader.py`, `test_frontend_tool_states.py`,
`test_chat_persistence.py`, `test_shared_page.py`, `test_tool_call_compact.py`,
`test_thinking_scroll.py`, `test_big_read_progress.py`, `test_read_range_tool_lines.py`,
`test_read_truncation_frontend.py`, `test_search_tool_scope.py`,
`test_frontend_failed_turn.py` (each pins `ensureThinkingBlock` / `appendToolLine`
/ the clock in different depths — inspect each; ONLY the pins the redesign breaks
change, the rest stay green untouched).
## Bug basis (code-traced, 2026-10-04)
The server streams the turn in true chronological order (`run_agent` yields per
round: thinking → tool call → thinking → … → answer), but the client LUMPS:
`app.js` funnels every thinking frame of the turn into ONE `details.thinking`
block (`ensureThinkingBlock` is idempotent — the phase-109 D15 re-open re-opens
that same block for each later agent round) and every tool frame into ONE
`details.tool-calls-disclosure`. DOM order is therefore always
`[ALL thinking] → [ALL tool calls] → [answer]` regardless of the actual order —
the owner's "thought everything, then called all the tools, then answered"
reading. The "thinking → answer → back to thinking" awkwardness is the D15
re-open live: the model emits a short mid-round reply alongside a tool call
(React style), the first `delta` closes the one thinking block and the answer
starts, then the next round's thinking frame re-opens the SAME block above the
tool lines — the answer begins, then thinking resumes on top of it. The persisted
`bor.chat.v1` record only stores `thinking` (one concatenated string) + `tools`
(one array) — the per-run order is never captured, so the shared page
(`shared.js`) renders the same lumped layout, collapsed.
## Objective
Render each agent turn as a chronological timeline — each CONTIGUOUS run of
thinking frames in its own `details.thinking` block, each CONTIGUOUS run of tool
calls in its own `details.tool-calls-disclosure` (`Tool call(s) (N)` per run),
segments in arrival order — live, on restore, and on the shared page; persist the
run order in a new optional `segments` array on the brain record (additive;
records without it — all pre-phase — restore byte-identically through the
untouched legacy path).
## Owner decisions (chat, 2026-10-04 — recorded per AGENTS.md rule 3)
- **D1 — Per-run timeline.** Each contiguous thinking run = its own Thinking
block; each contiguous tool-call run = its own disclosure. Consecutive frames
of the same kind extend the current run; the first frame of a new kind closes
the previous run and opens the new one.
- **D2 — Option B mid-round placement (owner-chosen, shown as a live DOM demo in
the owner's browser 2026-10-04).** ALL segments render ABOVE the answer
bubble, in arrival order — including runs that start AFTER the first `delta`
(the mid-round reply case). The bubble stays ONE element; its content
accumulates in arrival order (the mid-round text and the final answer are one
streamed text). Rejected: Option A (post-answer segments below the bubble —
the bubble grows above its own scratchpad).
- **D3 — Plain `Thinking` label on every block (owner-chosen).** The phase-17
pinned summary literal stays byte-identical across runs (zero new literals;
position distinguishes the runs). The disclosure summary stays the phase-117
`Tool call(s) (N)` — now per run.
- **D4 — Additive persistence.** The brain record gains an OPTIONAL `segments`
array; `thinking` (full concatenated text — feeds the history wire + the
per-turn log) and `tools` (all call records) are KEPT (old-client graceful
degradation; the phase-113 lesson applied up front). `segments` is omitted when
absent (the `undefined`-drops-the-key house pattern) — a no-thinking,
no-tools record stays byte-identical to pre-phase.
- **D5 — Open live, fold at rest** (extends phase 117 D3 + the phase-17
open-while-thinking contract): the active run's block is OPEN while its frames
stream; a new run's first frame closes the previous run; the first `delta`,
`done`, and the stop/abort settle fold the open runs; the restore path and
the shared page render everything COLLAPSED.
- **D6 — Server: a validated `segments` field on `ChatMessage`.**
`extra="forbid"` means the new key would 422 every saved-chat write without a
schema field (the phase-113 lesson: a missing key silently UNSAVED every
grounded brain record). The field is a discriminated union (`kind` =
`"thinking"` | `"tool"`) with phase-83-style value caps, omitted-when-`None`
in the serializer (the phase-50 round-trip contract). No migration (the record
is opaque JSONB), no SSE contract change, no history-wire change.
## Design (shared by all tasks — the executor reads this, not the chat)
### The `segments` entry shape (D4)
```
segments: [
{ kind: "thinking", text: "<that run's full text>" },
{ kind: "tool", name: "…", argument?: "…", offset?: int, limit?: int,
path?: "…", truncated?: true, chars_shown?: int, chars_total?: int },
…
]
```
- One `thinking` entry per RUN (the run's accumulated text — the same text that
streams live into that run's block). One `tool` entry per TOOL CALL (the exact
`toolAcc` record shape — the phase-37 `{name, argument}` plus the phase-139
`offset/limit` and phase-146 `path` additive fields, and the phase-95
`truncated/chars_shown/chars_total` stamp when present).
- Built at save time from the run tracker: an EMPTY thinking run stores NOTHING
(a zero-char run — e.g. a `BOR_STREAM_THINKING`-suppressed turn — is absent);
a turn with no thinking and no tools stores no `segments` key at all.
- `thinking` (the full concatenation, for the history wire + the log) and `tools`
(every call record, for old clients) are UNCHANGED in the record.
### `app/schemas.py` (task 01)
- `ChatThinkingSegment`: `kind: Literal["thinking"]`, `text: str`
(`min_length=1`, `max_length=32_000` — `HistoryTurn`'s cap, phase 83).
- `ChatToolSegment`: `kind: Literal["tool"]` + the EXACT `ToolCall` field set
(name/argument/offset/limit/path/truncated/chars_shown/chars_total with their
existing caps) — one model, no duplication of the field definitions where
practical (the docstring cross-references `ToolCall`).
- `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment],
Field(discriminator="kind")]`.
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None,
max_length=50)` (the phase-83 cap style — far past any realistic turn: the
round cap is 10 → ≤ 20 entries; 50 matches the `tools` cap).
- The EXISTING wrap serializer (`_drop_image_when_absent`) gains the rule:
`segments is None → pop "segments"` (ABSENT, never `null` — the phase-50
contract; a record WITH segments keeps them). `HistoryTurn` and the
`history_to_messages` mapper are UNTOUCHED (segments never travel the wire —
the concatenated `thinking` travels exactly as today).
### `frontend/assets/app.js` — the live turn (task 02)
- Turn state: `segRuns = []` (module scope, reset in `runTurn` beside
`thinkingAcc`/`toolAcc` — an array of run objects
`{ kind, text, records }`), `currentRun = null` (turn-local).
- **Insertion anchor (D2):** every run element is inserted `before`
`body.querySelector(".bubble")` (get-or-create the bubble first, as today).
There is no other anchor — segments ALWAYS sit above the bubble, in arrival
order. `ensureThinkingBlock`'s old `.tool-calls-disclosure ?? .tool-calls ??
.bubble` anchor is REPLACED by this.
- **`thinking` frame (per frame):** `settleToolLine()` (unchanged);
`thinkingAcc += ev.text` + `sawThinking` + `clearTurnTimeout()` + `if (!wrap)
wrap = addMessage("brain","")` + `removeTyping()` (all unchanged). Then the
run logic: if `currentRun?.kind === "thinking"` → extend it (re-render
`renderMarkdown(run.text)` into THAT block's `.thinking-text` — the existing
pre-update pin-state / bottom-follow logic moves verbatim into this branch,
now per-block); else → close `currentRun` (if any, `details.open = false`),
create `details.thinking` (`open = true`, `<summary>Thinking</summary>` +
`.thinking-text`, inserted before the bubble), push `{kind:"thinking",text:"",
records:[]}` to `segRuns`, `currentRun` = it, extend.
- **`tool` frame (per frame):** the existing guards stay byte-identical
(the phase-139 `frameOffset`/`frameLimit`, the phase-146 `framePath`
well-formedness; `toolAcc.push(toolRecord)`; `clearTurnTimeout()`; the
`toolStatus` label logic; the phase-70 legacy-name mapping). Then the run
logic: if `currentRun?.kind === "tool"` → extend that disclosure; else → close
`currentRun` (if any), create `details.tool-calls-disclosure` (`open = true`,
`summary.tool-calls-summary` + the `div.tool-calls` list `role=list`
`aria-label="Tool calls"`, inserted before the bubble), push
`{kind:"tool",text:"",records:[]}` to `segRuns`, `currentRun` = it; extend =
build the line EXACTLY as today (`appendToolLine` — now with the container
passed in: `appendToolLine(container, name, argument, offset, limit, scope)`,
the line bytes + the four pinned `line.textContent` label literals + the
phase-139 range suffix + the phase-146 scope span UNCHANGED, the summary
count updated per append — `Tool call(s) (N)` per run), then
`armToolLineClock(wrap, lineEl)` (the NEW signature — task 02's clock fix).
- **`armToolLineClock(wrap, lineEl)` (the phase-87 multi-disclosure fix):**
store the `lineEl` reference (module scope, reset with the wrap); the interval
appends the `.tool-elapsed` suffix to `lineEl` instead of querying
`.tool-calls .tool-call:last-child` (that query breaks with multiple
disclosures — it would find the last line of the FIRST list). The visible
behavior is identical (the suffix on the newest line; `settleToolLine` still
removes every `.tool-elapsed` in the wrap + resets the reference).
- **`delta` handler:** `settleToolLine()` + `acc += ev.text` + the
`thinking→streaming` transition UNCHANGED; replace
`closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with `closeCurrentRun()`
(folds `currentRun` if open; idempotent; no-op without a run).
- **The other settle-pair sites — `finalizeFailedTurn`'s `if (wrap)` block
(L2990–L2991), the STOP path (L3477–L3478), and every remaining
`closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep — the
redesign removes both functions):** `closeAllRuns(wrap)` (closes EVERY
open `details.thinking` + `details.tool-calls-disclosure` in the wrap —
defensive; by construction at most the last run is open, but a late
double-close must be a no-op). The rest of `done` is unchanged.
- **Save payloads (the FIVE sites):** `buildSegments(segRuns)` (a helper —
maps runs: thinking run → `{kind:"thinking",text}` when non-empty; tool run
→ one `{kind:"tool",…record}` per record, in order) — the `done` save point
(~L3355); the `finalizeFailedTurn` parameter + its `if (wrap)` saved record
(the two call sites — stream-drop ~L3431, network-error ~L3519 — pass
`buildSegments(segRuns)`); the STOP path's save meta (L3487–L3491,
`stopped: true`); the pagehide partial (L3642–L3650 — `segRuns` is module
scope, readable there). All include `segments` non-empty only. The
zero-frame fallback save needs nothing (zero frames → no runs → no key).
The `thinking`/`tools` keys are UNCHANGED at every site.
- The UI state machine, `#send-status` copy, the 300 s pre-token guard, the
stream-drop guard, and the `thinking-without-answer` fallback are ALL
UNCHANGED (§7.4 never-stale intact). `BOR_STREAM_THINKING=0` needs no client
change (no thinking frames arrive → no thinking runs).
### `frontend/assets/app.js` — the restore path (task 03)
- `renderStoredMessage`: after the bubble exists, IF
`Array.isArray(m.segments) && m.segments.length` → `restoreTimeline(wrap,
m.segments)` and SKIP the legacy `m.thinking` block + the `m.tools` loop
(segments take precedence — they carry the same data in order); ELSE the
legacy path is BYTE-IDENTICAL (a pre-phase record — or a `BOR_STREAM_THINKING=0`
turn saved with the old client — restores exactly as today).
- `restoreTimeline`: iterate `m.segments` in order; group CONSECUTIVE
`kind:"tool"` entries into ONE `details.tool-calls-disclosure` (created
CLOSED — D5; summary `Tool call(s) (N)`; each line through the SAME
`appendToolLine` container-signature path with the EXISTING tOffset/tLimit/tPath
guards + the existing `appendTruncatedNote` call for `t.truncated` records —
the current loop's guards move verbatim inside the grouping); a `kind:"thinking"`
entry → its OWN `details.thinking` (created CLOSED; `.thinking-text` =
`renderMarkdown(entry.text)` — the restore convention stays escape-first);
unknown `kind` values are SKIPPED (never render from storage — textContent
only, the house rule). All elements inserted before the bubble.
### `frontend/assets/shared.js` — the shared page (task 03)
- `addSegments(wrap, segments)` — the shared-page parity copy of
`restoreTimeline` (pure render, always COLLAPSED — D5): the same grouping,
the same line bytes (the four label literals + the phase-95 truncation note
+ the phase-139/146 suffixes, already parity-pinned in `addToolLines`), the
same summary count; the `addThinkingBlock`/`addToolLines` legacy path is
BYTE-IDENTICAL when the record has no `segments`. The record-dispatch site
(~L456): `if (Array.isArray(m.segments) && m.segments.length)
addSegments(wrap, m.segments); else { the existing addThinkingBlock +
addToolLines }`.
### `tests/e2e/mock_llm.py` (task 04)
- `THINK_TOOLS_TRIGGER = "think out loud and use your tools"` — checked in the
streaming branch BEFORE the `_tool_flow` dispatch (the more-specific-phrase-wins
convention — the phrase contains both `think out loud` and `use your tools`,
so it must not fall through to the plain answer path; no other existing
trigger phrase is a substring of it, and no existing E2E question carries it —
verify both before wiring).
- `_thinking_tools_flow(body)` — stateless, shaped like `_tool_flow` (gated on
the `tools` parameter + the `<tools>` section, like the other marker flows):
- request 1 (tools offered, no tool results yet): `thinking` =
`"Step 1: list the documents to find the reference server config."` →
`_tool_call_stream("ls", {}, "call_0", thinking=thinking)`;
- request 2 (a `tool`-role top-level source listing — the agent's `ls` tree
format, no file lines yet): `"Step 2: drill into the first source."` → the
`_drill_target` scoped `ls` (id `call_1`), with thinking;
- request 3 (a `tool`-role folder listing with file lines — parse the FIRST
file line's labeled `source:` / `path:` fields, the phase-63/70 format):
`"Step 3: read the file line's document."` → `read` on the JOINED
combined `source/path` (id `call_2`), with thinking;
- request 4 (a `tool`-role read result — content starting with the agent's
`"Document <source/path>:"` header): `"Step 4: compose the answer from the
read document."` → the phase-37 answer convention: `Read <source/path>.
<first 80 chars of the read document's content>` (content stream, with
thinking ahead of it).
- `_tool_call_stream(name, arguments, call_id, thinking="")` — the optional
`thinking` param: when non-empty, yield `reasoning_content` chunks FIRST
(the 12-char split — the `_sse_stream` convention), then the existing
`tool_calls` frames + `finish_reason: "tool_calls"` (unchanged). Default
`""` → every pre-phase 3-arg caller streams BYTE-IDENTICAL.
- Docstring: the new trigger entry (the phase-158 flow) — the mock stays
mock-only by design (`E2E_REAL_LLM` note unchanged).
### `tests/e2e/test_thinking_timeline.py` (task 04 — dedicated suite, run in isolation)
The conftest `app_server`/`page`/`seeded_kb` fixtures + the phase-37 admin
login + the trigger question (the KB must be seeded so request 4's read result
exists — mirror `test_agent_document_tools.py`). Six tests:
1. **`test_timeline_order_after_completed_turn`** — after `done`: the brain
wrap's `.msg-body` children before the bubble are EXACTLY
`[details.thinking, details.tool-calls-disclosure, details.thinking,
details.tool-calls-disclosure, details.thinking, details.tool-calls-disclosure,
details.thinking]` (7 elements); the four `.thinking-text`s carry DISTINCT
`Step 1`…`Step 4` markers (distinctness = the runs are separate blocks, not
one lump); each disclosure summary is `Tool call (1)`; the answer bubble
carries the `Read <path>` echo.
2. **`test_runs_fold_at_rest`** — after `done`: NO `.thinking` /
`.tool-calls-disclosure` has the `open` attribute; the summaries are
visible (D5).
3. **`test_live_runs_open_while_streaming`** — during the turn (an
`expect.poll` predicate over the mock's 0.1 s pacing window): at least one
`.thinking` or `.tool-calls-disclosure` is `open` while the answer bubble is
still empty (the D5 live-open contract — the live feedback the owner keeps).
4. **`test_restored_timeline_and_record`** — RELOAD (same context): the
restored message renders the same 7-element timeline, all closed; the
localStorage `bor.chat.v1` record for that brain turn carries a `segments`
array of EXACTLY 7 entries in the pattern
`thinking, tool, thinking, tool, thinking, tool, thinking` (4 thinking runs
+ 3 tool entries).
5. **`test_shared_page_timeline`** — the phase-51 save + share flow, a FRESH
context on `/shared/<token>`: the same 7-element timeline renders, all
collapsed (the `shared.js` parity — `addSegments`).
6. **`test_no_thinking_turn_single_disclosure`** — a plain `use your tools`
question (NO thinking trigger): the turn renders EXACTLY ONE
`.tool-calls-disclosure` (3 lines, `Tool calls (3)`) and ZERO `.thinking`
(the run-grouping contract: no thinking frames → one tool run — the
phase-117 behavior for no-thinking turns is preserved).
## Tasks
1. `01_schema_segments.md` — the discriminated `ChatSegment` models + the
`ChatMessage.segments` field + the serializer omission + the integration
contract (accept / caps / byte-identical round-trip / shared read).
2. `02_live_timeline.md` — the per-run live rendering (run tracker, D2 anchor,
the clock re-target, the settle sites, the save payloads) + the JS source
unit pins.
3. `03_restore_and_shared.md` — the `restoreTimeline` dispatch in
`renderStoredMessage` + the `shared.js` `addSegments` parity + the unit pins.
4. `04_mock_and_e2e.md` — the mock `THINK_TOOLS_TRIGGER` flow + the dedicated
`test_thinking_timeline.py` story suite, run in isolation.
5. `05_regression_update_and_commit.md` — the phase-109 suite pin update + the
remaining unit-pin updates, the full gate, the documentation pass, the
atomic commit, the phase move.
## Testing & Quality
- Unit (new): `tests/unit/test_thinking_timeline.py` — the source-level pins:
the per-run creation (a NEW `details.thinking` when the run kind changes,
insert-before-bubble anchor — D2), the plain `Thinking` literal (D3), the
per-run disclosure + the `Tool call(s) (N)` per-run count, the
`armToolLineClock` reference (the `:last-child` query is GONE), the
`segRuns` → `buildSegments` mapping (empty-run skip, the tool-record shape),
the `delta` `closeCurrentRun` + the settle-pair sites → `closeAllRuns`, the
FIVE save-payload sites (done / finalizeFailedTurn + its 2 call sites /
stop path / pagehide), the restore dispatch (segments vs legacy — the
legacy path byte-identical), the `shared.js` `addSegments` parity, and
`innerHTML` ABSENT from the restore/shared builders (the house rule).
- Integration: `tests/integration/test_chats_api.py` (extended) — a record WITH
segments (both kinds, every additive tool field) is accepted and round-trips
through create/get; `segments` > 50 → 422; a thinking-segment text > 32 000 →
422; an unknown segment `kind` → 422 (`extra="forbid"` intact — a stray
`<b>`-ish key still 422s); a record WITHOUT `segments` round-trips with the
key ABSENT (never `null` — the phase-50 byte-identical contract, pinned);
the shared read (`/api/chats/<id>/shared` or the public read path) carries
segments (the public shape).
- Existing unit suites MUST stay green: every pin NOT broken by the redesign
(the tool-line label literals, the phase-117 deboxed CSS, the phase-87
`.tool-elapsed` behavior, the phase-139/146 suffix pins, the emoji guard,
the `test_tool_call_compact.py` pins — a no-thinking turn is one run, so the
phase-117 E2E contract is UNCHANGED).
- E2E: `tests/e2e/test_thinking_timeline.py` (new — the six tests above), green
**in isolation** (`uv run pytest tests/e2e/test_thinking_timeline.py -v
--no-cov`), mock-only.
- Regression E2E (task 05, in isolation): `test_thinking_display.py` (a
no-tools thinking turn is ONE run — the suite's contract is unchanged),
`test_tool_call_compact.py` (unchanged — no-thinking single-run),
`test_turn_progress_loader.py` (UPDATED in task 05 — the D15 pin → the
per-run contract), `test_big_read_progress.py` (the clock's visible behavior
is identical — must stay green UNCHANGED), `test_agent_document_tools.py`,
`test_share_chat.py`, `test_chat_persistence.py`, `test_smoke.py`.
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` **>90%** on
`app/` (task 01's schema code is unit/integration-covered; the rest is held
by the suite).
- Lint/types: `uv run ruff check . && uv run pyright` clean.
- The phase is "Complete" only when the **entire** project test suite passes
100% (every test in the project, not just this phase's).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the
project as it stands after this phase — fixing every inconsistency found,
**including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
user-visible (the timeline is a visible UI change — name it where the chat
thinking/tool-call display is described, if anywhere).
## Completion Criteria
- [ ] A completed tool+thinking turn renders `Thinking → Tool call (1) →
Thinking → Tool call (1) → … → answer` — the E2E's 7-element order pin
(task 04 test 1); the runs fold at rest (test 2) and stay open while
streaming (test 3).
- [ ] A reload restores the same timeline + the record's `segments` array
(test 4); a fresh context on `/shared/<token>` renders the same timeline
(test 5).
- [ ] A no-thinking tool turn is ONE disclosure, 3 lines (test 6) — the
phase-117 contract is preserved; `test_tool_call_compact.py` +
`test_thinking_display.py` + `test_big_read_progress.py` green in
isolation UNCHANGED.
- [ ] A pre-phase saved record (no `segments`) restores byte-identically
(the integration pin + the E2E regression suites).
- [ ] `uv run pytest` green (100% of the project suite);
`uv run pytest --cov=app --cov-report=term-missing` > 90%;
`tests/e2e/test_thinking_timeline.py` green in isolation;
`uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `app/schemas.py`, `frontend/assets/app.js`,
`frontend/assets/shared.js`, `tests/`, and the phase files.
- [ ] README.md and AGENTS.md are accurate and consistent with the whole
project (verified in the final pass).
- [ ] ONE owner-directed exception to "no behavior change in completed phases":
the phase-109 D15 re-open contract is REPLACED by the per-run blocks
(owner direction 2026-10-04 — recorded above); `test_turn_progress_loader.py`'
pin is updated in task 05 to the per-run contract (two thinking blocks,
each carrying its own round, open in its live window, folded at rest).
- [ ] One atomic `--no-gpg-sign` commit (below); the phase dir moved to
`.agents/phases/complete/`.
## Commit
```bash
git add -A .agents/ app/schemas.py frontend/ tests/ README.md && git commit --no-gpg-sign -m "feat(ui): render thinking and tool runs as a chronological timeline (per-run blocks, segments persistence)"
```
## Locked decisions
- **The SSE contract is UNCHANGED (A15 intact).** The frames already arrive in
true chronological order (`run_agent`'s yield order — verified in code); this
phase is client-grouping + record persistence only. `app/rag/` is untouched.
- **D1–D6 (owner, 2026-10-04)** — the per-run timeline, Option B placement,
the plain `Thinking` label, the additive `segments` record, open-live/
fold-at-rest, and the validated `ChatMessage.segments` field (the
`extra="forbid"` boundary preserved with phase-83-style caps).
- **A10 history wire UNCHANGED** — segments never travel to the LLM (the
concatenated `thinking` travels as `reasoning_content` exactly as today;
`HistoryTurn` + the mapper are untouched).
- **A13 UNCHANGED** — no migration (the record is opaque JSONB; the schema
field is validation at the boundary only).
- **A16 honoured** — one new story E2E suite + the task-05 regression updates;
mock-only by design. **A17 honoured** — one atomic `--no-gpg-sign` commit.
- **The phase-87 clock re-target** (the `:last-child` query → the tracked
reference) is a bug fix in service of D1; its visible behavior (the suffix on
the newest line) is byte-identical — `test_big_read_progress.py` stays green
unchanged. No other anchor changed.
@@ -0,0 +1,69 @@
# Task 01 — the validated `ChatMessage.segments` field (server)
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
Saved-chat writes accept the new optional `segments` array (a discriminated
`thinking`/`tool` union with phase-83-style caps), omit it when absent so
pre-phase records round-trip byte-identically, and reject malformed values at
the `extra="forbid"` boundary.
## Work
1. `app/schemas.py`:
- `ChatThinkingSegment` (a `BaseModel`, beside `ToolCall`): `kind:
Literal["thinking"]`, `text: str = Field(min_length=1, max_length=32_000)`
(`HistoryTurn`'s cap, phase 83 — docstring cross-reference).
- `ChatToolSegment` (a `BaseModel`): `kind: Literal["tool"]` + the EXACT
`ToolCall` field set (name / argument / offset / limit / path / truncated /
chars_shown / chars_total with their existing types and caps — mirror, do
not weaken; docstring cross-references `ToolCall` as the shape source).
- `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment],
Field(discriminator="kind")]`.
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None,
max_length=50)` — with the phase-83 voice comment (the cap sizing: far
past any realistic turn — the round cap is 10 → ≤ 20 entries; 50 matches
the `tools` cap).
- The EXISTING wrap serializer (`_drop_image_when_absent`) gains the
omission rule: after the two image pops, `if self.segments is None:
data.pop("segments", None)` — ABSENT, never `null` (the phase-50
round-trip contract; the phase-122 `SourceRef.image_url` omission
precedent, the phase-113 lesson applied up front: the field EXISTS so
the boundary does NOT 422 the done-time auto-save carrying it).
- The `ChatMessage` docstring gains the phase-158 paragraph (the shape,
the omission rule, the `extra="forbid"` boundary) in the established
voice; `HistoryTurn` and everything in `app/rag/` is UNTOUCHED (the
history wire stays `{who, text, thinking}`).
2. NO migration, NO router change (`app/api/chats.py` validates through
`ChatMessage` — `m.model_dump()` + `ChatMessage.model_validate(m)` already
carry the new field end to end, including the public shared read
`SharedChatOut.messages`).
## Testing & Quality
- Integration (extend `tests/integration/test_chats_api.py` — the file already
pins the brain-record contract, phase 83/113/120/123/124):
- a record WITH `segments` (one `thinking` entry + two `tool` entries
carrying every additive field: `offset/limit`, `path`,
`truncated/chars_shown/chars_total`) is accepted by `POST /api/chats` and
round-trips through `GET` (the shape preserved, entry order preserved);
- `segments` with 51 entries → 422; a thinking-segment `text` of 32 001
chars → 422; a segment with `kind: "bogus"` → 422; a record with a stray
top-level key (e.g. `"foo": 1`) still → 422 (the `extra="forbid"`
boundary intact);
- a record WITHOUT `segments` (a pre-phase brain record, the full phase-113
shape) round-trips with the key ABSENT from the served JSON (never
`null` — the byte-identity pin, the phase-50 contract);
- the shared read path (the public read endpoint's `messages`) carries the
segments of a saved record with them (the public shape, the phase-51
contract).
- Coverage: **>90%** on this task's new/modified code (the schema code is
exercised by the integration tests).
## Completion Criteria
- [ ] the three models + the field + the serializer rule land exactly as
designed (a source check)
- [ ] the new integration tests are green; the EXISTING
`test_chats_api.py` tests stay green unchanged
- [ ] `uv run pytest tests/integration -q` green; `uv run ruff check . && uv
run pyright` clean
- [ ] no change in `app/rag/`, no migration, no router change (a `git diff
--stat` scoped check)
@@ -0,0 +1,195 @@
# Task 02 — the per-run live timeline in `app.js`
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
Replace the single-block/single-disclosure turn rendering with the per-run
timeline: each contiguous thinking run streams into its own `details.thinking`
block, each contiguous tool-call run into its own
`details.tool-calls-disclosure`, all segments ABOVE the answer bubble in
arrival order (D2); the runs open live and fold at rest (D5); the phase-87
clock is re-targeted to a tracked line reference; the save payloads persist
the `segments` array (D4).
## Work
1. `frontend/assets/app.js` — turn state (the `runTurn` locals / module scope,
~L3065–L3080):
- `segRuns = []` — MODULE scope (beside `thinkingAcc`, which the pagehide
partial reads), reset to `[]` in `runTurn` with the other turn locals.
Entries: `{ kind: "thinking", text: "", records: [] }` /
`{ kind: "tool", text: "", records: [] }` (the tool entry's `records`
holds the `toolAcc` record objects — the SAME objects `toolAcc` holds,
so the phase-95 `tool_result` stamp (`t.truncated = true; t.chars_shown
= …; t.chars_total = …`, ~L3398–L3404) flows into both automatically —
NO new wiring).
- `currentRun = null` — turn-local (the active run's element + kind).
2. `frontend/assets/app.js` — the run builders (replace `ensureThinkingBlock`
+ the single-container logic in `appendToolLine`):
- `ensureThinkingRun(wrap)` — returns the CURRENT thinking run's
`details.thinking` if `currentRun?.kind === "thinking"`; else closes
`currentRun` (if any — `open = false`), creates a NEW
`details.thinking` (`open = true`, `<summary>Thinking</summary>` — the
phase-17 literal, D3 — built via the existing `innerHTML` template that
`ensureThinkingBlock` uses, OR createElement/textContent — either is fine,
but the RESTORE path's builder stays createElement/textContent (the house
rule pins `innerHTML` ABSENT from the restore/shared builders — see task
03)), inserts it `before body.querySelector(".bubble")` (the bubble
get-or-created first), pushes a fresh `{kind:"thinking",…}` to `segRuns`,
sets `currentRun`.
- `ensureToolRun(wrap)` — the same, for
`details.tool-calls-disclosure` (`open = true`,
`summary.tool-calls-summary` + the `div.tool-calls` list `role=list`
`aria-label="Tool calls"` — the phase-117 structure, now per run).
- `appendToolLine` — the signature becomes
`appendToolLine(container, name, argument, offset = null, limit = null,
scope = null)` (the CALLER passes the run's disclosure — no more
`body.querySelector(".tool-calls-disclosure")` single-container
derivation). EVERYTHING else in the function is BYTE-IDENTICAL: the
`.tool-calls` list get-or-create, the line bytes, the four pinned
`line.textContent` label literals, the phase-139 range suffix, the
phase-146 scope span, the `Tool call(s) (N)` count.
- `closeCurrentRun()` — folds `currentRun` (if any, `open = false`);
idempotent; no-op without a run.
- `closeAllRuns(wrap)` — closes EVERY open `details.thinking` +
`details.tool-calls-disclosure` in the wrap (defensive; the `done` +
stop/abort settle sites).
3. `frontend/assets/app.js` — the turn-handler branches:
- the `thinking` branch (~L3148): keep `settleToolLine()`,
`thinkingAcc += ev.text || ""`, `sawThinking`, `clearTurnTimeout()`,
`if (!wrap) wrap = addMessage("brain", "")`, `removeTyping()` — all
unchanged. REPLACE the `ensureThinkingBlock` + `block.open = true`
(phase-109 D15 re-open — SUPERSEDED by D1) with
`const block = ensureThinkingRun(wrap);` + the run's text extension:
the run's `text` accumulates `ev.text`; the pre-update pin-state /
bottom-follow logic (the `isThinkingNearBottom` capture +
`scrollTop = scrollHeight`, ~L3166–L3189) moves VERBATIM into this
branch, now operating on THIS block (per-block — the existing comment
block stays, updated to name the per-run placement).
- the `tool` branch (~L3193): keep the phase-139/146 guards,
`toolAcc.push(toolRecord)`, `clearTurnTimeout()`, the wrap
get-or-create, and the `toolStatus` label logic UNCHANGED. REPLACE
`appendToolLine(wrap, …)` + `armToolLineClock(wrap)` with:
`const disc = ensureToolRun(wrap); const line =
appendToolLine(disc, name, argument, frameOffset, frameLimit, framePath);`
(the function returns the appended `line` element — a one-line change,
the function already has it in scope) +
`armToolLineClock(wrap, line)`. The run record is the SAME `toolRecord`
object pushed to `toolAcc` (push it into the run's `records` too — or
derive the run's `records` from `segRuns` at save time via the call
order — the executor picks ONE, but the phase-95 stamp must reach the
`segments` payload: the simplest is the run's `records.push(toolRecord)`
alongside the `toolAcc.push(toolRecord)`).
- the `delta` branch (~L3300): `settleToolLine()` + `acc += …` + the
`thinking→streaming` transition UNCHANGED; replace
`closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with
`closeCurrentRun()`.
- the THREE remaining settle-pair sites — `finalizeFailedTurn`'s
`if (wrap)` block (L2990–L2991), the STOP path (L3477–L3478), and any
other `closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep for
the pair — the redesign removes both functions): replace the pair with
`closeAllRuns(wrap);`.
- `removeTyping()` / the typing-indicator / the `#send-status` copy / the
UI state machine / the 300 s guard / the stream-drop guard / the
`thinking-without-answer` fallback: ALL UNCHANGED (§7.4 intact).
4. `frontend/assets/app.js` — the clock (the phase-87 re-target, ~L1479):
- `armToolLineClock(wrap, lineEl)` — the NEW second param: store `lineEl`
in a module-scope `toolLineEl` (reset to `null` with `toolLineWrap` in
`stopToolLineClock` and on re-arm); the interval body uses `toolLineEl`
(null-guarded — the wrap-reset no-op stays) instead of
`toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child")`.
The suffix element + the `(Ns)` text + the `TOOL_LINE_ELAPSED_AFTER_MS`
gate are UNCHANGED.
- `settleToolLine()` — the `querySelectorAll(".tool-elapsed")` removal stays
(it clears every line in the wrap — correct with multiple disclosures);
add `toolLineEl = null` alongside.
- the comment block updated to name the re-target (the multi-disclosure
fix, phase 158 D1).
5. `frontend/assets/app.js` — the save payloads (D4) — the FIVE sites:
- `buildSegments(segRuns)` — a helper (beside the turn state): maps the
runs IN ORDER — a thinking run → `{ kind: "thinking", text: run.text }`
ONLY when `run.text` is non-empty (an empty run stores nothing); a tool
run → one `{ kind: "tool", …record }` per record in order (spread the
record object — the additive `offset/limit/path` + the phase-95 stamp
fields ride through; NO re-validation on the client — the server's
`ChatMessage` boundary validates).
- the `done` save point (~L3355, the `rememberBrainTurn` meta):
`segments: buildSegments(segRuns).length ? buildSegments(segRuns) :
undefined` (the `undefined`-drops-the-key house pattern, D4) — the
`thinking: thinkingAcc || undefined` + `tools: toolAcc.length ? toolAcc :
undefined` lines are UNCHANGED.
- `finalizeFailedTurn` (L2983): the parameter object gains `segments` (the
TWO call sites — the stream-drop site ~L3431 and the network-error
site ~L3519 — pass `buildSegments(segRuns)`); the function's `if
(wrap)` saved record includes `segments` ONLY when the passed value is
a non-empty array (`segments: segments?.length ? segments :
undefined`); the existing `acc`/`thinking`/`tools` fields are
UNCHANGED.
- the STOP path's save point (L3487–L3491, the `stopped: true` meta):
gains the same `segments` field (non-empty only) — a stopped partial
restores its timeline.
- the pagehide partial (L3642–L3650, the module-scope read — `segRuns`
is module scope, so it is readable there): gains the same `segments`
field (non-empty only). The zero-frame fallback save (L3456–L3462)
needs NOTHING (zero frames → no runs → no key).
- the `thinking`/`tools` keys are UNCHANGED at every site.
6. `tests/unit/test_thinking_timeline.py` (NEW — the source-level house
pattern, the `js[fn : js.find("\n}\n", fn)]` body-extraction the existing
pins use):
- `ensureThinkingRun` body: creates `details` + `className = "thinking"` +
`open = true` + the plain `Thinking` summary literal (D3 — the phase-17
literal, NOT a numbered variant) + inserts before `.bubble` (D2 — the
anchor is the bubble, NOT the old `.tool-calls-disclosure ?? …` chain);
`ensureToolRun` body: `className = "tool-calls-disclosure"` + `open =
true` + the summary/list structure.
- `appendToolLine` signature: the first param is the container (no
`body.querySelector(".tool-calls-disclosure")` in the body — the
single-container derivation is GONE); the four label literals STILL
present (mirror the guard); returns the line.
- `armToolLineClock`: the `:last-child` query is GONE; a `toolLineEl`
reference is used; `settleToolLine` resets it.
- `closeCurrentRun` / `closeAllRuns` defined; the `delta` branch calls
`closeCurrentRun` (pin: appears in the delta-branch region); `done` +
the stop/abort settle call `closeAllRuns`.
- `buildSegments` present; the `done` save + the `finalizeFailedTurn`
call site + the pagehide partial all reference it (≥ 3 call sites).
- `innerHTML` NOT in `ensureThinkingRun`… — pin per the builder choice
made in Work item 2 (if the live builder reuses the phase-17 innerHTML
template, pin that; the RESTORE/SHARED builders (task 03) are pinned
innerHTML-free there).
- `segRuns` is module scope + reset in `runTurn`.
- Builder style: MIRROR the function it replaces (the live thinking
builder keeps the phase-17 static-structure convention — a static
template with NO data — the data flows through `renderMarkdown` /
`textContent` exactly as today; the live tool builder keeps the
phase-117 createElement/textContent style — `innerHTML` ABSENT from
`appendToolLine`, as pinned today). Pin the STRUCTURE + the no-data-
through-innerHTML rule, not the mechanism.
## Testing & Quality
- Unit: `tests/unit/test_thinking_timeline.py` (this task's pins) + UPDATE the
existing source pins THIS task's change breaks (inspect, in order:
`test_frontend_feedback.py`, `test_frontend_turn_loader.py` — the phase-109
D15 re-open pin becomes the per-run pin (a NEW block, not a re-open),
`test_frontend_tool_states.py` — the `appendToolLine` single-container pin,
`test_big_read_progress.py` — the clock-query pin, `test_frontend_failed_turn.py`
— the `finalizeFailedTurn` payload pin, `test_thinking_scroll.py`,
`test_chat_persistence.py` — ONLY the pins this task touches; the
restore-path pins are task 03's, the shared-page pins are task 03's). Every
pin this task does NOT break (the tool-line label literals, the phase-117
deboxed CSS, the phase-139/146 suffix pins, the emoji guard) stays green
UNCHANGED.
- Coverage: **>90%** on this task's modified code (the pins).
## Completion Criteria
- [ ] a live turn renders per-run blocks in arrival order (the code paths
land exactly as designed — the unit pins)
- [ ] the clock targets the tracked line reference (the `:last-child` query
is gone — the unit pin)
- [ ] the `done` / failed / pagehide save payloads carry `segments`
(non-empty only) — the unit pins
- [ ] `uv run pytest tests/unit -q` green (every pin this task breaks is
updated; every pin it doesn't touch stays green); `uv run ruff check .
&& uv run pyright` clean
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
`tests/unit/test_thinking_timeline.py`, and the unit pin files updated
in this task
@@ -0,0 +1,82 @@
# Task 03 — the restore path + the shared-page timeline
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
A restored brain record renders its `segments` as the collapsed timeline (the
grouping mirrors the live run structure); a record WITHOUT `segments` — every
pre-phase record — restores byte-identically through the untouched legacy
path; the shared page renders the same timeline from the same record shape.
## Work
1. `frontend/assets/app.js` — `renderStoredMessage` (~L2089):
- AFTER the bubble exists: `if (Array.isArray(m.segments) &&
m.segments.length) { restoreTimeline(wrap, m.segments); } else { THE
EXISTING `m.thinking` BLOCK + THE EXISTING `m.tools` LOOP,
BYTE-IDENTICAL (including the `closeToolCalls(wrap)` call — RENAME it
to `closeAllRuns(wrap)` — task 02 removed `closeToolCalls` — the
restore's folded-at-rest result is the same) }`.
- `restoreTimeline(wrap, segments)` (a new function, the house style —
createElement/textContent, `renderMarkdown` for the thinking text,
`innerHTML` ABSENT for the structure): iterate `segments` in order:
- a `kind: "thinking"` entry (guard: `typeof entry.text === "string"`)
→ its OWN `details.thinking` (created CLOSED — D5;
`<summary>Thinking</summary>` — the phase-17 literal, D3;
`.thinking-text` = `renderMarkdown(entry.text)` — the existing
restore convention, escape-first), inserted before the bubble;
- CONSECUTIVE `kind: "tool"` entries → ONE
`details.tool-calls-disclosure` (created CLOSED — D5;
`summary.tool-calls-summary` + the `div.tool-calls` list
`role=list` `aria-label="Tool calls"` — the phase-117 structure),
each entry's line through `appendToolLine` (task 02's container
signature) with the EXISTING tOffset/tLimit/tPath guards
(`Number.isInteger(x) && x >= 1` / the string guard — byte-identical,
the phase-139/146 restore precedent) + the EXISTING
`appendTruncatedNote(wrap, arg, …)` call for `entry.truncated`
records (the phase-95 marker re-render — moved verbatim inside the
grouping); the summary count `Tool call(s) (N)` set once per
disclosure after its entries;
- an entry with an unknown `kind` (or a malformed shape) → SKIPPED
(never render from storage — textContent only, the house rule;
a guard comment naming the skip).
2. `frontend/assets/shared.js` — `addSegments(wrap, segments)` (new, the
shared-page parity copy of `restoreTimeline` — pure render, always
COLLAPSED — D5): the same grouping, the same line bytes (the four label
literals + the phase-95 truncation note + the phase-139/146 suffixes —
the parity `addToolLines` already carries; mirror it), the same summary
count, the same unknown-`kind` skip. The record-dispatch site (~L456):
`if (Array.isArray(m.segments) && m.segments.length) {
addSegments(wrap, m.segments); } else { THE EXISTING addThinkingBlock +
addToolLines, BYTE-IDENTICAL }`. The `addThinkingBlock` / `addToolLines`
functions themselves are UNCHANGED (the legacy path is intact).
3. `tests/unit/test_thinking_timeline.py` — ADD the pins:
- the `renderStoredMessage` dispatch (segments take precedence; the
legacy path intact when `segments` is absent — pin the
`Array.isArray(m.segments) && m.segments.length` guard);
- `restoreTimeline` body: `document.createElement("details")` +
`className = "thinking"` (NO `open = true` — collapsed, D5) + the plain
`Thinking` literal + the consecutive-tool grouping (one disclosure per
run — pin the grouping loop) + the `appendTruncatedNote` call + the
unknown-`kind` skip; `innerHTML` ABSENT (structure built via
createElement/textContent).
- the `shared.js` `addSegments` body: the same structure pins (collapsed,
the parity) + `innerHTML` ABSENT; the dispatch guard in `shared.js`.
## Testing & Quality
- Unit: `tests/unit/test_thinking_timeline.py` (this task's pins) + UPDATE the
existing source pins THIS task's change breaks (inspect:
`test_chat_persistence.py` — the restore-path pins, `test_shared_page.py` —
the `addToolLines`/dispatch pins; every pin this task does NOT break stays
green UNCHANGED).
- Coverage: **>90%** on this task's modified code (the pins).
## Completion Criteria
- [ ] the `restoreTimeline` + `addSegments` builders land exactly as designed
(the unit pins)
- [ ] the legacy restore / shared paths are byte-unchanged (a `git diff`
scoped check — only the dispatch guard is added)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run
pyright` clean
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
`frontend/assets/shared.js`, `tests/unit/test_thinking_timeline.py`,
and the unit pin files updated in this task
@@ -0,0 +1,115 @@
# Task 04 — the mock interleaved flow + the dedicated E2E suite
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
A deterministic mock trigger that interleaves a DISTINCT reasoning run before
every tool call and before the final answer (the phase-158 flow), and the
dedicated `test_thinking_timeline.py` Playwright suite that pins the
timeline contract (order / fold-at-rest / live-open / restore + record /
shared-page parity / the no-thinking single-run grouping).
## Work
1. `tests/e2e/mock_llm.py`:
- `THINK_TOOLS_TRIGGER = "think out loud and use your tools"` (beside
`THINKING_TRIGGER`, L992) + the docstring entry for the flow (the
four-request stateless shape, the `Step N` markers, the phase-37
answer convention). BEFORE wiring, VERIFY (and note in the docstring):
no existing trigger phrase is a substring of it, and NO existing E2E
question contains it (grep `tests/e2e/*.py` for the phrase — the
convention that keeps the other suites byte-unaffected).
- `_thinking_tools_flow(body) -> tuple[str, ...] | None` (shaped like
`_tool_flow`, L1820 — the same stateless discrimination, the same
`<tools>` + `tools`-parameter gating, reusing `_drill_target` /
`_first_file_line` / the `read`-result header check):
- request 1 (tools offered, no tool results yet):
`thinking = "Step 1: list the documents to find the reference server
config."` → `_tool_call_stream("ls", {}, "call_0", thinking)`;
- request 2 (a `tool`-role top-level source listing — no file lines
yet): `thinking = "Step 2: drill into the first source."` → the
`_drill_target` scoped `ls` (id `call_1`), with thinking;
- request 3 (a `tool`-role folder listing WITH file lines — the
phase-63/70 labeled `source:` / `path:` format): `thinking = "Step 3:
read the file line's document."` → `read` on the JOINED combined
`source/path` (id `call_2`), with thinking;
- request 4 (a `tool`-role read result — content starting with the
agent's `"Document <source/path>:"` header): `thinking = "Step 4:
compose the answer from the read document."` → the phase-37 answer
convention: `Read <source/path>. <first 80 chars of the read
document's content>` (the `_sse_stream` content path — the
`thinking=` param of `_sse_stream` already exists — pass it; the
final answer's thinking streams ahead of the content, exactly like
the `think out loud` answer path).
- `_tool_call_stream(name, arguments, call_id, thinking="")` (L3813):
the optional fourth param — when non-empty, yield
`reasoning_content` chunks FIRST (the 12-char split — the
`_sse_stream` convention), then the EXISTING `tool_calls` frames +
`finish_reason: "tool_calls"` UNCHANGED. Default `""` → every
pre-phase 3-arg caller (the ~10 call sites) streams BYTE-IDENTICAL
(verify with the existing tool-flow E2E suites in the regression
pass). The docstring updated (the param, the phase-158 flow).
- the `chat_completions` streaming dispatch: check
`_thinking_tools_flow(body)` BEFORE the `_tool_flow(body)` dispatch
(L4453 region) — the more-specific-phrase-wins convention (the phrase
contains `use your tools`, so the plain `_tool_flow` must NOT see
it); on a hit, route each flow stage through the same
`StreamingResponse` wrapper the `_tool_flow` stages use.
2. `tests/e2e/test_thinking_timeline.py` (NEW — the six tests, the phase
design's contract):
- fixtures: the conftest `app_server` / `page` / `seeded_kb` (mirror
`test_agent_document_tools.py` — the admin login, the seeded KB so
request 4's read result exists);
- 1. `test_timeline_order_after_completed_turn` — the trigger
question; after the `done` state: the brain wrap's `.msg-body`
children before the bubble are EXACTLY `[details.thinking,
details.tool-calls-disclosure, details.thinking,
details.tool-calls-disclosure, details.thinking,
details.tool-calls-disclosure, details.thinking]` (assert the
tag+class sequence — 7 elements); the four `.thinking-text`s carry
DISTINCT `Step 1`…`Step 4` markers (a substring assert each —
distinctness proves the runs are separate blocks, not one lump); each
disclosure summary is `Tool call (1)`; the answer bubble carries the
`Read ` echo.
- 2. `test_runs_fold_at_rest` — after `done`: NO `.thinking` /
`.tool-calls-disclosure` has the `open` attribute (a `count` assert
on `[open]`); the summaries are visible.
- 3. `test_live_runs_open_while_streaming` — an `expect.poll` predicate
over the mock's pacing window (the tool frames' 0.1 s + the thinking
stream): a `.thinking` or `.tool-calls-disclosure` is `open` while
the answer bubble is still EMPTY (the D5 live-open contract).
- 4. `test_restored_timeline_and_record` — RELOAD (same context): the
restored message renders the same 7-element sequence, all closed; the
localStorage record (read via `page.evaluate` on the `bor.chat.v1`
storage key — the phase-14 key) for that brain turn carries a
`segments` array of EXACTLY 7 entries in the pattern
`thinking, tool, thinking, tool, thinking, tool, thinking` (assert the
`kind` sequence + the `Step N` text on the thinking entries + the
`name` on the tool entries); the `thinking` (full) + `tools` (3
entries) keys are STILL present (D4 — the legacy fallback fields).
- 5. `test_shared_page_timeline` — the phase-51 save + share flow (the
`test_share_chat.py` pattern), a FRESH context on `/shared/<token>`:
the same 7-element sequence renders, all collapsed, the `Step N`
markers visible when expanded (expand ONE — the click on a summary —
then assert), the answer bubble carries the `Read ` echo.
- 6. `test_no_thinking_turn_single_disclosure` — a plain `use your
tools` question (NO thinking trigger): EXACTLY ONE
`.tool-calls-disclosure` (3 `.tool-call` lines, summary `Tool calls
(3)`) and ZERO `.thinking` (the run-grouping contract — no thinking
frames → one tool run — the phase-117 behavior preserved).
## Testing & Quality
- The mock change is exercised by the suite (the trigger flow) + the
existing tool-flow suites stay byte-unaffected (the `thinking=""` default
— verified in the regression pass, task 05).
- E2E: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov`
green **in isolation** (prereq: `podman compose up -d db`), mock-only.
- Coverage: the mock is test code (no `app/` change — the coverage floor is
held by the untouched suite; task 01's schema code is covered there).
## Completion Criteria
- [ ] the mock trigger + flow + the `_tool_call_stream` param land exactly
as designed; the pre-phase callers are byte-unchanged (a `git diff`
scoped check)
- [ ] all six tests pass in isolation
- [ ] `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` green
in isolation; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,85 @@
# Task 05 — the phase-109 suite pin update, the full gate, docs pass, commit
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
Update `test_turn_progress_loader.py` from the phase-109 D15 re-open pin to
the per-run contract (the ONE owner-directed behavior change — recorded in
the phase design), run the full completion gate (unit + integration,
coverage >90%, the phase E2E + the regression E2E suites in isolation,
lint + types), run the documentation & consistency pass, and cut the single
atomic commit.
## Work
1. `tests/e2e/test_turn_progress_loader.py` — the D15 pin → the per-run
contract (the mock's turn-progress flow is `delta → tool → thinking →
delta → thinking → done` — the first thinking run starts AFTER the
first delta):
- the re-open pin (`test_thinking_block_reopens_after_delta_with_visible_loader`
— "the thinking block re-opens after the answer has started … the
scratchpad carries both thinking rounds") becomes the PER-RUN pin:
after the turn, the brain wrap holds TWO `.thinking` blocks (the
`count` assert), EACH carrying its OWN round's text (the flow's two
distinct reasoning texts — assert a marker on each, the way the mock's
two reasoning streams are already distinct); both are FOLDED at rest
(no `[open]`); the visible-during-streaming half of the pin (the
loader visible, the thinking text non-empty and open while its run
streams) is kept — a run is open in its live window (D5 replaces D15's
re-open: a new block instead of a re-opened one — the user sees the
new thinking either way; the D15 symptom — "the frozen chat" — stays
fixed). The module docstring updated (the phase-158 supersession,
owner direction 2026-10-04, recorded).
- the other tests in the file (the loader visibility pins) stay green
UNCHANGED — verify.
2. The full gate (the phase's Testing & Quality section):
- `uv run pytest` — the ENTIRE project suite, 100% green;
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on
`app/`;
- `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` — the
phase E2E, green in isolation (prereq: `podman compose up -d db`);
- the regression E2E suites, each green in isolation
(`-v --no-cov`): `test_thinking_display.py`,
`test_tool_call_compact.py`, `test_turn_progress_loader.py` (the
UPDATED pins), `test_big_read_progress.py` (UNCHANGED — the clock's
visible behavior is identical), `test_agent_document_tools.py`,
`test_share_chat.py`, `test_chat_persistence.py`, `test_smoke.py`;
- `uv run ruff check . && uv run pyright` clean.
- If a regression suite fails on a pin the redesign broke that tasks
02–04 missed: update THAT pin to the per-run contract (the same
supersession logic as item 1 — note it in the commit body), and
re-run the suite in isolation.
3. The documentation & consistency pass (the phase's Documentation &
Consistency section):
- `README.md` + `AGENTS.md` reviewed in full against the project as it
stands after this phase; every inconsistency fixed, **including
pre-existing ones** (the README's chat thinking/tool-call display
description, if any, gains the timeline sentence).
- the `app/schemas.py` docstring voice (task 01) + the
`frontend/assets/app.js` / `shared.js` comment blocks (tasks 02–03)
read in the established phase-citation voice (spot-check).
4. The commit + the phase move:
- `git add -A .agents/ app/schemas.py frontend/ tests/ README.md && git
commit --no-gpg-sign -m "feat(ui): render thinking and tool runs as a
chronological timeline (per-run blocks, segments persistence)"`
(the AGENTS.md rule 8 + A17; the LLM-Generated trailer per the
git-commit skill's convention for this repo's commits — check the
last few commits' bodies for the trailer form and match);
- `mv .agents/phases/todo/158_thinking_timeline
.agents/phases/complete/158_thinking_timeline`.
## Testing & Quality
- The gate above IS this task's testing; no new code.
- Coverage: the >90% floor (the gate).
## Completion Criteria
- [ ] `test_turn_progress_loader.py` updated to the per-run contract +
green in isolation (item 1)
- [ ] the FULL gate green (item 2 — every line)
- [ ] README.md + AGENTS.md accurate and consistent with the whole project
(item 3 — the final pass)
- [ ] `git diff --stat` of the phase's total footprint limited to
`app/schemas.py`, `frontend/assets/app.js`,
`frontend/assets/shared.js`, `tests/`, the docs, and the phase files
(the phase design's scope check)
- [ ] one atomic `--no-gpg-sign` commit; the phase dir moved to
`.agents/phases/complete/`