"""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 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 `` 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(wrap, t.name, arg)" 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(wrap, t.name, arg)") 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 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