phase: 158_thinking_timeline
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:
+134
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user