All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
28 KiB
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
Thinkinglabel 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-117Tool call(s) (N)— now per run. - D4 — Additive persistence. The brain record gains an OPTIONAL
segmentsarray;thinking(full concatenated text — feeds the history wire + the per-turn log) andtools(all call records) are KEPT (old-client graceful degradation; the phase-113 lesson applied up front).segmentsis omitted when absent (theundefined-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
segmentsfield onChatMessage.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-Nonein 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
thinkingentry per RUN (the run's accumulated text — the same text that streams live into that run's block). Onetoolentry per TOOL CALL (the exacttoolAccrecord shape — the phase-37{name, argument}plus the phase-139offset/limitand phase-146pathadditive fields, and the phase-95truncated/chars_shown/chars_totalstamp 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 nosegmentskey at all. thinking(the full concatenation, for the history wire + the log) andtools(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 EXACTToolCallfield 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-referencesToolCall).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 thetoolscap).- The EXISTING wrap serializer (
_drop_image_when_absent) gains the rule:segments is None → pop "segments"(ABSENT, nevernull— the phase-50 contract; a record WITH segments keeps them).HistoryTurnand thehistory_to_messagesmapper are UNTOUCHED (segments never travel the wire — the concatenatedthinkingtravels exactly as today).
frontend/assets/app.js — the live turn (task 02)
- Turn state:
segRuns = [](module scope, reset inrunTurnbesidethinkingAcc/toolAcc— an array of run objects{ kind, text, records }),currentRun = null(turn-local). - Insertion anchor (D2): every run element is inserted
beforebody.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 ?? .bubbleanchor is REPLACED by this. thinkingframe (per frame):settleToolLine()(unchanged);thinkingAcc += ev.text+sawThinking+clearTurnTimeout()+if (!wrap) wrap = addMessage("brain","")+removeTyping()(all unchanged). Then the run logic: ifcurrentRun?.kind === "thinking"→ extend it (re-renderrenderMarkdown(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 → closecurrentRun(if any,details.open = false), createdetails.thinking(open = true,<summary>Thinking</summary>+.thinking-text, inserted before the bubble), push{kind:"thinking",text:"", records:[]}tosegRuns,currentRun= it, extend.toolframe (per frame): the existing guards stay byte-identical (the phase-139frameOffset/frameLimit, the phase-146framePathwell-formedness;toolAcc.push(toolRecord);clearTurnTimeout(); thetoolStatuslabel logic; the phase-70 legacy-name mapping). Then the run logic: ifcurrentRun?.kind === "tool"→ extend that disclosure; else → closecurrentRun(if any), createdetails.tool-calls-disclosure(open = true,summary.tool-calls-summary+ thediv.tool-callslistrole=listaria-label="Tool calls", inserted before the bubble), push{kind:"tool",text:"",records:[]}tosegRuns,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 pinnedline.textContentlabel literals + the phase-139 range suffix + the phase-146 scope span UNCHANGED, the summary count updated per append —Tool call(s) (N)per run), thenarmToolLineClock(wrap, lineEl)(the NEW signature — task 02's clock fix).armToolLineClock(wrap, lineEl)(the phase-87 multi-disclosure fix): store thelineElreference (module scope, reset with the wrap); the interval appends the.tool-elapsedsuffix tolineElinstead 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;settleToolLinestill removes every.tool-elapsedin the wrap + resets the reference).deltahandler:settleToolLine()+acc += ev.text+ thethinking→streamingtransition UNCHANGED; replacecloseThinkingBlock(wrap)+closeToolCalls(wrap)withcloseCurrentRun()(foldscurrentRunif open; idempotent; no-op without a run).- The other settle-pair sites —
finalizeFailedTurn'sif (wrap)block (L2990–L2991), the STOP path (L3477–L3478), and every remainingcloseThinkingBlock(wrap); closeToolCalls(wrap);pair (grep — the redesign removes both functions):closeAllRuns(wrap)(closes EVERY opendetails.thinking+details.tool-calls-disclosurein the wrap — defensive; by construction at most the last run is open, but a late double-close must be a no-op). The rest ofdoneis 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) — thedonesave point (~L3355); thefinalizeFailedTurnparameter + itsif (wrap)saved record (the two call sites — stream-drop ~L3431, network-error ~L3519 — passbuildSegments(segRuns)); the STOP path's save meta (L3487–L3491,stopped: true); the pagehide partial (L3642–L3650 —segRunsis module scope, readable there). All includesegmentsnon-empty only. The zero-frame fallback save needs nothing (zero frames → no runs → no key). Thethinking/toolskeys are UNCHANGED at every site. - The UI state machine,
#send-statuscopy, the 300 s pre-token guard, the stream-drop guard, and thethinking-without-answerfallback are ALL UNCHANGED (§7.4 never-stale intact).BOR_STREAM_THINKING=0needs no client change (no thinking frames arrive → no thinking runs).
frontend/assets/app.js — the restore path (task 03)
renderStoredMessage: after the bubble exists, IFArray.isArray(m.segments) && m.segments.length→restoreTimeline(wrap, m.segments)and SKIP the legacym.thinkingblock + them.toolsloop (segments take precedence — they carry the same data in order); ELSE the legacy path is BYTE-IDENTICAL (a pre-phase record — or aBOR_STREAM_THINKING=0turn saved with the old client — restores exactly as today).restoreTimeline: iteratem.segmentsin order; group CONSECUTIVEkind:"tool"entries into ONEdetails.tool-calls-disclosure(created CLOSED — D5; summaryTool call(s) (N); each line through the SAMEappendToolLinecontainer-signature path with the EXISTING tOffset/tLimit/tPath guards + the existingappendTruncatedNotecall fort.truncatedrecords — the current loop's guards move verbatim inside the grouping); akind:"thinking"entry → its OWNdetails.thinking(created CLOSED;.thinking-text=renderMarkdown(entry.text)— the restore convention stays escape-first); unknownkindvalues 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 ofrestoreTimeline(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; theaddThinkingBlock/addToolLineslegacy path is BYTE-IDENTICAL when the record has nosegments. The record-dispatch site (~L456):if (Array.isArray(m.segments) && m.segments.length) addSegments(wrap, m.segments); else { the existing addThinkingBlock + addToolLines }.
- the phase-139/146 suffixes, already parity-pinned in
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_flowdispatch (the more-specific-phrase-wins convention — the phrase contains boththink out loudanduse 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 thetoolsparameter + 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'slstree format, no file lines yet):"Step 2: drill into the first source."→ the_drill_targetscopedls(idcall_1), with thinking; - request 3 (a
tool-role folder listing with file lines — parse the FIRST file line's labeledsource:/path:fields, the phase-63/70 format):"Step 3: read the file line's document."→readon the JOINED combinedsource/path(idcall_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).
- request 1 (tools offered, no tool results yet):
_tool_call_stream(name, arguments, call_id, thinking="")— the optionalthinkingparam: when non-empty, yieldreasoning_contentchunks FIRST (the 12-char split — the_sse_streamconvention), then the existingtool_callsframes +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_LLMnote 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:
test_timeline_order_after_completed_turn— afterdone: the brain wrap's.msg-bodychildren 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-texts carry DISTINCTStep 1…Step 4markers (distinctness = the runs are separate blocks, not one lump); each disclosure summary isTool call (1); the answer bubble carries theRead <path>echo.test_runs_fold_at_rest— afterdone: NO.thinking/.tool-calls-disclosurehas theopenattribute; the summaries are visible (D5).test_live_runs_open_while_streaming— during the turn (anexpect.pollpredicate over the mock's 0.1 s pacing window): at least one.thinkingor.tool-calls-disclosureisopenwhile the answer bubble is still empty (the D5 live-open contract — the live feedback the owner keeps).test_restored_timeline_and_record— RELOAD (same context): the restored message renders the same 7-element timeline, all closed; the localStoragebor.chat.v1record for that brain turn carries asegmentsarray of EXACTLY 7 entries in the patternthinking, tool, thinking, tool, thinking, tool, thinking(4 thinking runs- 3 tool entries).
test_shared_page_timeline— the phase-51 save + share flow, a FRESH context on/shared/<token>: the same 7-element timeline renders, all collapsed (theshared.jsparity —addSegments).test_no_thinking_turn_single_disclosure— a plainuse your toolsquestion (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
01_schema_segments.md— the discriminatedChatSegmentmodels + theChatMessage.segmentsfield + the serializer omission + the integration contract (accept / caps / byte-identical round-trip / shared read).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.03_restore_and_shared.md— therestoreTimelinedispatch inrenderStoredMessage+ theshared.jsaddSegmentsparity + the unit pins.04_mock_and_e2e.md— the mockTHINK_TOOLS_TRIGGERflow + the dedicatedtest_thinking_timeline.pystory suite, run in isolation.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 NEWdetails.thinkingwhen the run kind changes, insert-before-bubble anchor — D2), the plainThinkingliteral (D3), the per-run disclosure + theTool call(s) (N)per-run count, thearmToolLineClockreference (the:last-childquery is GONE), thesegRuns→buildSegmentsmapping (empty-run skip, the tool-record shape), thedeltacloseCurrentRun+ 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), theshared.jsaddSegmentsparity, andinnerHTMLABSENT 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 segmentkind→ 422 (extra="forbid"intact — a stray<b>-ish key still 422s); a record WITHOUTsegmentsround-trips with the key ABSENT (nevernull— the phase-50 byte-identical contract, pinned); the shared read (/api/chats/<id>/sharedor 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-elapsedbehavior, the phase-139/146 suffix pins, the emoji guard, thetest_tool_call_compact.pypins — 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% onapp/(task 01's schema code is unit/integration-covered; the rest is held by the suite). - Lint/types:
uv run ruff check . && uv run pyrightclean. - 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.mdandAGENTS.mdin 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.mdimmediately 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
segmentsarray (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.pygreen in isolation UNCHANGED. - A pre-phase saved record (no
segments) restores byte-identically (the integration pin + the E2E regression suites). uv run pytestgreen (100% of the project suite);uv run pytest --cov=app --cov-report=term-missing> 90%;tests/e2e/test_thinking_timeline.pygreen in isolation;uv run ruff check . && uv run pyrightclean.git diff --statlimited toapp/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-signcommit (below); the phase dir moved to.agents/phases/complete/.
Commit
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
Thinkinglabel, the additivesegmentsrecord, open-live/ fold-at-rest, and the validatedChatMessage.segmentsfield (theextra="forbid"boundary preserved with phase-83-style caps). - A10 history wire UNCHANGED — segments never travel to the LLM (the
concatenated
thinkingtravels asreasoning_contentexactly 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-signcommit. - The phase-87 clock re-target (the
:last-childquery → 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.pystays green unchanged. No other anchor changed.