"""Unit: the edit-and-resend frontend contract (phase 159, tasks 01–04).
Pins the phase-159 task-01 + task-02 client contract as source
assertions (the house ``test_frontend_*`` style — no browser): the Edit
button joins the bubble's ``.msg-actions`` row (getActionsRow — the
phase-130 house pattern) as a listitem of the ``role=list`` row,
``markLastEditable`` removes every rendered button FIRST and then
targets exactly ONE bubble — the newest question (the phase-49
single-target discipline, one ``who`` over — bailing on the in-flight
guard, a missing user record, and a missing tracked wrap),
``lastUserRecordIndex`` is a newest-first ``conversation`` scan,
``lastUserWrap``'s lifecycle sites, ``openEditor`` renders the
``openTuneForm`` family (a labeled ``textarea[maxlength]`` bound to
``MAX_QUESTION_CHARS`` — the 4000 cap stays single-sourced — prefilled
through ``.value``, never an HTML string; Save/Cancel; a ``role=alert``
rejection line), the Cancel / ``Escape`` restore paths, the
Ctrl/Cmd+Enter submit, the empty-guard rejection (the form stays
open), the in-flight save no-op, and the task-02 SAVE = replace +
truncate + re-ask flow: the guards (in-flight first, then the empty
trim) run before the record edit; the trimmed text replaces the record
in place; ``conversation.splice(editIdx + 1)`` drops the whole tail;
the matching DOM bubbles are removed from ``lastUserWrap.nextSibling``
onward; ``lastBrainWrap = null`` + the phase-73 ``leavePartialIndex``
reset (when it pointed past ``editIdx``) precede the re-ask;
``saveConversation()`` + ``persistConversation()`` persist the
truncated shape before the re-ask; ``markLastRetryable()`` re-targets
(clears — no brain record remains); the editor closes; the double
in-flight check sits on one synchronous stretch (no ``await``); and
the re-ask is exactly ``runTurn(text, { reask: true })`` — no image
(the phase-49/123 A7 text-only rule). The composer stays untouched.
And — the phase's explicit "NOT touched" contract — ``retryLastTurn``
is byte-unchanged, ``runTurn`` is pinned at exactly the state the
phase left it (its sanctioned additions — the task-01
``lastUserWrap`` assignment + the idle-settle ``markLastEditable()``
mark pass, and the task-03 aborted-settle mark pair — included;
no task may restructure it), and — the task-04 regression pins —
``handleSend`` and ``rememberBrainTurn`` are byte-unchanged (the
plain send's pre-work and the brain-record funnel the re-ask's
settle rides are touched by no phase-159 task). Task 03 (the
driver case — a failed or
rejected question stays editable) is pinned here too: the
non-``done`` settle pairings (the ``finalizeFailedTurn`` funnel that
every HTTP-422 / SSE-``error``-frame turn settles through, the
zero-frame fallback, the stop finalize, and the 300s guard's
``aborted`` catch branch — a timed-out turn must not strand the
question), ``markLastEditable``'s guard naming ONLY
``UI_STATE.thinking`` / ``UI_STATE.streaming`` (never the error
state — a failed turn IS idle-or-error at rest), the save guard
mirroring it byte-identically, the failed ``{ who: "brain",
failed: true, error }`` record being DROPPED (not kept) by the
edit-save truncation with ``lastBrainWrap = null`` ahead of the
re-ask, the restore paths (``renderStoredMessage``'s ``lastUserWrap``
tracking + the two restore mark pairs — the ``?chat=`` boot load
confirmed as an editable working copy, no read-only gate), the
truncation-ordering note (the new turn's ``rememberBrainTurn`` sees
the post-splice array), and ``showErrorBanner`` byte-unchanged (the
phase-120 shared pin — Edit is an in-bubble affordance beside
Retry, never a banner control).
"""
from __future__ import annotations
import re
from pathlib import Path
from tests.unit.test_frontend_failed_turn import (
PINNED_RETRY_LAST_TURN,
PINNED_SHOW_ERROR_BANNER,
)
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _find_body_brace(js: str, start: int) -> int:
"""The index of the REAL opening brace of a function at *start* —
the first ``{`` OUTSIDE the parameter list (paren depth 0), so
empty object defaults (``opts = {}``) and destructured parameters
(``{ acc, thinking }``) are skipped (the house convention)."""
i = start
paren = 0
while i < len(js):
c = js[i]
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{" and paren == 0:
return i
i += 1
return -1
def _fn_source(js: str, name: str) -> str:
"""The full source of ``[async] function name(…){…}`` (signature +
body)."""
start = js.index(f"function {name}(")
brace = _find_body_brace(js, start)
depth = 0
i = brace
while True:
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
return js[start : i + 1]
def _fn_body(js: str, name: str) -> str:
"""Just the body of ``[async] function name(…){…}`` (between the
braces)."""
src = _fn_source(js, name)
start = js.index(f"function {name}(")
brace = _find_body_brace(js, start)
rel = brace - start
return src[rel + 1 : len(src) - 1]
# ---------------------------------------------------------------------------
# EDIT_ICON — the pencil glyph (the retry/tune icon family)
# ---------------------------------------------------------------------------
def test_edit_icon_is_an_aria_hidden_stroke_svg() -> None:
"""``EDIT_ICON`` is an inline ``aria-hidden`` decoration in the
house stroke family (``fill="none"`` + ``stroke="currentColor"``
+ ``stroke-width="1.7"`` + the 24×24 viewBox — the RETRY_ICON
parameters): no icon font, no CDN (A11); the CSS themes the
stroke (ink-soft → ink on hover)."""
js = _js()
m = re.search(r"const EDIT_ICON\s*=\s*\n?\s*'([^']*)'", js)
assert m, "const EDIT_ICON must exist as a single-quoted SVG string"
icon = m.group(1)
assert 'aria-hidden="true"' in icon, "the glyph is decoration"
assert 'viewBox="0 0 24 24"' in icon, "the 24×24 viewBox (the family)"
assert 'fill="none"' in icon, "stroke family — never a filled glyph"
assert 'stroke="currentColor"' in icon, "currentColor — the CSS themes it"
assert 'stroke-width="1.7"' in icon, "stroke-width 1.7 (the RETRY_ICON family)"
# ---------------------------------------------------------------------------
# appendEditButton — the Edit button in the actions row
# ---------------------------------------------------------------------------
def test_append_edit_button_joins_the_actions_row() -> None:
"""``appendEditButton(wrap)`` is shaped exactly like
``appendRetryButton``: it routes through ``getActionsRow(wrap)``
(the phase-130 house pattern — the row's LAST row, right-aligned),
bails without a ``.msg-body``, keeps ONE button per bubble (the
duplicate guard), joins the ``role=list`` row as a ``listitem``
(ARIA valid), carries the visible "Edit" label (the accessible
name — no ellipsis clipping at this width), and the click opens
the editor with the toggle button (for the focus return)."""
js = _js()
body = _fn_body(js, "appendEditButton")
assert "getActionsRow(wrap)" in body, (
"the button joins the dedicated .msg-actions row (getActionsRow — "
"phase 130 task 02), never the first .msg-meta row"
)
assert "if (!meta) return;" in body
assert (
'if (meta.querySelector(".edit-btn")) return;' in body
), "one .edit-btn per bubble (the duplicate guard, the retry-btn way)"
assert 'btn.type = "button";' in body
assert 'btn.className = "edit-btn";' in body
assert (
'if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");'
in body
), "the role=list row joins the button as a listitem (ARIA valid)"
assert 'btn.innerHTML = EDIT_ICON + "Edit ";' in body, (
"the visible 'Edit' label carries the accessible name"
)
assert 'btn.addEventListener("click", () => openEditor(wrap, btn));' in body, (
"the click opens the editor with the toggle (the focus return)"
)
assert "meta.appendChild(btn);" in body
def test_edit_button_is_not_admin_gated() -> None:
"""Unlike ``appendTuneButton``, ``appendEditButton`` has NO admin
gate (the phase-49 Retry precedent — chat is public, so every
visitor who can chat can edit the last question; B5's admin-only
views are the header views, not the chat actions)."""
js = _js()
body = _fn_body(js, "appendEditButton")
assert "isAdmin" not in body, (
"the Edit button is NOT admin-gated (the phase-49 Retry precedent)"
)
# ---------------------------------------------------------------------------
# markLastEditable — the single-target discipline (the phase-49 mirror)
# ---------------------------------------------------------------------------
def test_mark_last_editable_removes_first_bails_then_appends_the_single_target() -> None:
"""``markLastEditable`` mirrors ``markLastRetryable``: it removes
EVERY rendered ``.edit-btn`` FIRST (an older bubble's button is
stale the moment a newer question lands), then bails on the
in-flight guard (thinking/streaming — the ``retryLastTurn`` guard),
then bails on a missing user record (``lastUserRecordIndex() ===
-1``), then bails on a missing ``lastUserWrap`` (a stale wrap can
never carry the button), and appends to ``lastUserWrap`` ONLY —
exactly one bubble, the newest question (owner-locked 2026-10-05;
earlier bubbles are never editable — no mid-conversation
branching)."""
js = _js()
body = _fn_body(js, "markLastEditable")
idx_remove = body.index(
'messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove());'
)
idx_inflight = body.index(
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
idx_norecord = body.index("if (lastUserRecordIndex() === -1) return;")
idx_nowrap = body.index("if (!lastUserWrap) return;")
idx_append = body.index("appendEditButton(lastUserWrap);")
assert idx_remove < idx_inflight < idx_norecord < idx_nowrap < idx_append, (
"remove FIRST, then the in-flight guard, the no-record bail, the "
"no-wrap bail, and the single-target append"
)
assert body.count("appendEditButton(") == 1, (
"exactly ONE append — the single-target discipline"
)
def test_last_user_record_index_is_a_newest_first_scan() -> None:
"""``lastUserRecordIndex()`` scans ``conversation`` NEWEST-FIRST
(``i`` from ``length - 1`` down to 0) for ``who === "user"`` and
returns that index — or -1 when the conversation holds no user
record (the markLastRetryable last-brain-index rule, one ``who``
over; the ASSUMPTION: the index comes from the record scan, NOT a
DOM↔record index map — re-deriving keeps the editor correct after
the task-02 truncation)."""
js = _js()
body = _fn_body(js, "lastUserRecordIndex")
assert "for (let i = conversation.length - 1; i >= 0; i -= 1) {" in body, (
"the scan is newest-first (i from length-1 down)"
)
assert 'if (conversation[i].who === "user") return i;' in body
assert "return -1;" in body, "-1 when there is no user record"
# ---------------------------------------------------------------------------
# lastUserWrap — the lifecycle (the exact mirror of lastBrainWrap)
# ---------------------------------------------------------------------------
def test_last_user_wrap_lifecycle_sites() -> None:
"""``lastUserWrap`` is a module-scope variable (exactly one
declaration) with the three sanctioned lifecycle sites: (a)
assigned in ``runTurn``'s fresh-send block — the ``addMessage``
wrap is captured and the wrap assigned BEFORE the persistence save
point (the question record push); (b) assigned in
``renderStoredMessage``'s user branch — the LAST restored user
bubble wins (the mirror of the ``lastBrainWrap`` line below the
brain branch); (c) cleared by ``startNewChat`` (the editable wrap
resets with the conversation; the list reset removes the buttons
with the list, so startNewChat needs no markLastEditable call —
the phase-49 reasoning)."""
js = _js()
assert js.count("let lastUserWrap = null;") == 1, (
"exactly one module-scope declaration"
)
# (a) the runTurn fresh-send assignment (before the save point)
rt = _fn_source(js, "runTurn")
assert "const userWrap = addMessage(" in rt, (
"the fresh send captures the wrap (the addMessage return)"
)
assert "lastUserWrap = userWrap;" in rt
assert rt.index("lastUserWrap = userWrap;") < rt.index("conversation.push("), (
"the wrap is assigned before the persistence save point"
)
# (b) the restore user-branch assignment
rst = _fn_body(js, "renderStoredMessage")
assert "lastUserWrap = wrap;" in rst, (
"the restore user branch assigns the wrap (the mirror of the "
"lastBrainWrap line below the brain branch)"
)
# (c) the startNewChat clear
snc = _fn_body(js, "startNewChat")
assert "lastUserWrap = null;" in snc, (
"startNewChat clears the wrap (the buttons die with the list reset)"
)
# ---------------------------------------------------------------------------
# openEditor — the inline form (the openTuneForm family)
# ---------------------------------------------------------------------------
def test_open_editor_renders_a_labelled_capped_textarea_prefilled_from_the_record() -> None:
"""``openEditor`` renders the ``openTuneForm`` family: one editor
at a time (every existing ``.edit-form`` removed first), a
``form.edit-form`` with ``noValidate``, a ```` paired
with the textarea's ``id`` (a unique ``edit-input-N`` from the
module-level ``editSeq`` — the ``tuneSeq`` precedent), a textarea
with ``rows="3"`` and ``maxlength = MAX_QUESTION_CHARS`` (the 4000
cap stays single-sourced — NEVER a second literal), the prefill
through ``.value`` (NEVER an HTML string — the house
no-injection rule), the composer's placeholder reused when one
exists, a ``.edit-form-actions`` row with a submit ``.edit-save``
("Save") + a ``type=button`` ``.edit-cancel`` ("Cancel"), and a
``role=alert`` ``.edit-error`` line (hidden)."""
js = _js()
body = _fn_body(js, "openEditor")
assert 'document.querySelectorAll(".edit-form").forEach((f) => f.remove());' in body, (
"one editor at a time"
)
assert 'form.className = "edit-form";' in body
assert "form.noValidate = true;" in body
assert "let editSeq = 0;" in js, "module-level editSeq (the tuneSeq precedent)"
assert "editSeq += 1;" in body
assert "const inputId = `edit-input-${editSeq}`;" in body
assert 'label.setAttribute("for", inputId);' in body
assert 'label.textContent = "Edit your question";' in body
assert "ta.id = inputId;" in body, "the label's for pairs the textarea's id"
assert 'ta.setAttribute("rows", "3");' in body
assert "ta.setAttribute(\"maxlength\", String(MAX_QUESTION_CHARS));" in body, (
"the maxlength is bound to MAX_QUESTION_CHARS (single-sourced — the "
">4000 case is unreachable)"
)
assert "ta.value = conversation[editIdx].text;" in body, (
"the prefill goes through .value from the record — never an HTML "
"string (the house no-injection rule)"
)
assert "if (input && input.placeholder) ta.placeholder = input.placeholder;" in body, (
"the composer's placeholder is reused when one exists"
)
assert 'actions.className = "edit-form-actions";' in body
assert "editSaveBtn.type = \"submit\";" in body
assert 'editSaveBtn.className = "edit-save";' in body
assert 'editSaveBtn.textContent = "Save";' in body
assert "cancelBtn.type = \"button\";" in body
assert 'cancelBtn.className = "edit-cancel";' in body
assert 'cancelBtn.textContent = "Cancel";' in body
assert 'status.className = "edit-error";' in body
assert 'status.setAttribute("role", "alert");' in body
assert "status.hidden = true;" in body
def test_open_editor_hides_the_bubble_while_open() -> None:
"""While open the rendered ``.bubble`` gets ``.is-editing`` (the
CSS hides it — the question appears ONCE, never twice); the
editor appends into the bubble's ``.msg-body`` and focuses the
textarea (the openTuneForm convention)."""
js = _js()
body = _fn_body(js, "openEditor")
assert 'const bubble = wrap.querySelector(".bubble");' in body
assert "bubble.classList.add(\"is-editing\");" in body, (
".is-editing lands on the bubble while the form is open"
)
assert "body.appendChild(form);" in body
assert "ta.focus();" in body
# The hide is applied BEFORE the form appends (the question must not
# be visible twice for a frame).
assert body.index("bubble.classList.add(\"is-editing\");") < body.index(
"body.appendChild(form);"
)
def test_open_editor_close_paths_restore_and_refocus() -> None:
"""Cancel and Escape both cancel: ``closeEditor`` removes the
form, removes ``.is-editing`` (restoring the rendered bubble), and
returns focus to the toggle button (the openTuneForm cancel
rule). Cancel is the button's click; Escape is the textarea's
keydown (with preventDefault — the browser default never runs)."""
js = _js()
body = _fn_body(js, "openEditor")
assert "function closeEditor() {" in body
assert "form.remove();" in body
assert 'bubble.classList.remove("is-editing");' in body
assert "toggleBtn.focus();" in body
assert "cancelBtn.addEventListener(\"click\", closeEditor);" in body
# The Escape key (the keydown handler)
assert "e.key === \"Escape\"" in body
idx_esc = body.index("e.key === \"Escape\"")
idx_esc_close = body.index("closeEditor();", idx_esc)
assert idx_esc_close != -1 and idx_esc_close < body.index(
'e.key === "Enter"'
), "Escape cancels (the close before the Ctrl/Cmd+Enter branch)"
assert "e.preventDefault();" in body
def test_open_editor_ctrl_cmd_enter_submits() -> None:
"""Ctrl/Cmd+Enter in the textarea submits the form (
``form.requestSubmit()`` — the standard submit path, so the
handler's guards run exactly once)."""
js = _js()
body = _fn_body(js, "openEditor")
assert "e.key === \"Enter\" && (e.ctrlKey || e.metaKey)" in body
idx = body.index("e.key === \"Enter\" && (e.ctrlKey || e.metaKey)")
assert body.index("form.requestSubmit();", idx) != -1
def test_open_editor_save_bails_when_in_flight() -> None:
"""The submit handler re-checks the in-flight guard BEFORE any
save work (the in-flight discipline): a save can never start a
second turn (task 02's re-ask rides the same guard) — the handler
is a no-op (the form stays open; the user can Cancel or wait for
the settle)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_submit = body.index("form.addEventListener(\"submit\"")
handler = body[idx_submit:]
idx_guard = handler.index(
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
idx_trim = handler.index("const text = ta.value.trim();")
assert 0 <= idx_guard < idx_trim, (
"the in-flight guard precedes the save work (no-op — the form "
"stays open)"
)
# The editor's OPEN does not bail in-flight — the stale-click guard
# is the only open-time bail (a button left from a previous settle
# is still usable; the save guard + the settle re-mark cover the
# rest).
open_head = body[: body.index("const body = wrap.querySelector")]
assert "UI_STATE.thinking" not in open_head, (
"openEditor's open-time bails are the stale-click guard + the "
"no-body/no-record bails — NOT the in-flight guard"
)
def test_open_editor_save_empty_guard_keeps_the_form() -> None:
"""A whitespace-only save is rejected in-form: the trimmed text is
empty → the ``.edit-error`` line shows "Ask something first — the
question can't be empty." and the form STAYS open (the early
return precedes the replace work — the note is never lost, the
openTuneForm failure way). The schema backstop:
``ChatRequest.message`` / ``ChatMessage.text`` both require
``min_length=1``."""
js = _js()
body = _fn_body(js, "openEditor")
idx_trim = body.index("const text = ta.value.trim();")
idx_empty = body.index("if (!text) {", idx_trim)
idx_replace = body.index("conversation[editIdx].text = text;")
assert idx_trim < idx_empty < idx_replace, (
"the empty guard runs between the trim and the replace"
)
assert "status.textContent = \"Ask something first — the question can't be empty.\";" in body
# Inside the empty branch: reveal the alert + return (form kept).
empty_block = body[idx_empty: idx_replace]
assert "status.hidden = false;" in empty_block
assert "return;" in empty_block
assert "form.remove()" not in empty_block, "the form is NOT removed"
def test_open_editor_save_runs_the_full_task_02_flow_in_order() -> None:
"""A valid save runs the task-02 flow in order (the task-01
replace + re-render + close pins survive inside it): the empty
trim, the in-place record replace, the record-tail splice, the DOM
bubble removal, the ``lastBrainWrap`` / ``leavePartialIndex``
resets, the ``renderMarkdown`` re-render (the attachment captured
before the wipe and re-prepended after — phase 123 position),
``saveConversation()`` + ``persistConversation()``,
``markLastRetryable()``, ``closeEditor()``, the double in-flight
check, and the ``runTurn(text, { reask: true })`` re-ask. The
task-01 seam marker is GONE (the seam is filled). No new live
region (the visible re-render is the feedback — the
``#send-status`` announcer stays the sole a11y announcer)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
positions = [
body.index("const text = ta.value.trim();"),
body.index("conversation[editIdx].text = text;"),
idx_splice,
body.index("let n = lastUserWrap.nextSibling;"),
body.index("lastBrainWrap = null;"),
body.index("if (leavePartialIndex > editIdx) leavePartialIndex = -1;"),
body.index('const img = bubble.querySelector(".msg-image");'),
body.index("bubble.innerHTML = renderMarkdown(text);"),
body.index("bubble.prepend(img);"),
body.index("saveConversation();", idx_splice),
body.index("persistConversation();"),
body.index("markLastRetryable();"),
body.index("closeEditor();", body.index("markLastRetryable();")),
body.index("return runTurn(text, { reask: true });"),
]
assert positions == sorted(positions) and len(set(positions)) == len(positions), (
"the save flow is: trim → replace → splice → DOM removal → "
"bookkeeping resets → re-render → save → persist → retry "
"re-target → close → re-ask"
)
assert "task 02: truncate the tail" not in body, (
"the task-01 seam marker is gone (the seam is filled)"
)
assert "announce" not in body, (
"no new live region — the visible re-render is the feedback"
)
def test_open_editor_save_truncates_the_record_tail_outright() -> None:
"""The tail truncation is ``conversation.splice(editIdx + 1)`` —
the WHOLE tail after the edited record (a single-argument splice
removes to the end), nothing before it: the edited record itself
is kept (replaced in place), and the records the OLD question
produced are dropped outright — never rewritten (the phase-114
whole-turn rule). The splice runs after the record replace and
BEFORE any persistence — the persisted shape is the truncated
one."""
js = _js()
body = _fn_body(js, "openEditor")
idx_replace = body.index("conversation[editIdx].text = text;")
idx_splice = body.index("conversation.splice(editIdx + 1);")
idx_save = body.index("saveConversation();", idx_splice)
assert idx_replace < idx_splice < idx_save, (
"replace → splice (the whole tail) → persist (the truncated "
"shape)"
)
assert "conversation.splice(editIdx)" not in body, (
"the edited record itself is kept — only the tail goes"
)
def test_open_editor_save_removes_the_dropped_bubbles_from_the_dom() -> None:
"""The DOM removal mirrors the record splice: it walks
``lastUserWrap.nextSibling`` onward and removes every trailing
sibling with the safe walk (``nextSibling`` captured BEFORE the
``.remove()``). It runs after the record splice and before the
persistence (the DOM and the record list are truncated as one
shape — #messages' direct children are the bubbles, in order, and
the empty-state div is a leading child, so the trailing siblings
are exactly the dropped tail)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
idx_walk = body.index("let n = lastUserWrap.nextSibling;")
assert idx_splice < idx_walk, "the DOM removal follows the record splice"
idx_loop = body.index("while (n) {", idx_walk)
assert idx_walk < idx_loop
loop_close = body.index("}", idx_loop)
loop_body = body[idx_loop : loop_close + 1]
assert "const next = n.nextSibling;" in loop_body, (
"the nextSibling is captured BEFORE the removal (the safe walk)"
)
assert "n.remove();" in loop_body
assert "n = next;" in loop_body
def test_open_editor_save_resets_the_turn_bookkeeping_before_the_re_ask() -> None:
"""The drop resets the turn bookkeeping BEFORE the re-ask:
``lastBrainWrap = null`` (the removed answer is no longer a retry
target — ``markLastRetryable``'s targeting keys off it) and
``leavePartialIndex`` is cleared to -1 (the safe value — "none")
WHEN IT POINTED PAST ``editIdx`` — a phase-73 pagehide partial
saved from the DROPPED turn must not be re-persisted into the new
shape (the settle's identity guard would otherwise fall back to
an append; a partial on or before the edited record is untouched,
and the next ``runTurn`` resets it turn-locally regardless). Both
resets precede the re-ask."""
js = _js()
body = _fn_body(js, "openEditor")
idx_reset = body.index("lastBrainWrap = null;")
idx_partial = body.index(
"if (leavePartialIndex > editIdx) leavePartialIndex = -1;"
)
idx_reask = body.index("return runTurn(text, { reask: true });")
assert idx_reset < idx_reask, "lastBrainWrap = null precedes the re-ask"
assert idx_partial < idx_reask, "the leavePartialIndex reset precedes the re-ask"
assert "leavePartialIndex > editIdx" in body, (
"the reset is gated on the pointer being past editIdx"
)
def test_open_editor_save_persists_the_truncated_shape_before_the_re_ask() -> None:
"""The truncated shape persists BEFORE the re-ask: BOTH
``saveConversation()`` (localStorage — a crash never resurrects the
pre-edit text or the stale tail; ``trimToBudget`` rides it
unchanged) and ``persistConversation()`` (the phase-55 auto-save —
fire-and-forget, never blocks the turn; a shortened messages array
is already legal — no new field, no migration, A10 stays
stateless) run after the splice and before ``runTurn``."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
idx_save = body.index("saveConversation();", idx_splice)
idx_persist = body.index("persistConversation();", idx_save)
idx_reask = body.index("return runTurn(text, { reask: true });")
assert idx_splice < idx_save < idx_persist < idx_reask, (
"splice → saveConversation → persistConversation → runTurn"
)
def test_open_editor_save_re_targets_the_retry_button_on_the_removed_tail() -> None:
"""After the drop, ``markLastRetryable()`` re-targets: no brain
record exists (and ``lastBrainWrap`` is null), so the call simply
clears every ``.retry-btn`` (its bail — the removed answer is no
longer a retry target). It runs before the re-ask; the re-ask's
settle re-appends the button on the fresh bubble through its own
``markLastRetryable`` / ``markLastEditable`` pair."""
js = _js()
body = _fn_body(js, "openEditor")
idx_mark = body.index("markLastRetryable();")
idx_reask = body.index("return runTurn(text, { reask: true });")
assert 0 <= idx_mark < idx_reask, ("the retry re-target precedes the re-ask")
def test_open_editor_save_re_asks_with_reask_true_and_no_image() -> None:
"""The re-ask is EXACTLY ``return runTurn(text, { reask: true })``:
``reask`` is the whole point (``runTurn`` skips the user-bubble
append + save point 1 — the question is never duplicated — and
``history = conversation.slice(0, -1)`` is exactly the prior turns
because the edited question is the LAST record), the promise is
RETURNED (the ``retryLastTurn`` convention — ``runTurn`` never
rejects; a failure lands in the phase-120 failed-turn path), and
NO ``image`` is passed (the phase-49/123 locked A7 rule: a re-ask
sends text only — the record keeps its image key, the bubble keeps
the attachment)."""
js = _js()
body = _fn_body(js, "openEditor")
calls = re.findall(r"runTurn\([^)]*\)", body)
assert calls == ["runTurn(text, { reask: true })"], (
"the handler's only runTurn call is the re-ask — exactly "
"runTurn(text, { reask: true }), no image argument"
)
def test_open_editor_save_is_one_synchronous_stretch_with_the_double_check() -> None:
"""The in-flight double-check: the handler is ONE synchronous
stretch from the guard at the top to the ``runTurn`` call (no
``await`` in between — the ``retryLastTurn`` discipline, which
earns its double-check exactly this way), and a SECOND in-flight
guard sits before the re-ask (a save can never start a second
turn)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_submit = body.index('form.addEventListener("submit"')
handler = body[idx_submit:]
guard = (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
assert handler.count(guard) == 2, "the double in-flight check (top + pre-re-ask)"
idx_guard1 = handler.index(guard)
idx_guard2 = handler.index(guard, idx_guard1 + 1)
idx_reask = handler.index("return runTurn(text, { reask: true });")
assert idx_guard1 < idx_guard2 < idx_reask, (
"the top guard, then the pre-re-ask guard, then the re-ask"
)
stretch = handler[idx_guard1:idx_reask]
# The no-await discipline is about the CODE — strip the line
# comments (which may word the invariant) before the check.
code = re.sub(r"//[^\n]*", "", stretch)
assert not re.search(r"\bawait\b", code), (
"the handler is one synchronous stretch — the double-check is "
"earned, not simulated"
)
def test_open_editor_save_never_touches_the_composer() -> None:
"""The composer is kept out of it (the task-02 work item): the
save handler never writes ``input.value`` (the user's unsent draft
survives an edit), and never calls ``autoGrow()`` or
``updateCharCount()`` — the composer's char-count display keeps
counting the DRAFT, not the edited question. The only composer
contact is the read-only placeholder reuse at form build time.
``MAX_QUESTION_CHARS`` stays the textarea's ``maxlength`` (the
server's ``ChatRequest.message max_length=4000`` remains the real
bound — a >4000 trim is unreachable)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_submit = body.index('form.addEventListener("submit"')
handler = body[idx_submit:]
assert "input.value" not in handler, (
"editing never touches the composer's draft (input.value)"
)
assert "autoGrow()" not in handler, ("the composer's autoGrow is untouched")
assert "updateCharCount()" not in handler, ("the char count keeps counting the draft")
assert "ta.placeholder = input.placeholder" in body, (
"the only composer contact is the read-only placeholder reuse"
)
def test_open_editor_stale_click_guard() -> None:
"""The click's wrap must still be ``lastUserWrap`` (the
phase-49 stale-click discipline, the ``retryLastTurn`` guard
mirror): a superseded bubble's button is inert. The guard is the
FIRST statement — before the one-at-a-time removal, so a stale
click never closes a live editor."""
js = _js()
body = _fn_body(js, "openEditor")
idx_guard = body.index("if (wrap !== lastUserWrap) return;")
idx_remove = body.index('document.querySelectorAll(".edit-form")')
assert 0 <= idx_guard < idx_remove, (
"the stale-click guard precedes the one-at-a-time removal"
)
# ---------------------------------------------------------------------------
# Call sites — the six pairs + the runTurn idle-settle mark pass
# ---------------------------------------------------------------------------
def test_the_mark_last_retryable_sites_pair_mark_last_editable() -> None:
"""Every settle/restore ``markLastRetryable()`` call site (the
seven: ``restoreConversation``, ``restoreSavedChatFromUrl``,
``finalizeFailedTurn`` — the funnel every non-OK-HTTP / SSE
``error``-frame turn settles through, the done settle, the
zero-frame fallback, the stop finalize, and the task-03 300s
guard ``aborted`` branch) is followed IMMEDIATELY by a
``markLastEditable()`` — the paired settle (the mid-turn sites
no-op on the in-flight guard; the two restore sites land the
button at boot; the aborted site lands it in the error state,
which the guard does not block). Task 02 adds an EIGHTH call
site — the save-handler re-target (after the tail drop it
clears the buttons; it is NOT paired: the re-ask's settle
re-marks through its own pair). Total: eight call sites, seven
paired."""
js = _js()
calls = js.count("markLastRetryable();")
assert calls == 8, (
f"eight call sites: the seven paired settles + the task-02 "
f"save-handler re-target (found {calls})"
)
paired = re.findall(
r"markLastRetryable\(\);[^\n]*\n\s*markLastEditable\(\);", js
)
assert len(paired) == 7, (
"the seven settle/restore sites are each immediately followed by "
"markLastEditable() (the paired settle)"
)
# Counting both calls: the editable sites are the seven pairs + the
# runTurn finally idle-settle mark pass (the at-rest landing — no
# retryable site there, the settle re-runs the editable mark only).
assert js.count("markLastEditable();") == 8, (
"eight markLastEditable() sites: the seven pairs + the finally "
"settle"
)
def test_runturn_idle_settle_marks_editable() -> None:
"""The terminal settle (``finally``) re-runs ``markLastEditable()``
AFTER the ``setUiState(UI_STATE.idle)`` — the ONE mark pass that
lands the button at rest (the four mid-turn paired sites inside
the event handlers no-op on the in-flight guard). Exactly ONE
``markLastEditable()`` inside the finally block (the settle — the
fresh-send block does not mark; the reask path keeps the existing
bubble + button). A New-chat-mid-turn is a no-op by construction
(the records + the wrap are cleared, so the scan/wrap bails)."""
js = _js()
rt = _fn_source(js, "runTurn")
finally_idx = rt.index("} finally {")
finally_block = rt[finally_idx:]
assert finally_block.count("markLastEditable();") == 1, (
"exactly one markLastEditable() inside the finally (the idle "
"settle mark pass)"
)
settle = rt.index("if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);")
mark = rt.index("markLastEditable();", finally_idx)
assert settle < mark, "the mark pass runs AFTER the idle settle"
# The four mid-turn paired sites (done / zero-frame / stop /
# task-03 aborted) live in the event handlers, BEFORE the finally
# (they no-op on the guard; the aborted one lands in the error
# state, which the guard does not block).
assert rt.count("markLastEditable();") == 5, (
"the four mid-turn paired sites + the finally settle — five "
"mark sites inside runTurn"
)
before_finally = rt[:finally_idx]
assert before_finally.count("markLastEditable();") == 4
# ---------------------------------------------------------------------------
# NOT touched — the phase's explicit contract (byte-pinned sources)
# ---------------------------------------------------------------------------
def test_retry_last_turn_is_byte_unchanged() -> None:
"""``retryLastTurn`` is byte-unchanged by phase 159 (tasks
01–02; the redo-in-place is REUSED, not extended — the phase's
"NOT touched" contract): the full source must match the
phase-120 pin (the shared constant — one pin, no drift). Task 02
consumes its ``runTurn(text, { reask: true })`` convention (the
returned promise) without touching the function."""
assert _fn_source(_js(), "retryLastTurn") == PINNED_RETRY_LAST_TURN
#: The FULL source of ``runTurn`` as of phase 159 task 01 — the
#: The FULL source of ``runTurn`` as of phase 159 — the phase-49
#: redo-in-place turn with the phase's SANCTIONED additions: the
#: task-01 fresh-send ``lastUserWrap`` assignment (work item 3), the
#: task-01 idle-settle ``markLastEditable()`` mark pass (work item 3
#: call sites — the in-flight guard's at-rest landing), and the
#: task-03 aborted-settle mark pair (the 300s guard's ``aborted``
#: catch branch — a timed-out turn must not strand the question).
#: The ``reask`` path is consumed as-is. A diff here is a contract
#: violation for the phase's remaining tasks.
PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}) {
if (!reask) {
// Phase 123 (task 02, TODO L6): the attached image rides the user
// bubble (the data URL live — the stored path is the fallback, so
// any future caller passing only a path still renders) and the
// STORED RECORD (the path, A5: never base64). A null image (every
// text-only send, every re-ask — A7: a redo re-sends the text
// only) leaves the bubble and the record byte-identical to
// pre-phase.
const userWrap = addMessage(
"user",
renderMarkdown(text),
true, // reveal my message (owner-kept)
image ? { src: image.src || image.path, alt: image.alt } : null
);
lastUserWrap = userWrap; // phase 159: the newest question bubble is the editable one
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it. The `image`
// key (the stored path) joins the `bor.chat.v1` record only when an
// attachment exists (A5 — the phase-14 shape gains one optional key;
// a text-only record is byte-identical to pre-phase).
conversation.push(
image ? { who: "user", text, image: image.path } : { who: "user", text }
);
saveConversation();
// Phase 55 (A2): the auto-save rides the save point — an unlinked
// conversation creates its row here (auto-title, server-side), a
// linked one refreshes. Fire-and-forget: it never blocks the turn.
persistConversation();
// Phase 123 (task 02): the strip must not linger into the turn —
// cleared AFTER the bubble is rendered (the bubble already holds
// the image; the record holds the path; a failed turn keeps both).
clearAttachedImage();
}
let wrap = null;
let res = null;
let aborted = false; // the 300s guard already took the turn to error
// Phase 20: acc / thinkingAcc / persistedOnLeave live at module scope
// (the pagehide handler reads them) but reset here, so they stay
// turn-scoped exactly like the other turn locals. Phase 73:
// leavePartialIndex joins them — no pagehide partial exists for this
// turn yet (the pagehide handler records it if one lands).
acc = "";
thinkingAcc = "";
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
currentRun = null;
persistedOnLeave = false;
leftThePage = false;
leavePartialIndex = -1;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals.
turnAbort = new AbortController();
stoppedByUser = false;
let sawThinking = false; // did any `thinking` frame arrive this turn?
let sawDone = false; // did the stream end with a `done` event?
let toolAcc = []; // phase 37: {name, argument} per `tool` frame —
// persisted with the turn (optional `tools` key);
// phase 139: a range read's {offset, limit} join
// additively (each field only when well-formed)
try {
// thinking = pre-token: dots + busy button. The guard is armed so a
// hung stream can never leave the button zombified; it clears on the
// first thinking OR delta event (phase 17) and on every terminal
// transition.
setUiState(UI_STATE.thinking);
armTurnTimeout(() => {
aborted = true;
cancelStream(res); // best-effort: the reader may still hold the lock
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
// set first, so the catch never reads the guard's
// abort as a user stop
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
// Phase 74 (TODO L4): the conversation so far travels WITH the
// question — the `conversation` record minus the current question.
// The invariant every caller holds at fetch time: a fresh send just
// pushed the question (save point 1 above); the phase-49 retry and
// the phase-53 stale-regen (both through retryLastTurn) popped the
// old answer and keep the question as the last entry — so slice(0,
// -1) is exactly the prior turns, oldest first, and the question is
// never duplicated into the history. `thinking` rides only brain
// records that actually streamed one (phase 17's optional key —
// `undefined` drops it from the JSON, the record's convention);
// user turns and old/restored records without thinking send none
// (the server maps those to plain assistant messages). No other
// record key (`sources`, `tools`, `deflected`, `suggestions`,
// `stopped`) travels in the body — the server schema (task 01)
// accepts exactly {who, text, thinking}; `tools` metadata is
// display-only and was never part of the LLM wire.
const history = conversation.slice(0, -1).map((m) => ({
who: m.who,
text: m.text,
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
}));
// Phase 123 (task 02, TODO L6): the attached image's STORED path
// rides the body top-level (A5: the path, never base64; the server
// builds the multimodal content from the stored bytes). Added only
// when present — a text-only body omits the `image` key entirely
// (byte-identical to pre-phase). HISTORY entries stay {who, text,
// thinking}: prior turns' images are never replayed (locked A7 —
// the image is turn-local to the original send).
const payload = { message: text, history };
if (image) payload.image = image.path;
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
});
if (!res.ok || !res.body) {
let detail = `Brain's API answered with HTTP ${res.status}.`;
try {
const body = await res.json();
if (body.detail) detail = body.detail;
} catch { /* non-JSON error body */ }
throw new Error(detail);
}
await readSSE(res, (ev) => {
if (aborted) return;
if (ev.type === "thinking") {
// Phase 87 (TODO.md L5): a frame arrived — the latest tool line
// is no longer "processing"; settle its "(Ns)" suffix (the
// visible indication moves to the thinking block).
settleToolLine();
// Phase 17: model reasoning — stream it live into the collapsible
// Thinking block. No setUiState here: the UI state stays
// "thinking" (the button stays the enabled "Stop" control —
// phase 48 — #send-status unchanged) — the live block simply
// replaces the typing dots as the visible feedback.
// Phase 158 (D1, owner direction 2026-10-04): PER-RUN blocks —
// the D15 "re-open the one shared block" is superseded. Each
// CONTIGUOUS thinking run streams into its OWN
// details.thinking (ensureThinkingRun: a frame of a new kind
// closes the previous run and opens a new block; D5 — the new
// block is OPEN while its frames stream). A post-delta
// thinking frame (the next agent round: the model answered,
// called a tool, then thinks again) lands in a NEW block above
// the answer — not a re-open of the block that sits on top of
// it (the owner's "thinking resumed above the answer" awkwardness
// is gone; the runs stay in arrival order, D2).
thinkingAcc += ev.text || "";
sawThinking = true;
clearTurnTimeout(); // the stream is alive — as the first delta says
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingRun(wrap);
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
// than the 32px band — any paragraph break ("\\n\\n") or a few
// lines of text, which is exactly what the real model streams —
// grows the window's content below the old bottom, so measuring
// the distance AFTER the update reads the chunk's height, not
// the user's position, and the follow died at the first
// 2-newline gap. Pre-update, the distance is where the user
// actually is. Phase 158: the same pin logic, now operating on
// THIS block (per-run — each run's block pins its own tail;
// the pre-update capture is verbatim).
const pinned = block.open && isThinkingNearBottom(textEl);
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
if (pinned) {
// Follow the live tail only while the user was pinned to the
// window bottom before this chunk (owner direction
// 2026-08-27); a scrolled-up reader is never re-pinned —
// returning to the bottom re-arms the pin on the next chunk.
textEl.scrollTop = textEl.scrollHeight;
}
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button stays the enabled "Stop"
// control (phase 48, owner-locked: it no longer relabels to
// "Calling tool…"); what changes are the STATUS LABELS:
// #send-status + the typing indicator (if still visible) say
// what Brain is doing, and a .tool-call line lands above the
// answer (append-only, in order). The elapsed-seconds hint
// (thinkingClock) keeps running through tool frames — no clock
// changes here.
const name = typeof ev.name === "string" ? ev.name : "";
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
// Phase 139 (task 02): the phase-131 wire fields — the `tool`
// frame already carries offset/limit for a range read (ABSENT
// on every non-range frame, never null — the server's
// _whole_line_number drops them when malformed). The SAME
// well-formedness guard as readRangeSuffix (Number.isInteger(x)
// && x >= 1) → null when absent / malformed, and each field
// lands on the record ONLY when non-null: a non-range frame
// keeps the exact pre-phase {name, argument} shape (the
// bor.chat.v1 save JSONB is additive — old records restore
// byte-identically, no migration), while a range read persists
// WHICH lines the model requested (the owner's "same file over
// and over" confusion — two reads of one file now differ).
const frameOffset =
Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null;
const frameLimit =
Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null;
// Phase 146 (task 02): the SCOPE of a scoped grep — the
// frame's additive `path` field (ABSENT on every non-scoped
// frame, never null — the ChatToolEvent serializer drops it
// when None). The SAME string guard as `argument` → null when
// absent / malformed; the record gains `path` ONLY when
// non-null (a bare grep keeps the exact pre-phase
// {name, argument} shape — the bor.chat.v1 save JSONB is
// additive, old records restore byte-identically, no
// migration), and the line + the status label show WHERE the
// search runs (the "looks like the LLM is repeating itself"
// confusion, TODO.md L3).
const framePath =
typeof ev.path === "string" && ev.path ? ev.path : null;
const toolRecord = { name, argument };
if (frameOffset !== null) toolRecord.offset = frameOffset;
if (frameLimit !== null) toolRecord.limit = frameLimit;
if (framePath !== null) toolRecord.path = framePath;
toolAcc.push(toolRecord);
clearTurnTimeout(); // the stream is alive — a frame arrived
if (!wrap) wrap = addMessage("brain", "");
// Phase 70: the harness-aligned names (read/grep/ls) map to the
// same status copy as their legacy counterparts (read_document /
// search_documents) — a pre-remap frame keeps its label; the
// scoped ls mirrors the scoped tool line. Phase 139 (task 02,
// ASSUMPTION A2): the read status carries the SAME requested-
// range suffix as the line (readRangeSuffix — the single source
// of truth for the wording; a non-range read renders
// byte-identical, the helper returns "").
const toolStatus =
(name === "read" || name === "read_document") && argument
? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`
: (name === "grep" || name === "search_documents") && argument
? `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}`
: name === "ls" && argument
? `${brand()} is listing documents in ${argument}`
: `${brand()} is listing documents`;
if (uiState === UI_STATE.thinking) {
sendStatus.textContent = toolStatus;
document
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", toolStatus);
}
// Phase 158 (D1): PER-RUN disclosure — each CONTIGUOUS tool-call
// run streams into its OWN details.tool-calls-disclosure
// (ensureToolRun: a frame of a new kind closes the previous run
// and opens a new disclosure; D5 — OPEN while its frames stream),
// inserted BEFORE the bubble in arrival order (D2). The record
// rides the run's `records` too — the SAME toolAcc objects, so
// the phase-95 tool_result stamp (truncated / chars_shown /
// chars_total) flows into the segments payload with no extra
// wiring.
const disc = ensureToolRun(wrap);
currentRun.run.records.push(toolRecord);
const line = appendToolLine(
disc,
name,
argument,
frameOffset,
frameLimit,
framePath
);
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site
// (A6): the phase-14 restore path re-renders lines without it.
// Phase 158: the clock TARGETS this line (the tracked reference
// — the `:last-child` query is gone; multiple disclosures make
// it the wrong line).
armToolLineClock(wrap, line);
// No page scroll (phase 42): tool lines never yank the viewport.
} else if (ev.type === "retry") {
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
// tool line's "(Ns)" suffix (the retry status line takes over as
// the visible feedback).
settleToolLine();
// Phase 67 (owner-locked A4): the server restarted the LLM
// request before ANY frame of it reached the client (locked
// A2) — say exactly what is happening on the existing status
// line. Transient status only: no bubble, no tool line, no
// banner, no UI-state change — the next thinking/tool/delta
// frame replaces it through its own branch. The gate covers
// BOTH live states: a LATER agent round may restart while the
// UI already streams (a content+tool-call stream from an
// earlier round).
clearTurnTimeout(); // the stream is alive — the server is restarting the LLM request
const attempt = Number(ev.attempt) || 1;
const max = Number(ev.max_attempts) || 1;
const retryStatus =
`Communication interrupted — retrying (${attempt} of ${max})…`;
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) {
sendStatus.textContent = retryStatus;
document
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", retryStatus);
}
} else if (ev.type === "delta") {
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
// tool line's "(Ns)" suffix (the answer bubble takes over as the
// visible feedback).
settleToolLine();
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
// Phase 158 (D5): the first delta FOLDS the open run (the
// active block closes; a later thinking frame opens a NEW run
// above the bubble — the D15 re-open of the shared block is
// gone). Idempotent — a no-op without an open run.
closeCurrentRun();
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
// Phase 158 (D5): the turn is over — fold EVERY open run
// (defensive: by construction at most the last run is open; a
// late double-close is a no-op). Replaces the phase-117
// closeToolCalls fold + the D15 block close.
closeAllRuns(wrap);
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
}
if (ev.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, ev.suggestions);
}
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
// answer's first-class figure (A4): appended to .msg-body
// right BEFORE the source clusters (phase 130: the chips row,
// then the images row), so it lands below the answer text
// and above the sources. The text is untouched (the
// figure is additive — "summaries should still be provided");
// a frame without it renders byte-identically (no figure DOM).
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
appendSources(wrap, ev.sources);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
// under the usefulness bar — and the weak hits of a DEFLECTED
// turn, whose `ev.sources` is empty → zero chips) renders as
// the de-emphasized labeled row BELOW the citation clusters
// and ABOVE the actions row (phase 130 task 02 — the
// .msg-actions row is the bubble's LAST row, so the action
// appenders after this get-or-create it; the getActionsRow
// scoping keeps the buttons out of the related row either way
// — a deflected turn with related docs still gets its own
// actions row).
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
// final record REPLACES it in place (leavePartialIndex = -1 when
// there was none — the append, exactly as before).
// Phase 158 (D4): the turn's CHRONOLOGICAL run order persists
// with the turn — segments (non-empty only: the
// undefined-drops-the-key house pattern; a no-thinking/no-tools
// turn stores no key). Additive: thinking + tools stay UNCHANGED
// (the history wire + the old-client degradation path).
const turnSegments = buildSegments(segRuns);
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
segments: turnSegments.length ? turnSegments : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
// Phase 124 (task 06, TODO L3): the answer image persists
// with the turn (undefined drops the key from the JSON — the
// house optional-meta pattern, the `related` line below is
// the precedent): an image-less / pre-phase turn stores a
// byte-identical record.
answer_image: ev.answer_image || undefined,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
// row exactly as it looked live.
related: ev.related?.length ? ev.related : undefined,
suggestions: ev.suggestions,
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
// Phase 130 (task 02): the row order is the DOM order — the
// related row above, then the actions row LAST (Tune → Retry
// → Save-as-doc in insertion order, the bubble's bottom-right
// cluster).
appendRelated(wrap, ev.related); // phase 113 — above the actions row
appendTuneButton(wrap); // every completed brain bubble is tunable
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
markLastEditable(); // phase 159: the done turn's question is editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores above,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
} else if (ev.type === "tool_result") {
// Phase 95 (A15 extension, task 02): the truncation the LLM is
// told about is told to the USER. One frame per truncated read,
// always after its `tool` frame and before the next round — the
// Reading line is already on screen. Settle that line's
// elapsed clock like every other frame (the marker is the
// visible feedback now), then append the marker to the NEWEST
// line carrying this argument (appendTruncatedNote — a DOM
// append to the existing line: no new line, no re-render, the
// phase-37/48 tool-line lifecycle is untouched) and stamp the
// matching toolAcc entry so the save payload carries it (the
// `done` save point below needs zero other change). A frame
// whose line/toolAcc entry is gone (New Chat mid-turn) is a
// silent no-op; a non-truncated read never sends one.
settleToolLine();
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
const shown = Number(ev.chars_shown) || 0;
const total = Number(ev.chars_total) || 0;
if (argument && ev.truncated) {
appendTruncatedNote(wrap, argument, shown, total);
for (let i = toolAcc.length - 1; i >= 0; i -= 1) {
const t = toolAcc[i];
if (t && t.argument === argument) {
t.truncated = true;
t.chars_shown = shown;
t.chars_total = total;
break;
}
}
}
} else if (ev.type === "error") {
// Phase 114 (TODO L6): carry the frame's optional hint (the
// "question too long" frame has one) through the throw — the
// banner shows it in place of the default reachability hint.
const err = new Error(ev.detail || "Something went wrong on my side.");
err.hint = ev.hint;
throw err;
}
});
// Stream-drop guard (phase 17): frames arrived but no `done` event —
// the connection died mid-turn. Say so; never settle silently into
// idle with a half bubble. The zero-frame case falls through to the
// existing empty-answer fallback below.
if (!sawDone && !aborted && (acc || thinkingAcc)) {
// Phase 120 (TODO.md L3–4): a half-answer is a FAILED answer — the
// partial persists as failed (its text + the in-bubble error note +
// a working Retry) BEFORE the error state, so Refresh restores what
// the user saw + a Retry (no bare question, no "new chat").
const detail = "The stream ended before my answer finished — try again?";
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
wrap,
leavePartialIndex,
});
setUiState(UI_STATE.error, detail);
}
if (!aborted && !wrap) {
const fallback = EMPTY_ANSWER_FALLBACK;
// Phase 120 (TODO.md L3–4, task 01 ASSUMPTION): the stream
// COMPLETED with ZERO frames — it is a failed turn too. The bubble
// text stays EMPTY_ANSWER_FALLBACK (a meaningful record text); the
// failed marker + error note make it persist + restore as a failure
// with a working Retry (the pre-phase record persisted with no
// marker — inconsistent with the refresh case this phase fixes).
const nothing = "The model answered with nothing.";
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendFailedNote(fwrap, nothing);
// Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but
// passed so EVERY settle write goes through the same correlation.
rememberBrainTurn(fallback, {
failed: true,
error: nothing,
}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
markLastEditable(); // phase 159: the failed turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
}
} catch (err) {
if (aborted) {
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
// Phase 159 (task 03): the aborted settle must not strand the
// question — the same mark pair as the other non-`done` settles.
// markLastRetryable re-targets (a timed-out turn persists NO
// brain record — the phase-120 exclusion — so the pair re-lands
// the previous turn's target and clears stale buttons), and
// markLastEditable lands Edit on the question NOW (the guard
// bails only on thinking/streaming — the error state passes;
// the finally idle-settle re-runs it — the remove-then-append
// keeps the double pass harmless).
markLastRetryable();
markLastEditable();
} else if (stoppedByUser || err?.name === "AbortError") {
// Phase 48 (owner-locked): the STOP path — no error banner. When
// answer text streamed, keep the partial on screen and persist it
// with the optional `stopped` marker (phase-14 no-version-bump
// convention; no sources/suggestions — the turn never settled).
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
// which aborts the same fetch — from saving the partial twice.
if (wrap && acc && !persistedOnLeave) {
// Phase 158 (D5): the turn was stopped — fold every open run
// (replaces the phase-117 closeToolCalls fold + the D15 block
// close, like `done`).
closeAllRuns(wrap);
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
// Phase 73 (task 02): the stopped partial REPLACES a pagehide
// partial (leavePartialIndex) in place — one brain turn for the
// question, marked stopped, never two records. Phase 158
// (D4): the stopped partial's timeline persists too
// (non-empty only).
const stopSegments = buildSegments(segRuns);
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
segments: stopSegments.length ? stopSegments : undefined,
stopped: true,
}, leavePartialIndex);
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
markLastEditable(); // phase 159: the stopped turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
}
// Pre-token / thinking-only stop: persist NOTHING brain-side
// (phase-20 convention — the question is already saved on send).
// The "Answer stopped." confirmation is set in the finally, AFTER
// the single settle, so setUiState(idle) can't overwrite it.
} else {
const detail =
err instanceof Error && err.message
? err.message
: "Something went wrong on my side.";
// Phase 120 (TODO.md L3–4, locked A1): persist the failed turn
// BEFORE the error state — the funnel sets lastBrainWrap, so the
// existing `opts.retryable && lastBrainWrap` condition in
// setUiState → showErrorBanner now reveals the phase-111 banner
// Retry on a network error (the button's precondition finally
// holds; showErrorBanner itself is byte-unchanged).
// Phase 120 verification (navigate-away regression): a REAL
// departure (pagehide fired) tears the document down and the
// browser cancels the in-flight fetch — that teardown rejection
// is NOT a failed turn. The phase-20 convention stands: a
// thinking-only navigate-away persists NOTHING brain-side, and a
// partial navigate-away persists the pagehide partial (above) as
// a plain record — never a failed one. A real network error
// while the page is still alive (no pagehide) takes the funnel
// as before.
if (!leftThePage) {
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
wrap,
leavePartialIndex,
});
}
// Phase 114 (TODO L6): the SSE error frame's optional hint flows to
// the banner (setUiState → showErrorBanner's opts.hint); the
// phase-111 Retry button rides along on the same turn-error path.
setUiState(UI_STATE.error, detail, err instanceof Error ? { hint: err.hint } : {});
}
} finally {
// done | error | stop → idle: always settle, always focus back.
// State is turn-local, so a page reload mid-stream leaves a usable
// composer. The stop path must not double-settle — this is the one
// settle, and it never scrolls (phase 42: no scrollReveal on stop).
clearTurnTimeout();
stopThinkingClock();
cancelStream(res); // the reader lock is released — no unhandled rejection
turnAbort = null; // phase 48: the turn's abort owner is spent
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
// Phase 159 (task 01): the terminal settle (idle) is the ONE mark
// pass that lands the Edit button at rest — the mid-turn paired
// call sites (done / zero-frame / stop / failed funnel) no-op on
// the in-flight guard, so this re-run at idle is what actually
// appends (a New-chat-mid-turn clears the records + the wrap, so
// this is a no-op there by construction).
markLastEditable();
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
// coarse pointer (a phone/tablet) focusing the composer pops the
// virtual keyboard over the completed answer, which is exactly what
// the owner found annoying. Fine pointers keep the immediate
// refocus (type the follow-up without moving a hand); a coarse
// pointer stays unfocused — the composer is a ≥44px tap target
// (PLAN §7.1), so the next question starts with a tap. The
// never-scroll contract (phase 42) and the single-settle rule are
// unchanged on every device — only the focus call is gated.
if (!COARSE_POINTER) input.focus({ preventScroll: true });
}
}"""
def test_run_turn_is_pinned_at_phase_159_state() -> None:
"""``runTurn`` is pinned at exactly the state phase 159 left it
(task 02 consumes its ``reask`` path AS-IS — the phase's "NOT
touched" contract): the full source must match the pin — the
``reask`` path, the turn-local resets, the SSE contract, the
save points, and the sanctioned additions (the task-01
``lastUserWrap`` assignment + the idle-settle mark pass, and the
task-03 aborted-settle mark pair) included."""
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
def test_run_turn_pin_carries_exactly_the_sanctioned_additions() -> None:
"""Structural sanity on the pin: the fresh-send block captures the
wrap and assigns it (exactly once), the idle-settle mark pass is
the only ``markLastEditable()`` inside the finally block (the
four mid-turn paired sites — done / zero-frame / stop / task-03
aborted — are the other sanctioned call sites), and the editor
itself lives OUTSIDE ``runTurn`` (the editor is
``openEditor``'s job — ``runTurn`` is consumed as-is by task 02)."""
rt = PINNED_RUN_TURN
assert rt.count("lastUserWrap = userWrap;") == 1
finally_block = rt[rt.index("} finally {"):]
assert finally_block.count("markLastEditable();") == 1
assert ".edit-form" not in rt, "the editor form is not built inside runTurn"
assert "edit-input-" not in rt
# ---------------------------------------------------------------------------
# Task 03 — the driver case: a failed or rejected question stays editable
# ---------------------------------------------------------------------------
# The in-flight guard expression — ONE string, three homes (the
# markLastEditable guard, the save handler's top guard, the save
# handler's pre-re-ask guard).
IN_FLIGHT_GUARD = (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
def test_mark_last_editable_guard_bails_only_on_thinking_and_streaming() -> None:
"""The error state must not lock the editor: ``markLastEditable``
bails on ``UI_STATE.thinking`` / ``UI_STATE.streaming`` ONLY —
never on ``UI_STATE.error`` or ``UI_STATE.idle`` (a failed turn
IS idle-or-error at rest — the pin of the two states the guard
checks; read ``setUiState``: the error state is a REST state,
not an in-flight one)."""
js = _js()
body = _fn_body(js, "markLastEditable")
assert IN_FLIGHT_GUARD in body, ("the guard names thinking + streaming")
assert "UI_STATE.error" not in body, (
"the guard NEVER bails on the error state — the driver case "
"(the 'Question too long' frame's turn) settles there, and the "
"Edit button must land in it"
)
assert "UI_STATE.idle" not in body, (
"the guard NEVER bails on idle — the at-rest settle lands the "
"button"
)
def test_save_editor_inflight_guard_mirrors_mark_last_editable_exactly() -> None:
"""``saveEditor``'s in-flight guard mirrors ``markLastEditable``'
guard EXACTLY (the byte-identical expression — the same two
states, the same no-op semantics): a save can never start a
second turn, and the error state (the driver case — trim + Save
right after the 'Question too long' frame) never blocks a save."""
js = _js()
assert IN_FLIGHT_GUARD in _fn_body(js, "markLastEditable")
body = _fn_body(js, "openEditor")
idx_submit = body.index('form.addEventListener("submit"')
handler = body[idx_submit:]
assert handler.count(IN_FLIGHT_GUARD) == 2, (
"the save handler's double guard (top + pre-re-ask) is "
"byte-identical to markLastEditable's guard"
)
assert "UI_STATE.error" not in handler, (
"the save guards NEVER check the error state (a save in the "
"error state — the trim-and-re-ask remedy — must run)"
)
def test_show_error_banner_is_byte_unchanged_by_phase_159() -> None:
"""``showErrorBanner`` is NOT changed by phase 159 (the phase-120
rule, re-pinned here through the SHARED phase-120 pin — one pin,
no drift): its ``opts.retryable && lastBrainWrap`` condition stays
byte-identical — Edit is an in-bubble affordance beside Retry,
NEVER a banner control."""
assert _fn_source(_js(), "showErrorBanner") == PINNED_SHOW_ERROR_BANNER
body = _fn_source(_js(), "showErrorBanner")
assert "if (opts.retryable) {" in body, ("the phase-111 retryable gate")
assert "if (btn && lastBrainWrap) {" in body, ("the phase-111 bubble gate")
def test_failed_turn_record_after_the_edited_question_is_dropped_not_kept() -> None:
"""The failed answer record is part of the stale tail: after an
edit-save, the phase-120 ``{ who: "brain", failed: true, error }``
record that follows the edited question is DROPPED by the
single-argument ``conversation.splice(editIdx + 1)`` (a
single-argument splice drops to the END — nothing after the
edited record survives) and its bubble (with the ``.failed-note``)
is removed by the ``lastUserWrap.nextSibling`` DOM walk; and
``lastBrainWrap = null`` precedes the re-ask so no stale Retry
target survives the drop."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
assert idx_splice != -1, "the tail splice exists"
assert "conversation.splice(editIdx + 1," not in body, (
"the splice is SINGLE-argument — it drops to the END, so the "
"failed record after the question goes with the rest of the "
"tail (dropped, not kept, not rewritten)"
)
# The DOM walk (which removes the failed bubble, .failed-note
# riding it) runs after the record splice.
idx_walk = body.index("let n = lastUserWrap.nextSibling;")
assert idx_walk > idx_splice, "the DOM removal follows the record splice"
idx_loop = body.index("while (n) {", idx_walk)
loop_close = body.index("}", idx_loop)
loop_body = body[idx_loop : loop_close + 1]
assert "n.remove();" in loop_body, ("every trailing bubble is removed")
# The retry target dies with the drop — BEFORE the re-ask.
idx_lastbrain = body.index("lastBrainWrap = null;")
idx_reask = body.index("return runTurn(text, { reask: true });")
assert idx_lastbrain < idx_reask, (
"lastBrainWrap = null precedes the re-ask — the failed bubble "
"is no longer a retry target (no stale Retry survives)"
)
def test_truncation_ordering_note_pins_the_remember_brainturn_safety() -> None:
"""The splice carries the phase-159 task-03 ordering note: the
truncation happens BEFORE the re-ask's turn starts, so the new
turn's ``rememberBrainTurn`` sees the post-splice array (its
index bookkeeping — the ``replaceIndex`` / ``leavePartialIndex``
correlation, whose ``who === "brain"`` guard falls back to an
append — can never be confused by the drop)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
preceding = body[max(0, idx_splice - 1500) : idx_splice]
assert "BEFORE the re-ask's turn starts" in preceding, (
"the ordering note states the truncation precedes the re-ask"
)
assert "rememberBrainTurn" in preceding, (
"the note names rememberBrainTurn's index bookkeeping"
)
assert "post-splice array" in preceding, (
"the note states the new turn sees the post-splice array"
)
def test_turn_timeout_aborted_settle_marks_retry_then_editable() -> None:
"""The 300s guard abort (``armTurnTimeout`` →
``setUiState(UI_STATE.error, …)``) settles through the catch's
``aborted`` branch, which carries the mark pair (a timed-out
turn must not strand the question): ``markLastRetryable()``
(re-target — a timed-out turn persists NO brain record, the
phase-120 exclusion, so the pair re-lands the previous turn's
target and clears stale buttons) IMMEDIATELY followed by
``markLastEditable()`` (lands Edit on the question — the guard
bails only on thinking/streaming, so the error state passes);
the finally idle-settle then re-runs ``markLastEditable()`` (the
remove-then-append keeps the double pass harmless)."""
js = _js()
rt = _fn_source(js, "runTurn")
idx_catch = rt.index("} catch (err) {")
idx_aborted = rt.index("if (aborted) {", idx_catch)
idx_stop = rt.index(
'} else if (stoppedByUser || err?.name === "AbortError") {',
idx_aborted,
)
aborted_block = rt[idx_aborted:idx_stop]
idx_retry = aborted_block.index("markLastRetryable();")
idx_edit = aborted_block.index("markLastEditable();")
assert 0 <= idx_retry < idx_edit, (
"the aborted branch marks retryable, then editable (the pair)"
)
# And the finally idle-settle still re-runs the editable mark.
idx_finally = rt.index("} finally {")
finally_block = rt[idx_finally:]
assert finally_block.count("markLastEditable();") == 1, (
"the finally idle-settle re-runs markLastEditable (the at-rest "
"landing)"
)
def test_restore_paths_put_edit_back_on_the_restored_question() -> None:
"""A reload after a failed turn restores the question with a
working Edit: ``renderStoredMessage``'s user branch tracks
``lastUserWrap`` (the restored question bubble is the target —
the mirror of the brain line below), and BOTH restore paths end
with the ``markLastRetryable()`` → ``markLastEditable()`` pair
(the pair lands Retry on the restored failed bubble and Edit on
the restored question). The ``?chat=`` boot load (admin
restore) is an EDITABLE working copy — not a read-only view (the
phase-159 task-03 finding — the assumption holds, no gate
needed): it mirrors the conversation to localStorage
(``saveConversation`` — a plain refresh returns to it), links the
row (``currentChatId`` — a subsequent Save updates THIS row), and
carries the same mark pair; no read-only render path exists in
the function."""
js = _js()
pair_re = re.compile(
r"markLastRetryable\(\);[^\n]*\n\s*markLastEditable\(\);"
)
# The restore renderer's user branch tracks the wrap.
rst = _fn_body(js, "renderStoredMessage")
idx_user = rst.index('if (m.who === "user") {')
idx_wrap = rst.index("lastUserWrap = wrap;", idx_user)
assert idx_wrap > idx_user, ("the restore user branch assigns the wrap")
# The local restore ends with the pair.
rc = _fn_body(js, "restoreConversation")
assert pair_re.search(rc), ("restoreConversation ends with the mark pair")
# The ?chat= boot load: the pair, the mirror, the link — an
# editable working copy, no read-only gate.
rsu = _fn_body(js, "restoreSavedChatFromUrl")
assert pair_re.search(rsu), ("the ?chat= boot load carries the same mark pair")
assert "saveConversation();" in rsu, (
"the ?chat= restore mirrors to localStorage — an editable "
"working copy (a plain refresh returns to it)"
)
assert "currentChatId = chatId;" in rsu, (
"the ?chat= restore links the row — a subsequent Save updates "
"THIS row (the working copy, not a read-only view)"
)
assert "readOnly" not in rsu and "readonly" not in rsu, (
"no read-only render path in the ?chat= restore (the task-03 "
"finding — Edit follows Retry for the admin working copy)"
)
# ---------------------------------------------------------------------------
# CSS — the .edit-btn pill family + the .edit-form card family
# ---------------------------------------------------------------------------
def test_edit_btn_css_is_the_retry_pill_family() -> None:
"""``.edit-btn`` is the exact visual family of ``.retry-btn`` (the
neutral pill: 44px floor, the 999px pill radius, the --line
hairline, transparent background, ink-soft text; the hover tints
to --brand-soft + ink — the same tint; the 14px glyph): the
meta actions read as a set. No literal colors (the phase-92
zero-literal convention — the tokens theme)."""
css = _css()
m = re.search(r"\.edit-btn \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .edit-btn"
body = m.group(1)
for decl in (
"display: inline-flex;",
"min-height: 44px;",
"border-radius: 999px;",
"border: 1px solid var(--line);",
"background: transparent;",
"color: var(--ink-soft);",
"cursor: pointer;",
):
assert decl in body, f".edit-btn must carry {decl!r} (the retry pill family)"
hover = re.search(r"\.edit-btn:hover \{([^}]*)\}", css)
assert hover, "styles.css must define .edit-btn:hover"
assert "background: var(--brand-soft);" in hover.group(1)
assert "color: var(--ink);" in hover.group(1)
svg = re.search(r"\.edit-btn svg \{([^}]*)\}", css)
assert svg, "the .edit-btn glyph must be 14px (the family)"
assert "width: 14px;" in svg.group(1) and "height: 14px;" in svg.group(1)
# The global :focus-visible ring (the 3px outline) is NOT scoped to
# the button classes — it must be a top-level bare selector, so the
# Edit button inherits it for free.
assert re.search(r"^:focus-visible \{", css, re.M), (
"the :focus-visible ring must stay GLOBAL (not scoped to the "
"tune/retry buttons) — the Edit button inherits it"
)
def test_edit_form_css_is_the_tune_card_family() -> None:
"""The ``.edit-form`` card + label + textarea + actions row are
the ``.tune-form`` family (the surface card, the --brand-soft
hairline, the --radius-sm radius; the labelled textarea with the
house input tokens; the ≥44px Save (brand) + Cancel (ghost); the
``.edit-error`` alert line on the --err-* tokens — B5: state is
text + color, never color alone)."""
css = _css()
m = re.search(r"\.edit-form \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .edit-form"
body = m.group(1)
for decl in (
"display: flex;",
"flex-direction: column;",
"background: var(--surface);",
"border: 1px solid var(--brand-soft);",
"border-radius: var(--radius-sm);",
):
assert decl in body, f".edit-form must carry {decl!r} (the tune card family)"
assert re.search(r"\.edit-form label \{", css), "a label rule (the labelled textarea)"
assert re.search(r"\.edit-form textarea \{", css), "the textarea rule"
assert re.search(r"\.edit-form-actions \{", css), "the actions row rule"
save = re.search(r"\.edit-save \{([^}]*?)\n\}", css, re.S)
assert save, "styles.css must define .edit-save"
assert "min-height: 44px;" in save.group(1)
assert "background: var(--brand);" in save.group(1)
assert "color: var(--bg);" in save.group(1)
cancel = re.search(r"\.edit-cancel \{([^}]*?)\n\}", css, re.S)
assert cancel, "styles.css must define .edit-cancel"
assert "min-height: 44px;" in cancel.group(1)
assert "border: 1px solid var(--line);" in cancel.group(1)
assert "background: transparent;" in cancel.group(1)
err = re.search(r"\.edit-error \{([^}]*)\}", css)
assert err, "styles.css must define .edit-error"
tokens = (
"background: var(--err-bg);",
"color: var(--err-ink);",
"border: 1px solid var(--err-line);",
)
for token in tokens:
assert token in err.group(1), f".edit-error must use the --err-* tokens ({token})"
def test_is_editing_hides_the_user_bubble() -> None:
"""``.msg.user .bubble.is-editing`` is ``display: none`` — while
the editor is open the rendered question is hidden so the
question appears ONCE (textarea + label, never the rendered text
twice). Scoped to USER bubbles (a brain bubble never gets
``.is-editing``)."""
css = _css()
m = re.search(r"\.msg\.user \.bubble\.is-editing \{([^}]*)\}", css)
assert m, "styles.css must hide the editing user bubble"
assert "display: none;" in m.group(1)
def test_mobile_block_carries_the_edit_btn_44px_floor() -> None:
"""The mobile block (the ≤640px media query) carries
``.edit-btn { min-height: 44px; }`` beside the existing
``.tune-btn`` / ``.retry-btn`` 44px floors — the Edit action is
a ≥44px touch target on mobile (WCAG 2.1 AA / PLAN §7.1)."""
css = _css()
idx_retry = css.index(".retry-btn { min-height: 44px; }")
idx_edit = css.index(".edit-btn { min-height: 44px; }")
assert idx_edit != -1, "the mobile block must carry the .edit-btn 44px floor"
assert 0 < idx_edit - idx_retry < 40, (
"the .edit-btn floor sits beside the .retry-btn floor (the mobile "
"block)"
)
# ---------------------------------------------------------------------------
# Task 04 — the remaining "NOT touched" regression pins: handleSend +
# rememberBrainTurn (the send path and the brain-record funnel)
# ---------------------------------------------------------------------------
#: The FULL source of ``handleSend`` as of phase 159 — the form-level
#: pre-work (the in-flight Stop branch, the empty/4000-char guards, the
#: phase-123 attached-image upload-first, the input clear +
#: ``runTurn(text, { reask: false, image })``). The phase edits the
#: LAST question through the in-bubble editor — it never routes
#: through ``handleSend``, which stays byte-unchanged. (The pin
#: matches ``_fn_source``'s extraction: the ``async`` prefix is not
#: part of the matched ``function handleSend(`` substring.)
PINNED_HANDLE_SEND = """function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
// button (setUiState keeps it enabled) — a click or an Enter-to-submit
// aborts the in-flight turn instead of starting a new one.
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) {
stopTurn();
return;
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
// Phase 104 (owner 2026-09-12, A5): maxlength caps typing + pastes, but
// a programmatic fill (the chip one-tap path) bypasses it — this guard
// is the never-stale backstop (PLAN §7.4): no turn, no clear, the user
// trims the kept text (out-of-turn banner, the saveAsDoc precedent).
if (text.length > MAX_QUESTION_CHARS) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 123 (task 02, TODO L6; locked A8): an attached image uploads
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
// send and the typed question stays exactly where the user left it
// (the question is never sent without the image the user attached;
// the banner says what failed, the attachment stays for the retry).
// The returned STORED path (never the bytes, A5) then rides runTurn
// into the record + the request body.
let image = null; // { path, src, alt } | null — null = text-only send
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
// Phase 132 (task 01, TODO L8): the uploading state is entered
// exactly ONCE per upload — after the guard, before the await —
// so the gap between the press and the resolution is never
// frozen-looking (the strip's spinner + "Uploading image…", the
// disabled "Uploading…" Send).
enterUploadState();
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
// Phase 132: exited on BOTH resolution paths — the helper never
// throws, so this line is reached on success AND failure. Before
// the success path continues to runTurn (whose setUiState takes
// the button over) and before the failure path returns below
// (banner + editable composer + the attachment for the retry).
exitUploadState();
if (path === null) return; // A8: the send is blocked — the question stays
image = {
path, // the record + the /api/chat body (A5: the path, never base64)
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
};
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false, image });
}"""
#: The FULL source of ``rememberBrainTurn`` as of phase 159 — the
#: brain-record funnel (replace-or-append + ``saveConversation`` + the
#: phase-55 auto-save). The re-ask's settle rides it UNCHANGED: the
#: truncation happens before the new turn starts, so the funnel sees
#: the post-splice array (the task-03 ordering note).
PINNED_REMEMBER_BRAIN_TURN = """function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}"""
def test_handle_send_is_byte_unchanged() -> None:
"""``handleSend`` is byte-unchanged by phase 159 (the "NOT
touched" contract, task-04 pin): the edit flow is the in-bubble
editor — ``handleSend`` stays the plain send's pre-work (the
in-flight Stop branch, the empty / >4000-char guards, the
phase-123 upload-first, the input clear, the
``runTurn(text, { reask: false, image })`` call). The
composer-draft survival (task 02) holds because the save handler
never touches ``input.value`` — ``handleSend``'s own clear is the
send path only."""
assert _fn_source(_js(), "handleSend") == PINNED_HANDLE_SEND
def test_remember_brainturn_is_byte_unchanged() -> None:
"""``rememberBrainTurn`` is byte-unchanged by phase 159 (the
"NOT touched" contract, task-04 pin): the re-ask's settle persists
the fresh brain record through this EXACT funnel (the
replace-or-append + ``saveConversation`` + the phase-55
``persistConversation`` ride) — the phase adds no parameter and
restructures nothing (the truncation-ordering note, pinned in
``test_truncation_ordering_note_pins_the_remember_brainturn_safety``,
is what keeps the funnel's index bookkeeping sound across the
edit)."""
assert _fn_source(_js(), "rememberBrainTurn") == PINNED_REMEMBER_BRAIN_TURN
# The funnel's two persistence rides — the contract the edit's
# truncated shape depends on (save point 2 + the auto-save).
body = _fn_body(_js(), "rememberBrainTurn")
assert "saveConversation();" in body
assert "persistConversation();" in body