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.
223 lines
9.8 KiB
Python
223 lines
9.8 KiB
Python
"""Unit: the phase-95 (task 02) truncation-marker frontend contract.
|
|
|
|
No new Python app logic exists for the marker itself — the behavior
|
|
lives in ``frontend/assets/app.js`` (the ``tool_result`` SSE branch +
|
|
the ``toolAcc`` stamp + the phase-14 restore marker),
|
|
``frontend/assets/shared.js`` (the shared page's local tool-line
|
|
render) and ``frontend/assets/styles.css`` (the theme-neutral
|
|
``.truncated-note`` rule). Like the other frontend-adjacent unit files
|
|
(``test_frontend_tool_states.py`` is the phase-37 precedent), this
|
|
module pins the JS/CSS markers the story depends on, so a silent
|
|
regression in the handler, the pinned marker copy, the persistence
|
|
stamp, or the styling is caught without a browser. The E2E gate is the
|
|
phase's story suite (task 03).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
SHARED_JS = FRONTEND / "assets" / "shared.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
#: The pinned marker copy (the unit + E2E assertion target): plain
|
|
#: integers, no thousands separators, the em-dash per the owner's TODO.
|
|
MARKER_TEMPLATE = '" (truncated — showing "'
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _shared_js() -> str:
|
|
return SHARED_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _css() -> str:
|
|
return STYLES_CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_tool_result_branch_is_a_first_class_turn_branch() -> None:
|
|
"""The turn handler must branch on `tool_result` frames (the
|
|
seventh, optional event type — the A15 extension): the branch sits
|
|
after `done` (a frame for a settled turn is a tolerated late
|
|
append, never a crash) and before `error`, settles the tool-line
|
|
clock like every other frame, and must never flip the state
|
|
machine (the phase-37/48 lifecycle is untouched)."""
|
|
js = _js()
|
|
done_idx = js.find('ev.type === "done"')
|
|
tool_result_idx = js.find('ev.type === "tool_result"')
|
|
error_idx = js.find('ev.type === "error"')
|
|
assert -1 < done_idx < tool_result_idx < error_idx, (
|
|
"the turn handler must branch on tool_result frames (after done,"
|
|
" before error)"
|
|
)
|
|
branch = js[tool_result_idx:error_idx]
|
|
assert "settleToolLine()" in branch, (
|
|
"a frame arrived — the latest tool line's elapsed clock settles"
|
|
)
|
|
assert "appendTruncatedNote(wrap, argument, shown, total)" in branch
|
|
# No UI-state transition: the state machine never knows the marker.
|
|
assert "setUiState" not in branch
|
|
assert "aborted" not in branch, (
|
|
"the aborted guard lives at the dispatch top, not per branch"
|
|
)
|
|
|
|
|
|
def test_truncation_marker_copy_is_pinned() -> None:
|
|
"""The marker text is pinned EXACTLY: \" (truncated — showing N of
|
|
M chars)\" — plain integers (no separators), a leading space (it
|
|
follows the line's <code> child), the em-dash per the TODO copy.
|
|
Both app.js's helper and shared.js's local render carry the same
|
|
template (pixel-identical marker — the phase-50 restore
|
|
contract)."""
|
|
js = _js()
|
|
fn = js.find("function appendTruncatedNote")
|
|
assert fn != -1, "appendTruncatedNote must exist in app.js"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert MARKER_TEMPLATE in body
|
|
assert '+ charsShown + " of " + charsTotal + " chars)"' in body
|
|
shared = _shared_js()
|
|
fn = shared.find("function addToolLines")
|
|
assert fn != -1
|
|
sbody = shared[fn : shared.find("\n}\n", fn)]
|
|
assert MARKER_TEMPLATE in sbody
|
|
assert 'chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)"' in sbody
|
|
|
|
|
|
def test_append_truncated_note_matches_newest_line_and_uses_text_content() -> None:
|
|
"""appendTruncatedNote: the target is the NEWEST `.tool-call` line
|
|
whose `<code>` child carries the frame's argument (the raw
|
|
source/path — the same string the matching `tool` frame put in the
|
|
line), scanned newest-first; the marker is a SPAN sibling appended
|
|
to the existing line (createElement + className + textContent only
|
|
— the house "this file never builds HTML" rule, no innerHTML); a
|
|
frame whose line is gone (New Chat mid-turn) is a silent no-op."""
|
|
js = _js()
|
|
fn = js.find("function appendTruncatedNote")
|
|
assert fn != -1
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert 'wrap?.querySelector?.(".tool-calls")' in body, (
|
|
"a wrap without tool lines is a silent no-op"
|
|
)
|
|
assert 'querySelectorAll(".tool-call")' in body
|
|
assert "for (let i = lines.length - 1; i >= 0; i -= 1)" in body, (
|
|
"newest line first — the last call for that argument"
|
|
)
|
|
assert 'code.textContent !== argument' in body, (
|
|
"the match key is the code child's argument (the raw source/path)"
|
|
)
|
|
assert 'note.className = "truncated-note"' in body
|
|
assert 'lines[i].appendChild(note)' in body, (
|
|
"DOM append to the EXISTING line — no new line, no re-render"
|
|
)
|
|
assert "innerHTML" not in body, (
|
|
"no HTML injection surface — createElement + textContent only"
|
|
)
|
|
# The no-op guard: a wrap without tool lines returns silently; a scan
|
|
# that finds no matching line falls off the loop without appending.
|
|
assert "if (!calls) return;" in body
|
|
|
|
|
|
def test_tool_result_branch_stamps_the_newest_toolacc_entry() -> None:
|
|
"""The `tool_result` frame stamps the matching toolAcc entry (same
|
|
argument, NEWEST — the reverse scan mirrors the line match) with
|
|
`truncated` + `chars_shown` + `chars_total` — the `done` save point
|
|
below then carries it with zero other change (the persistence
|
|
shape rides the existing `tools` key). The stamp is gated on the
|
|
frame's argument + truncated truth (a malformed frame is a silent
|
|
no-op for persistence)."""
|
|
js = _js()
|
|
tool_result_idx = js.find('ev.type === "tool_result"')
|
|
error_idx = js.find('ev.type === "error"')
|
|
assert -1 < tool_result_idx < error_idx
|
|
branch = js[tool_result_idx:error_idx]
|
|
assert "t.truncated = true" in branch
|
|
assert "t.chars_shown = shown" in branch
|
|
assert "t.chars_total = total" in branch
|
|
assert "for (let i = toolAcc.length - 1; i >= 0; i -= 1)" in branch, (
|
|
"the NEWEST matching entry (the reverse scan, same rule as the"
|
|
" line match) gets stamped — then break"
|
|
)
|
|
assert "t.argument === argument" in branch
|
|
assert "break" in branch
|
|
# The guard: the stamp only runs for a real truncation with an argument.
|
|
assert "if (argument && ev.truncated)" in branch
|
|
# The saved payload rides the existing save point — no second tools
|
|
# key, no new record field.
|
|
done_block = js[js.find('ev.type === "done"') : tool_result_idx]
|
|
assert "tools: toolAcc.length ? toolAcc : undefined" in done_block
|
|
|
|
|
|
def test_restore_path_renders_the_stored_marker() -> None:
|
|
"""The phase-14 LOCAL restore path (renderStoredMessage): a stored
|
|
tool record with `truncated` + the counts re-renders the SAME
|
|
marker next to its Reading line, right after the line is restored
|
|
(the same order as the live frames). A pre-phase-95 record (no
|
|
field — `t.truncated` falsy) renders unchanged (no marker, no
|
|
migration)."""
|
|
js = _js()
|
|
fn = js.find("function renderStoredMessage")
|
|
assert fn != -1
|
|
end = js.find("function restoreConversation")
|
|
body = js[fn:end]
|
|
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
|
assert "t.truncated && arg" in body, (
|
|
"only an argument-bearing (Reading) record with the flag renders"
|
|
" the marker"
|
|
)
|
|
assert (
|
|
"appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0,"
|
|
" Number(t.chars_total) || 0)" in body
|
|
)
|
|
# The marker append sits INSIDE the tools loop, after the line append.
|
|
line_idx = body.find("appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)")
|
|
note_idx = body.find("appendTruncatedNote(wrap, arg,")
|
|
assert -1 < line_idx < note_idx
|
|
|
|
|
|
def test_shared_page_renders_the_stored_marker() -> None:
|
|
"""shared.js's local tool-line render (addToolLines): the same
|
|
marker from the stored record — a span sibling appended to the
|
|
line, after the line's existing children (the template text + the
|
|
<code> argument), textContent only (no HTML from storage, ever). A
|
|
record saved before phase 95 renders exactly as before."""
|
|
js = _shared_js()
|
|
fn = js.find("function addToolLines")
|
|
assert fn != -1
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert "t.truncated && argument" in body
|
|
assert 'note.className = "truncated-note"' in body
|
|
assert 'line.appendChild(note)' in body
|
|
assert MARKER_TEMPLATE in body
|
|
# Still textContent-only: the marker adds no innerHTML surface, and
|
|
# the three argument-bearing lines keep their textContent treatment.
|
|
assert body.count("code.textContent = argument") == 3
|
|
assert "innerHTML" not in body
|
|
|
|
|
|
def test_truncated_note_style_is_theme_neutral() -> None:
|
|
"""styles.css: `.tool-call .truncated-note` exists and colors ONLY
|
|
through a `var(--…)` token (the phase-92 zero-literal invariant —
|
|
no new hue; under phase 93's monochrome theme it grays
|
|
automatically, and the marker stays TEXT, never color alone, B5).
|
|
--ink-soft is the AA-safe soft-ink the status suffixes already
|
|
borrow."""
|
|
css = _css()
|
|
m = re.search(r"\.tool-call \.truncated-note \{([^}]*)\}", css)
|
|
assert m, "the .tool-call .truncated-note rule must exist"
|
|
rule = m.group(1)
|
|
assert "var(--ink-soft)" in rule
|
|
assert "color: var(--ink-soft)" in rule
|
|
# Theme-neutral: the whole rule is a single var() color — no hex,
|
|
# no rgb(), no other property.
|
|
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgb\(", rule)
|
|
|
|
|
|
def test_no_cdn_added() -> None:
|
|
"""AGENTS.md rule 6: the marker adds no external script/link."""
|
|
index = (FRONTEND / "index.html").read_text(encoding="utf-8")
|
|
assert 'src="http' not in index and 'href="http' not in index
|