phase: 153_document_viewer_context
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:
@@ -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/`
|
||||
Reference in New Issue
Block a user