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.
424 lines
18 KiB
Python
424 lines
18 KiB
Python
"""Unit: the phase-130 (task 02, TODO L6) chat actions cluster.
|
|
|
|
The three answer actions (Tune / Retry / Save-as-doc) and the
|
|
answer-status notes (Stopped / Failed) stop joining whatever the first
|
|
``.msg-meta`` row happens to be (usually the source chips row). They
|
|
get-or-create a dedicated ``.msg-meta.msg-actions`` row — the bubble's
|
|
LAST row, right-aligned by CSS (task 03) — so chips and images can
|
|
never push the buttons around.
|
|
|
|
Pins (the house source-pin pattern — no browser):
|
|
|
|
* ``getActionsRow(wrap)`` — the shared get-or-create helper: scoped to
|
|
the ``msg-actions`` class so it NEVER grabs the chips row (or any
|
|
other meta row), ``role=list`` + ``aria-label="Answer actions"``,
|
|
null without a ``.msg-body``;
|
|
* all FIVE appenders/notes route through ``getActionsRow(wrap)`` —
|
|
none of them reuses the first ``.msg-meta`` row anymore;
|
|
* ``markLastRetryable`` re-appends the Retry button into the actions
|
|
row and the phase-59 save-button re-append hack is GONE (the row's
|
|
row-level right-align replaces the per-button auto margins);
|
|
* the call-order audit (task 02 item 5): at every completed-bubble
|
|
render site the order is answer figure → sources → related → the
|
|
action appenders, so ``.msg-actions`` is the bubble's LAST row, and
|
|
the done branch's insertion order inside the row is Tune → Retry →
|
|
Save-as-doc.
|
|
|
|
The browser-level assertions (the right-aligned cluster, the row
|
|
order in the rendered DOM, the restore parity) are E2E-gated by
|
|
tests/e2e/test_chat_answer_clusters.py (task 04).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _fn_body(js: str, header: str) -> str:
|
|
"""The full source of the function whose header is *header* —
|
|
brace-matched from the first ``{`` OUTSIDE the parameter list
|
|
(paren depth 0, the house convention), so destructured parameters
|
|
are skipped (the pinned functions carry no string literal with a
|
|
stray brace)."""
|
|
start = js.index(header)
|
|
i = start
|
|
paren = 0
|
|
while i < len(js):
|
|
c = js[i]
|
|
if c == "(":
|
|
paren += 1
|
|
elif c == ")":
|
|
paren -= 1
|
|
elif c == "{" and paren == 0:
|
|
break
|
|
i += 1
|
|
depth = 0
|
|
for j in range(i, len(js)):
|
|
c = js[j]
|
|
if c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return js[start : j + 1]
|
|
raise AssertionError(f"unbalanced braces in {header!r}")
|
|
|
|
|
|
def _done_branch(js: str) -> str:
|
|
"""The SSE ``done`` branch — from the branch test to the next
|
|
``else if`` (flat block, the slice is exact)."""
|
|
start = js.index('ev.type === "done"')
|
|
return js[start : js.index('ev.type === "tool_result"', start)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# getActionsRow — the shared get-or-create helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_actions_row_gets_or_creates_the_dedicated_row() -> None:
|
|
"""``getActionsRow(wrap)`` find-or-creates the bubble's
|
|
``.msg-meta.msg-actions`` row: the query is scoped to the
|
|
``msg-actions`` class (NEVER the bare first ``.msg-meta`` — that
|
|
would grab the chips row), the created row is ``role=list`` +
|
|
``aria-label="Answer actions"`` (the ARIA convention of the other
|
|
meta rows), and it lands in ``.msg-body``. A missing ``.msg-body``
|
|
(the defensive pre-phase convention) returns null — the appenders
|
|
no-op."""
|
|
js = _js()
|
|
body = _fn_body(js, "function getActionsRow(wrap) {")
|
|
assert 'body.querySelector(".msg-meta.msg-actions")' in body, (
|
|
"the reuse query is scoped to the msg-actions class — the "
|
|
"helper must NEVER grab the chips row (or any other meta row)"
|
|
)
|
|
assert "document.querySelector" not in body and "body.querySelector(\".msg-meta\")" not in body
|
|
assert 'row.className = "msg-meta msg-actions";' in body
|
|
assert 'row.setAttribute("role", "list");' in body, (
|
|
"the row is an accessible list (the house meta-row convention)"
|
|
)
|
|
assert 'row.setAttribute("aria-label", "Answer actions");' in body, (
|
|
"the row's accessible name (WCAG — the row is never unnamed)"
|
|
)
|
|
assert "body.appendChild(row)" in body, "a new row lands in .msg-body"
|
|
assert 'wrap?.querySelector?.(".msg-body")' in body, (
|
|
"defensive .msg-body lookup (the stopped-note convention)"
|
|
)
|
|
assert "if (!body) return null" in body, "no .msg-body → null (appenders no-op)"
|
|
assert "return row" in body
|
|
|
|
|
|
def test_get_actions_row_is_the_shared_helper() -> None:
|
|
"""EXACTLY ONE getActionsRow (no per-appender copies of the
|
|
get-or-create logic), and the created row's class string appears
|
|
nowhere else in the file (the appenders only ever LOOK it up —
|
|
they never build a second actions row)."""
|
|
js = _js()
|
|
assert js.count("function getActionsRow(wrap) {") == 1, (
|
|
"exactly ONE getActionsRow — the shared helper"
|
|
)
|
|
assert js.count('row.className = "msg-meta msg-actions";') == 1, (
|
|
"the row is created in exactly ONE place — the helper; the "
|
|
"appenders never build a row themselves"
|
|
)
|
|
assert js.count('body.querySelector(".msg-meta.msg-actions")') == 1, (
|
|
"the reuse query lives in exactly ONE place — the helper"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the five appenders/notes — all through getActionsRow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
#: (function header, the one-per-bubble guard's class, the child class)
|
|
APPENDERS = [
|
|
("function appendTuneButton(wrap) {", ".tune-btn", "tune-btn"),
|
|
("function appendRetryButton(wrap) {", ".retry-btn", "retry-btn"),
|
|
(
|
|
"function appendSaveAsDocButton(wrap, markdown) {",
|
|
".save-as-doc-btn",
|
|
"save-as-doc-btn",
|
|
),
|
|
("function appendStoppedNote(wrap) {", ".stopped-note", "stopped-note"),
|
|
("function appendFailedNote(wrap, detail) {", ".failed-note", "failed-note"),
|
|
]
|
|
|
|
|
|
def test_all_action_appenders_join_the_actions_row() -> None:
|
|
"""All FIVE appenders/notes (Tune, Retry, Save-as-doc, Stopped,
|
|
Failed) route through ``getActionsRow(wrap)`` and no-op on its
|
|
null (no ``.msg-body``) — none of them reuses the FIRST
|
|
``.msg-meta`` row anymore (the pre-phase-130 soup-div behavior is
|
|
gone from every appender)."""
|
|
js = _js()
|
|
for header, guard, child in APPENDERS:
|
|
body = _fn_body(js, header)
|
|
assert "getActionsRow(wrap)" in body, f"{header} must use getActionsRow"
|
|
assert 'querySelector(".msg-meta")' not in body, (
|
|
f"{header} must not query the bare first .msg-meta row"
|
|
)
|
|
assert "if (!meta) return" in body, (
|
|
f"{header} must no-op when getActionsRow returns null"
|
|
)
|
|
assert f'querySelector("{guard}")' in body, (
|
|
f"{header} keeps its one-per-bubble guard"
|
|
)
|
|
assert f'"{child}"' in body, f"{header} keeps its child class"
|
|
|
|
|
|
def test_action_appenders_keep_the_listitem_aria() -> None:
|
|
"""ARIA is kept (the existing convention): the role=list check
|
|
stays on every appender and the child joins the row as a
|
|
listitem (the row is ALWAYS role=list — getActionsRow sets it —
|
|
so the check always applies)."""
|
|
js = _js()
|
|
for header, _, _ in APPENDERS:
|
|
body = _fn_body(js, header)
|
|
assert 'meta.getAttribute("role") === "list"' in body, (
|
|
f"{header} keeps the role=list check"
|
|
)
|
|
assert 'setAttribute("role", "listitem")' in body, (
|
|
f"{header} keeps the listitem join"
|
|
)
|
|
|
|
|
|
def test_tune_and_save_gates_are_unchanged() -> None:
|
|
"""The gates are untouched (no behavior change in completed work):
|
|
Tune is admin-gated (phase 16) and Save-as-doc is admin-gated AND
|
|
docs-repo-gated (phase 59) — both BEFORE any row work. Retry is
|
|
NOT admin-gated (owner-locked: every visitor)."""
|
|
js = _js()
|
|
tune = _fn_body(js, "function appendTuneButton(wrap) {")
|
|
assert 'if (!isAdmin) return' in tune
|
|
save = _fn_body(js, "function appendSaveAsDocButton(wrap, markdown) {")
|
|
assert "if (!isAdmin || !docsRepoConfigured) return" in save
|
|
retry = _fn_body(js, "function appendRetryButton(wrap) {")
|
|
assert "isAdmin" not in retry, "Retry stays NOT admin-gated"
|
|
|
|
|
|
def test_failed_note_detail_is_textcontent_only() -> None:
|
|
"""The ``failed-detail`` contract survives (task 02 item 3): the
|
|
error detail goes through ``textContent`` — no HTML from error
|
|
strings, ever."""
|
|
js = _js()
|
|
body = _fn_body(js, "function appendFailedNote(wrap, detail) {")
|
|
assert 'd.className = "failed-detail"' in body
|
|
assert "d.textContent = detail" in body, (
|
|
"the error string is NEVER innerHTML"
|
|
)
|
|
# The icon constant is the ONLY innerHTML (the static aria-hidden
|
|
# SVG) — the detail path never builds HTML (comments stripped —
|
|
# the "NEVER innerHTML" remark is not code).
|
|
code = "\n".join(
|
|
line for line in body.splitlines() if "//" not in line
|
|
)
|
|
assert code.count("innerHTML") == 1, (
|
|
"only the static FAILED_ICON innerHTML remains in the note"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# markLastRetryable — the rework (task 02 item 4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_mark_last_retryable_moves_retry_into_the_actions_row() -> None:
|
|
"""``markLastRetryable`` keeps the phase-49 remove-then-append
|
|
shape (remove every rendered .retry-btn FIRST, then re-append on
|
|
the last brain bubble — only when its preceding user record
|
|
exists), but the re-append now lands in the bubble's .msg-actions
|
|
row (via appendRetryButton → getActionsRow). The phase-59
|
|
save-button re-append hack (the ``saveDocBtn`` block) is DELETED:
|
|
the row's row-level right-align (task 03) right-aligns the whole
|
|
cluster, and the DOM order inside the row is the insertion order.
|
|
The 'auto margins split the free space' rationale is gone with it."""
|
|
js = _js()
|
|
body = _fn_body(js, "function markLastRetryable() {")
|
|
assert 'querySelectorAll(".retry-btn")' in body
|
|
assert ".remove()" in body
|
|
assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), (
|
|
"the existing buttons are removed before the new one is appended"
|
|
)
|
|
assert "appendRetryButton(lastBrainWrap)" in body, (
|
|
"the re-append targets the last brain wrap (via getActionsRow)"
|
|
)
|
|
assert 'who !== "user"' in body, "the preceding-user-record guard stays"
|
|
assert "saveDocBtn" not in body, (
|
|
"the phase-59 save-button re-append hack is deleted"
|
|
)
|
|
assert "parentElement" not in body, (
|
|
"no DOM re-parenting of the save button anymore"
|
|
)
|
|
assert "auto margins" not in body, (
|
|
"the 'auto margins split the free space' rationale is gone"
|
|
)
|
|
# The hack had no other home in the file.
|
|
assert "saveDocBtn" not in js
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the call-order audit (task 02 item 5) — .msg-actions is the LAST row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_done_branch_appends_actions_last_in_insertion_order() -> None:
|
|
"""The live ``done`` branch (a completed brain bubble): the row
|
|
order is the DOM order — the answer figure, then the source
|
|
clusters (appendSources), then the related row, then the action
|
|
appenders — so the .msg-actions row is the bubble's LAST row.
|
|
Inside the row the insertion order is Tune → Retry (the
|
|
markLastRetryable re-append) → Save-as-doc."""
|
|
js = _js()
|
|
branch = _done_branch(js)
|
|
for marker in (
|
|
"appendSources(wrap, ev.sources);",
|
|
"appendRelated(wrap, ev.related);",
|
|
"appendTuneButton(wrap);",
|
|
"markLastRetryable();",
|
|
'appendSaveAsDocButton(wrap, finalText || acc || "…");',
|
|
):
|
|
assert marker in branch, f"the done branch must keep {marker!r}"
|
|
order = [
|
|
branch.index("appendSources(wrap, ev.sources);"),
|
|
branch.index("appendRelated(wrap, ev.related);"),
|
|
branch.index("appendTuneButton(wrap);"),
|
|
branch.index("markLastRetryable();"),
|
|
branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'),
|
|
]
|
|
assert order == sorted(order), (
|
|
"done branch: sources → related → Tune → Retry → Save-as-doc "
|
|
"(the .msg-actions row is the bubble's LAST row — phase 130 "
|
|
"task 02; the row's inner order is the insertion order)"
|
|
)
|
|
# lastBrainWrap is set before the Retry re-append reads it.
|
|
assert branch.index("lastBrainWrap = wrap") < branch.index(
|
|
"markLastRetryable();"
|
|
)
|
|
|
|
|
|
def test_zero_frame_fallback_appends_actions_last() -> None:
|
|
"""The zero-frame-but-completed fallback bubble (the stream
|
|
settled with no events): a failed turn — Tune + the Failed note +
|
|
Retry (markLastRetryable) + Save-as-doc, all in the bubble's
|
|
.msg-actions row (the only row it has), the note BEFORE the Retry
|
|
re-append (the 'note + Retry' reading), the save button LAST in
|
|
the row (the phase-59 parity with the done path)."""
|
|
js = _js()
|
|
zero = js.index("if (!aborted && !wrap) {")
|
|
block = js[zero : js.index("} catch (err) {", zero)]
|
|
for marker in (
|
|
"appendTuneButton(fwrap);",
|
|
"appendFailedNote(fwrap, nothing);",
|
|
"markLastRetryable();",
|
|
"appendSaveAsDocButton(fwrap, fallback);",
|
|
):
|
|
assert marker in block, f"the fallback block must keep {marker!r}"
|
|
order = [
|
|
block.index("appendTuneButton(fwrap);"),
|
|
block.index("appendFailedNote(fwrap, nothing);"),
|
|
block.index("markLastRetryable();"),
|
|
block.index("appendSaveAsDocButton(fwrap, fallback);"),
|
|
]
|
|
assert order == sorted(order), (
|
|
"fallback: Tune → Failed note → Retry → Save-as-doc — the "
|
|
"actions row holds the whole cluster (phase 130 task 02)"
|
|
)
|
|
|
|
|
|
def test_stop_finalize_appends_actions_last() -> None:
|
|
"""The stop finalize (a stopped partial — the prime retry
|
|
candidate): Tune + the Stopped note + Retry (markLastRetryable),
|
|
all in the actions row — the stop path renders no sources or
|
|
related rows, so the actions row IS the bubble's only (and last)
|
|
row. The order is Tune → note → Retry (the 'note + Retry'
|
|
reading), unchanged from pre-phase (no save button — a stopped
|
|
partial is a note, not an answer)."""
|
|
js = _js()
|
|
catch = js.index("} catch (err) {")
|
|
stop = js.index('stoppedByUser || err?.name === "AbortError"', catch)
|
|
branch = js[stop : js.index("} else {", stop)]
|
|
for marker in (
|
|
"appendTuneButton(wrap);",
|
|
"appendStoppedNote(wrap);",
|
|
"markLastRetryable();",
|
|
):
|
|
assert marker in branch, f"the stop branch must keep {marker!r}"
|
|
order = [
|
|
branch.index("appendTuneButton(wrap);"),
|
|
branch.index("appendStoppedNote(wrap);"),
|
|
branch.index("markLastRetryable();"),
|
|
]
|
|
assert order == sorted(order), (
|
|
"stop: Tune → Stopped note → Retry (phase 130 task 02 — the "
|
|
"note + Retry in the actions row)"
|
|
)
|
|
assert "appendSaveAsDocButton" not in branch, (
|
|
"the stopped partial (note, not answer) still gets no save button"
|
|
)
|
|
|
|
|
|
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
|
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
|
|
note is appended and the funnel ENDS with markLastRetryable — the
|
|
note + Retry in the failed bubble's actions row (the partial
|
|
keeps its streamed text; the zero-frame shape gets a plain
|
|
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
|
|
js = _js()
|
|
header = (
|
|
"function finalizeFailedTurn(\n"
|
|
" detail,\n"
|
|
" { acc, thinking, tools, segments, wrap, leavePartialIndex }\n"
|
|
") {"
|
|
)
|
|
body = _fn_body(js, header)
|
|
assert body.count("appendFailedNote(") == 2, (
|
|
"both funnel shapes add the in-bubble error note"
|
|
)
|
|
lines = [line for line in body.splitlines() if line.strip()]
|
|
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
|
|
"markLastRetryable();"
|
|
), (
|
|
"the funnel still ends with the markLastRetryable() call — the "
|
|
"Retry button lands on the failed bubble's actions row"
|
|
)
|
|
|
|
|
|
def test_restore_path_appends_actions_last() -> None:
|
|
"""The phase-14 restore path (renderStoredMessage): the answer
|
|
figure → sources → related → the action appenders (Tune /
|
|
Save-as-doc / the Stopped + Failed notes) — so the .msg-actions
|
|
row a restored bubble gets is its LAST row too, identical to the
|
|
live done path. The restore loop's after-the-loop
|
|
markLastRetryable() then lands the Retry button in that same row
|
|
(on the LAST restored brain bubble)."""
|
|
js = _js()
|
|
body = _fn_body(js, "function renderStoredMessage(m) {")
|
|
for marker in (
|
|
"appendSources(wrap, m.sources);",
|
|
"appendRelated(wrap, m.related);",
|
|
"appendTuneButton(wrap)",
|
|
"appendSaveAsDocButton(wrap, m.text);",
|
|
"appendStoppedNote(wrap);",
|
|
"appendFailedNote(wrap, m.error);",
|
|
"lastBrainWrap = wrap",
|
|
):
|
|
assert marker in body, f"the restore path must keep {marker!r}"
|
|
order = [
|
|
body.index("appendSources(wrap, m.sources);"),
|
|
body.index("appendRelated(wrap, m.related);"),
|
|
body.index("appendTuneButton(wrap)"),
|
|
body.index("appendSaveAsDocButton(wrap, m.text);"),
|
|
body.index("lastBrainWrap = wrap"),
|
|
]
|
|
assert order == sorted(order), (
|
|
"restore: sources → related → the action appenders → "
|
|
"lastBrainWrap (the after-the-loop markLastRetryable lands the "
|
|
"Retry in the same actions row — phase 130 task 02)"
|
|
)
|