phase: 158_thinking_timeline
Build and Push Containers / build-and-push-app (push) Successful in 8m38s
Build and Push Containers / build-and-push-db (push) Successful in 32s

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.
This commit is contained in:
2026-10-05 02:52:55 -04:00
parent 3f752fb77f
commit 31bd903a93
49 changed files with 4280 additions and 482 deletions
+134 -3
View File
@@ -247,6 +247,125 @@ function addToolLines(wrap, tools) {
body.insertBefore(disclosure, body.querySelector(".bubble"));
}
/* Phase 158 (task 03, D4/D5): the SHARED page's per-run timeline —
* the parity copy of the chat page's restoreTimeline: the record's
* `segments` array (the per-run order, task 01's ChatMessage field)
* renders each CONTIGUOUS thinking run as its own details.thinking
* block and each CONTIGUOUS run of tool entries as ONE
* details.tool-calls-disclosure ("Tool call(s) (N)" per run), in
* arrival order, ABOVE the answer — a pure render, so everything is
* created CLOSED (D5; the same collapsed convention as
* addThinkingBlock / addToolLines). The structure is
* createElement/textContent; the line bytes mirror addToolLines
* EXACTLY (the four pinned label literals + the phase-70 legacy
* names + the phase-139 readRangeSuffix + the phase-95 truncation
* note — the shared-page parity), and the thinking text flows
* through the SAME escape-first renderMarkdown write addThinkingBlock
* uses (XSS-safe). An entry with an UNKNOWN `kind` (or a malformed
* shape) is SKIPPED — never render from storage (textContent only,
* the house rule). A record WITHOUT `segments` never reaches here —
* the dispatch keeps the legacy addThinkingBlock + addToolLines path
* byte-identical. */
function addSegments(wrap, segments) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
const bubble = body.querySelector(".bubble");
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
for (const entry of segments || []) {
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
// never render from storage (textContent only, the house rule).
if (!entry || typeof entry !== "object") continue;
if (entry.kind === "thinking") {
if (typeof entry.text !== "string") continue; // malformed: skipped
if (toolDisc) toolDisc = null; // the run is sealed — the count is final
const block = document.createElement("details");
block.className = "thinking";
block.open = false; // D5: collapsed (the addThinkingBlock convention)
const summary = document.createElement("summary");
summary.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs)
const textEl = document.createElement("div");
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock)
block.append(summary, textEl);
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
} else if (entry.kind === "tool") {
if (typeof entry.name !== "string") continue; // malformed: skipped
if (!toolDisc) {
toolDisc = document.createElement("details");
toolDisc.className = "tool-calls-disclosure";
toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
toolDisc.appendChild(summary);
const container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
toolDisc.appendChild(container);
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
}
const container = toolDisc.querySelector(".tool-calls");
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
const argument =
typeof entry.argument === "string" && entry.argument
? entry.argument
: null;
// The line bytes MIRROR addToolLines exactly (the shared-page
// parity — phase 70: new names first, legacy names kept; the
// phase-139 range suffix; the phase-95 truncation note).
if ((entry.name === "read" || entry.name === "read_document") && argument) {
line.textContent = "📄 Reading ";
const code = document.createElement("code");
code.textContent = argument; // the path is data, never markup
line.appendChild(code);
const rangeSuffix = readRangeSuffix(entry.offset, entry.limit);
if (rangeSuffix) {
const range = document.createElement("span");
range.className = "read-range";
range.textContent = rangeSuffix; // the range is data, never markup
line.appendChild(range);
}
} else if (
(entry.name === "grep" || entry.name === "search_documents") && argument
) {
line.textContent = "🔎 Searching for ";
const code = document.createElement("code");
code.textContent = argument; // the pattern is data, never markup
line.appendChild(code);
} else if (entry.name === "ls" && argument) {
line.textContent = "🔎 Listing documents in ";
const code = document.createElement("code");
code.textContent = argument; // the source scope is data, never markup
line.appendChild(code);
} else {
line.textContent = "🔎 Listing documents";
}
container.appendChild(line);
// Phase 95: the stored truncation record — the same marker
// addToolLines renders (plain integers, no separators); only
// argument-bearing (Reading) lines can carry it.
if (entry.truncated && argument) {
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + (Number(entry.chars_shown) || 0) + " of " + (Number(entry.chars_total) || 0) + " chars)";
line.appendChild(note);
}
// The per-run count (parity with addToolLines — one disclosure
// per contiguous run, D3) rides every append; the final
// append leaves the final count.
const n = container.children.length;
toolDisc.querySelector("summary").textContent =
`Tool call${n === 1 ? "" : "s"} (${n})`;
}
}
// an entry with an unknown `kind` (or a malformed shape) is
// SKIPPED — never render from storage (textContent only, the
// house rule); a guard comment names the skip.
}
/* Phase 139 (task 01): the shared page's parity copy of app.js's
* readRangeSuffix — the requested line range on the Reading line. The
* saved record now carries the chat page's offset/limit (the toolAcc
@@ -453,10 +572,22 @@ function renderSharedMessage(m) {
return;
}
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
if (typeof m.thinking === "string" && m.thinking) {
addThinkingBlock(wrap, m.thinking);
// Phase 158 (task 03, D4): a record WITH `segments` renders the
// per-run timeline through addSegments (collapsed — the parity
// copy of the chat page's restoreTimeline); the runs carry the
// SAME data as `m.thinking` + `m.tools`, in order, so the legacy
// addThinkingBlock + addToolLines are SKIPPED (rendering both
// would double the runs). A record WITHOUT `segments` — every
// pre-phase record — renders BYTE-IDENTICALLY through the legacy
// path below.
if (Array.isArray(m.segments) && m.segments.length) {
addSegments(wrap, m.segments);
} else {
if (typeof m.thinking === "string" && m.thinking) {
addThinkingBlock(wrap, m.thinking);
}
addToolLines(wrap, m.tools);
}
addToolLines(wrap, m.tools);
if (m.deflected) {
wrap.classList.add("is-deflected");
addMaybeTry(wrap, m.suggestions);