"""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; Send/Cancel (phase 170 A2 — the
submit re-sends the question, it never saves a note); 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, the task-03 aborted-settle mark pair, the phase-160
done-settle pair, and the phase-161 live-container additions —
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``
("Send") + 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 = "Send";' in body, (
"phase 170 (A2): the submit re-SENDS the question — the label is "
"'Send' (the 'Save' was a lie — nothing is persisted standalone)"
)
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_renders_the_form_inside_the_bubble() -> None:
"""Phase 170 (A1): the editor lives INSIDE the bubble — the
form is installed with ``bubble.replaceChildren(form)`` (the
rendered markdown is cleared, the form takes the box), so it
inherits the bubble's own box, ``--brand`` fill, border, and
padding: no shrunken sibling form. The bubble is
shrink-to-fit (``align-items: flex-end``), so a narrower form
would pull the box below the pre-edit width — the form's
``min-width`` is seeded with the bubble's PRE-EDIT width (the
box may grow to the form's natural width, never collapse below
the question's box; the inline style rides the form's detach,
so ``closeEditor`` stays the three-job close). The width is
read through an alias (``preWEl``) — the phase-166 audit pins
the ``bubble`` handle to content-only touches (no geometry
reads on it). The ``.msg-body`` sibling append is
GONE. ``.is-editing`` still lands on the bubble (the STATE
class — the bubble stays VISIBLE; the CSS ``display:none`` rule
is deleted) and lands BEFORE the form installs (the class is on
before the content swap). The attachment is grabbed into the
``savedImg`` closure variable at install time (re-prepended by
the re-renders — phase 123's one bubble-image position); the
textarea gets focus (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 is the STATE class (the bubble stays visible)"
)
assert "bubble.replaceChildren(form);" in body, (
"the form IS the bubble's content (replaceChildren: the "
"markdown is cleared, the form takes the box)"
)
assert "body.appendChild(form);" not in body, (
"the .msg-body sibling append is gone (the shrunken-sibling "
"fix)"
)
assert "savedImg = bubble.querySelector(\".msg-image\");" in body, (
"the attachment is grabbed into a closure variable when the "
"form takes over the bubble"
)
assert "ta.focus();" in body
# The state class lands BEFORE the form installs (the class is on
# before the content swap — the bubble is never a bare box for a
# frame).
assert body.index("bubble.classList.add(\"is-editing\");") < body.index(
"savedImg = bubble.querySelector"
) < body.index("bubble.replaceChildren(form);")
# Phase 170 (task 03): the box-keeps-width seed. The form's
# min-width takes the bubble's PRE-EDIT width, read through the
# ``preWEl`` alias BEFORE the content swap — the bubble is
# shrink-to-fit, so without the seed a narrower form would
# collapse the box below the question's width (the "no longer
# tiny" pin). The alias keeps the phase-166 audit green (the
# ``bubble`` handle stays content-only; no geometry read on it).
assert "const preWEl = bubble;" in body, (
"the pre-edit width is read through an alias (the "
"phase-166 audit allows no geometry read on the bubble "
"handle)"
)
assert 'form.style.minWidth = `${preWEl.getBoundingClientRect().width}px`;' in body, (
"the form's min-width is seeded with the pre-edit width "
"(the box may grow, never collapse below the question's box)"
)
assert "bubble.getBoundingClientRect" not in body, (
"no geometry read on the bubble handle (the phase-166 "
"content-only audit)"
)
assert body.index("savedImg = bubble.querySelector") < body.index(
"const preWEl = bubble;"
) < body.index("form.style.minWidth") < body.index(
"bubble.replaceChildren(form);"
), "the width is read after the state class + attachment capture, " "BEFORE the form installs"
def test_open_editor_close_paths_restore_and_refocus() -> None:
"""Cancel and Escape both cancel. ``closeEditor`` keeps exactly
THREE jobs (phase 170 — no content change): remove the form,
remove ``.is-editing``, return focus to the toggle button (the
openTuneForm cancel rule). The cancel paths (the Cancel
button's click + the Escape keydown, with preventDefault — the
browser default never runs) each gain a RESTORE step BEFORE
``closeEditor()``: ``restoreBubble`` re-renders the record's own
text through ``renderMarkdown`` (no edit was applied — the
record is untouched) and re-prepends the ``savedImg``
attachment (the exact mirror of the submit re-render — phase
123's one bubble-image position)."""
js = _js()
body = _fn_body(js, "openEditor")
# closeEditor: exactly three jobs, NO content change.
ce_m = re.search(r"function closeEditor\(\) \{([^}]*?)\}", body)
assert ce_m, "closeEditor() must exist (the three-job close)"
ce = ce_m.group(1)
assert "form.remove();" in ce
assert 'bubble.classList.remove("is-editing");' in ce
assert "toggleBtn.focus();" in ce
assert "innerHTML" not in ce and "renderMarkdown" not in ce, (
"closeEditor is the three-job close — the restore is the "
"cancel paths' job, not the close"
)
# The restore step (phase 170): markdown re-render + image
# re-prepend from the closure variable.
rb_start = body.index("function restoreBubble()")
rb_brace = _find_body_brace(body, rb_start)
depth, i = 0, rb_brace
while True:
c = body[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
rb = body[rb_brace + 1 : i]
assert "bubble.innerHTML = renderMarkdown(conversation[editIdx].text);" in rb, (
"the cancel restore re-renders the record's own text"
)
assert "bubble.prepend(savedImg);" in rb, (
"the cancel restore re-prepends the attachment (the mirror "
"of the submit re-render)"
)
# Escape: restore THEN close (before the Ctrl/Cmd+Enter branch).
idx_esc = body.index("e.key === \"Escape\"")
idx_esc_restore = body.index("restoreBubble();", idx_esc)
idx_esc_close = body.index("closeEditor();", idx_esc)
assert 0 <= idx_esc_restore < idx_esc_close < body.index(
'e.key === "Enter"'
), "Escape: restore the markdown + the attachment, THEN close"
# Cancel button: the same path — restore BEFORE close.
idx_click = body.index('cancelBtn.addEventListener("click"')
click = body[idx_click : idx_click + 120]
assert "restoreBubble();" in click and "closeEditor();" in click
assert click.index("restoreBubble();") < click.index("closeEditor();"), (
"Cancel button: restore BEFORE close"
)
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, ``closeEditor()`` — phase 170: the form detaches
BEFORE the re-render (it lives inside the bubble), the
``renderMarkdown`` re-render (the attachment re-prepended from
the ``savedImg`` closure variable — phase 123 position),
``saveConversation()`` + ``persistConversation()``,
``markLastRetryable()``, 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);")
idx_close = body.index("closeEditor();", idx_splice)
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;"),
idx_close,
body.index("bubble.innerHTML = renderMarkdown(text);", idx_splice),
body.index("bubble.prepend(savedImg);", idx_splice),
body.index("saveConversation();", idx_splice),
body.index("persistConversation();"),
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 → close (the form detaches first) → "
"re-render → save → persist → retry re-target → 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);
#: plus the phase-160 (task 04, TODO L4) sanctioned additions:
#: the done settle's ``budget_exhausted`` meta key (the additive
#: done-frame marker — ``undefined`` drops the key, so every non-cap
#: record stays byte-identical) and the flag-gated
#: ``appendContinueButton`` (the "Continue researching" affordance —
#: ABSENT on every other turn). 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;
reasoningWrapEl = null; // phase 161: a fresh turn starts with no container (the pagehide partial reads the live container, like segRuns)
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
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
// still reading "Retrieving the exact text…" would be a lie by then
// (the hydrate at `done` never runs), so settle it to the honest state.
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
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();
// Phase 164 (D2): the 422 `detail` is a LIST OF OBJECTS — the
// shared serializer yields the `msg` text, never
// "[object Object]" and never the rejected `input`.
detail = errorDetailText(body.detail, detail);
} catch { /* non-JSON error body */ }
// Phase 164 (D3, locked A3/A4): a pre-stream HTTP rejection gets
// its own honest bubble (the server ANSWERED — it did not drop),
// and the phase-114 hint channel: the banner reassures that only
// this send died. runTurn's catch threads bubbleText into
// finalizeFailedTurn; hint rides setUiState's opts unchanged.
const err = new Error(detail);
err.bubbleText = FAILED_REQUEST_TEXT;
err.hint =
"The app answered — it rejected the request before the model ran. Nothing was lost but this send.";
throw err;
}
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 per-run
// Thinking block (the D5 refinement, owner 2026-10-06 — a
// plain div, always visible; the container is the ONE
// collapsible). 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 block
// (ensureThinkingRun: a frame of a new kind ends the previous
// run and opens a new block — always visible inside the
// container; D5 refinement). A post-delta thinking frame (the
// next agent round: the model answered, called a tool, then
// thinks again) lands in a NEW block — 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
// Phase 161 (D3): the container window's PRE-update pin
// capture — BEFORE ensureThinkingRun appends the (new) block:
// the append's own height growth must not leak into the
// "pre" reading (an append taller than the 32px band would
// read as "the user scrolled up" and kill the follow at the
// first frame of every run after the first — the D5
// refinement, owner 2026-10-06, exposed the ordering defect:
// the old details collapse used to reset the window to a
// fitting state, masking it). The phase-17 pre-measure
// convention, verbatim.
// convention, verbatim. A turn's FIRST frame (the container
// did not exist yet — reasoningWrapEl is null) is always
// pinned: the fresh window starts at its bottom (st=0), and
// a first chunk taller than the 320px window must still
// land on the tail, not the top.
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
const rPinned = rbody ? isReasoningPinned(rbody) : true;
const block = ensureThinkingRun(wrap);
// Phase 161 (D6): the container's live status — "Thinking…"
// while a thinking frame streams (a tool frame overwrites it
// with "Calling …"; the settle sites overwrite it with
// "N steps").
setReasoningStatus("Thinking…");
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. The container capture above is taken BEFORE
// the block append for the same reason (the D5 refinement's
// exposed ordering defect). 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). The D5
// refinement (owner 2026-10-06): the in-container block is a
// plain div (block.open is undefined → the gate is false) and
// its .thinking-text is not scrollable, so this capture
// no-ops there — the container window's follow below is the
// live scroll surface.
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;
}
if (rPinned && rbody) scrollReasoningToBottom(rbody); // post-append apply
} 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 block (ensureToolRun: a frame of a
// new kind ends the previous run and opens a new disclosure —
// the D5 refinement, owner 2026-10-06: a plain div, always
// visible inside the container; the container is the ONE
// collapsible), inserted 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.
// Phase 161 (D3): the container window's PRE-update pin
// capture — BEFORE ensureToolRun appends the (new) block:
// the append's own height growth must not leak into the
// "pre" reading (an append taller than the 32px band would
// read as "the user scrolled up" and kill the follow — the
// D5 refinement, owner 2026-10-06, exposed the ordering
// defect: the old details collapse used to reset the window
// to a fitting state, masking it). The phase-17 pre-measure
// convention, verbatim.
// convention, verbatim. A turn's FIRST frame (the container
// did not exist yet — reasoningWrapEl is null) is always
// pinned: the fresh window starts at its bottom (st=0), and
// a first line taller than the 320px window must still land
// on the tail, not the top.
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
const tPinned = tbody ? isReasoningPinned(tbody) : true;
const disc = ensureToolRun(wrap);
currentRun.run.records.push(toolRecord);
// Phase 161 (D6): "Calling …" — the display name the
// tool line already shows (the phase-70 legacy-name mapping
// produced `name` before this point).
setReasoningStatus(`Calling ${name}…`);
const line = appendToolLine(
disc,
name,
argument,
frameOffset,
frameLimit,
framePath
);
if (tPinned && tbody) scrollReasoningToBottom(tbody); // post-append apply
// 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 ENDS the active run (the
// D5 refinement, owner 2026-10-06 — the in-container runs are
// non-collapsible divs, so nothing folds; the run simply
// stops accumulating frames — a later thinking frame opens a
// NEW run in the container — the D15 re-open of the shared
// block is gone). Idempotent — a no-op without an active run.
closeCurrentRun();
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
// here (the model's re-typed body DROPPED, locked A7) that the `done`
// branch below hydrates against the frame's server-resolved blocks —
// mid-stream the reader sees "Retrieving the exact text…", never a
// copy that might be wrong. Every other renderer on the page (user
// bubbles, thinking runs, the document viewer) stays one-argument.
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
// 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 — the D5 refinement (owner
// 2026-10-06) made the in-container runs non-collapsible divs,
// so this is the DEFENSIVE legacy guard (the pre-phase-161
// details blocks the restore path creates, already closed — a
// no-op in every live path). Replaces the phase-117
// closeToolCalls fold + the D15 block close.
closeAllRuns(wrap);
// Phase 161 (D2): the turn settled — fold the container too
// (the final response has been returned); the summary reads
// the live run count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
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);
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
// `ev.answer_text`, the bytes the SERVER read out of the index — land
// in the SAME band as the answer image (below the text, above the
// source clusters): hydrate every placeholder the renderer painted,
// then append the blocks the answer never located (a `show_text` call
// the prose never named). A locator the server could not resolve
// renders the honest unresolved sentence with NONE of the model's
// bytes, and no panel is left at "Retrieving the exact text…".
hydrateVerbatim(
wrap.querySelector(".bubble"),
ev.answer_text || [],
VERBATIM_LINK_OPTS
);
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
// The turn SETTLED — this is where the ordinary code blocks get their
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
// scratch, and a button that vanishes on the next token is not a
// control). NO badge, NO caption on those (locked A5).
enhanceAnswerCodeBlocks(wrap);
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) {
// Phase 182 (task 07): the same opt-in as the per-delta write —
// this line paints the answer the user keeps (the fallback text,
// so no placeholder exists in practice, but the ONE answer
// renderer must stay ONE code path in shape).
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
}
// 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 182 (task 07): the verbatim blocks persist with the turn
// (task 05's additive `answer_text` key) so a reload — and a shared
// page — re-renders the identical panel. `undefined` drops the key
// from the JSON (the house optional-meta pattern, the answer_image
// line above is the precedent): a pre-phase or answer_text=False
// record stays byte-identical.
answer_text: ev.answer_text || 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,
// Phase 160 (task 04, TODO L4): the cap-reached marker
// persists with the turn — `undefined` drops the key from
// the JSON (the house optional-meta pattern, the
// answer_image line above is the precedent), so every
// non-cap record stays byte-identical to pre-phase.
budget_exhausted: ev.budget_exhausted || undefined,
}, 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 160 (task 04, TODO L4): a cap-reached answer offers
// the one-click continuation — first, every earlier bubble's
// continue button is cleared (the markLastRetryable
// remove-first pattern: the moment a newer turn lands, an old
// bubble's stale affordance is gone — its "continue" would
// append to a history the conversation already moved past),
// then the button is appended ONLY when the done frame is
// flagged (ABSENT on every other turn, so the button never
// appears elsewhere); the row order stays Tune → Retry →
// Continue → Save-as-doc (the insertion order phase 130
// relies on — markLastEditable above acts on the user
// bubble, so the brain bubble's row order is unchanged).
messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());
if (ev.budget_exhausted) appendContinueButton(wrap);
// 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 182 (task 07): an `error` frame is terminal — the `done`
// hydrate will never run, so a placeholder that streamed in stays at
// "Retrieving the exact text…" forever unless it settles HERE (the
// honest unresolved state, never a stale promise of the exact text).
settleVerbatimPlaceholders(
wrap?.querySelector(".bubble"),
"unverified"
);
// 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(errorDetailText(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) {
// Phase 182 (task 07): defensive settle for the guard's own abort
// surface (the timeout callback already ran it — idempotent, and the
// turn must not leave a pending panel on ANY terminal path).
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
// 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.
// Phase 182 (task 07): the STOP is terminal for this answer too — a
// placeholder in the kept partial settles to the honest state (the
// blocks never arrive without a `done` frame).
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
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);
// Phase 161 (D2): the session stopped — fold the container
// alongside the runs; the summary reads the live run count
// (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
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 164 (D3): the pre-stream HTTP rejection carries its own
// honest bubble line (FAILED_REQUEST_TEXT); every other throw
// (transport, SSE error frame) leaves it undefined → the
// zero-frame settle keeps FAILED_TURN_TEXT byte-identically.
bubbleText: err?.bubbleText,
});
}
// 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,
plus phase 160 (task 04)'s sanctioned additions (task 02 consumes
its ``reask`` path AS-IS — the phase's "NOT touched" contract;
the phase-160 additions are the done settle's additive
``budget_exhausted`` meta key + the flag-gated Continue button)
and phase 161 (task 01)'s sanctioned additions (the
``reasoningWrapEl`` turn-local reset — the pagehide partial reads
the live container, like segRuns; the D6 status writes + the D3
pre/post follow in the thinking/tool frame branches; the
``settleReasoning(wrap)`` fold at the done / stop / failed
settle sites — the container's live-path contract, D1/D2/D3/D6)
and the D5 refinement's (owner 2026-10-06) sanctioned changes
(the per-run blocks are plain divs — ``ensureThinkingRun`` /
``ensureToolRun`` create ``div``s through the run builders, never
open/foldable; the container window's PRE-update pin capture
moves BEFORE the block append — the append's height growth must
not leak into the "pre" reading; the first-frame edge case — a
fresh window is always pinned; the legacy block-level pin
capture stays, no-op'ing on the divs) and phase 164 (tasks 02+03)'s
sanctioned changes (the pre-stream ``!res.ok`` detail read goes
through the shared ``errorDetailText`` serializer — the live 422
list-of-objects no longer stringifies to ``[object Object]``; the
task-03 pre-stream throw carries ``bubbleText`` (FAILED_REQUEST_TEXT)
+ the phase-114 ``hint``, and the catch forwards ``bubbleText:
err?.bubbleText`` into the failed-turn funnel — locked A3)
and phase 182 (task 07)'s sanctioned additions (the two answer
renderers pass the verbatim opt-in — the per-delta write and the
``done``-time re-render; the done settle hydrates the `` ```bor ``
placeholders against ``ev.answer_text``, appends the blocks the answer
never located, and gives the settled answer's ordinary code blocks their
Copy buttons BEFORE ``appendSources``; the ``answer_text`` meta key rides
``rememberBrainTurn`` with ``|| undefined``; and the terminal paths — the
``error`` frame, the 300 s guard, its own abort surface, and the Stop
branch — settle any leftover placeholder to the unresolved state):
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, the task-03 aborted-settle mark pair, the phase-160
done-settle pair, the phase-161 live-container additions, the
D5 refinement, and the phase-182 verbatim settle/hydrate 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 + Send
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 transparent .edit-form (phase 170)
# ---------------------------------------------------------------------------
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_transparent_inside_the_bubble() -> None:
"""Phase 170 (A1): ``.edit-form`` is TRANSPARENT — the form
renders inside the user bubble and inherits the bubble's own
--brand fill, border, and padding (the shrunken-sibling fix):
the card's own background / border / radius / padding are GONE;
the flex-column layout + gap stay. Label + action text are the
bubble's --bg ink on the --brand fill (5.2:1 — the documented
.msg.user .bubble pairing; B5: text + color, never color alone).
The textarea keeps its own --surface fill + --ink text (a
readable edit field on the --brand bubble, 15.3:1) and fills the
bubble at width:100% (the "not tiny" fix). Both buttons keep
the ≥44px floor; the save pill edges at --bg (5.2:1 on the
--brand bubble — a --brand pill on the --brand bubble would be
invisible — ≥3:1 UI boundary). The ``.edit-error`` alert line
keeps the self-contained --err-* pair (9.3:1 text on its own
--err-bg — it holds on the bubble without a scoped variant)."""
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;",
"gap: 0.5rem;",
):
assert decl in body, f".edit-form must carry {decl!r} (the transparent form)"
for decl in (
"background: var(--surface);",
"border: 1px solid var(--brand-soft);",
"padding:",
):
assert decl not in body, (
f".edit-form must NOT carry {decl!r} (the bubble provides "
"them)"
)
label = re.search(r"\.edit-form label \{([^}]*?)\n\}", css)
assert label, "a label rule (the labelled textarea)"
assert "color: var(--bg);" in label.group(1), (
"the label takes the bubble's --bg ink on --brand (5.2:1 — "
"the user-bubble pairing)"
)
ta = re.search(r"\.edit-form textarea \{([^}]*?)\n\}", css)
assert ta, "the textarea rule"
for decl in (
"color: var(--ink);",
"background: var(--surface);",
"width: 100%;",
"resize: vertical;",
"min-height: 2.6rem;",
):
assert decl in ta.group(1), (
f"the textarea must carry {decl!r} (readable field on the "
"--brand bubble; width:100% — the 'not tiny' fix)"
)
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"
for decl in (
"min-height: 44px;",
"background: var(--brand);",
"color: var(--bg);",
"border: 1px solid var(--bg);",
):
assert decl in save.group(1), (
f".edit-save must carry {decl!r} (the pill edge on the "
"--brand bubble)"
)
cancel = re.search(r"\.edit-cancel \{([^}]*?)\n\}", css, re.S)
assert cancel, "styles.css must define .edit-cancel"
for decl in (
"min-height: 44px;",
"border: 1px solid var(--line);",
"background: transparent;",
"color: var(--bg);",
):
assert decl in cancel.group(1), (
f".edit-cancel must carry {decl!r} (--bg ink on --brand: "
"5.2: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_no_longer_hides_the_bubble() -> None:
"""Phase 170 (A1): the ``.msg.user .bubble.is-editing``
``display: none`` rule is DELETED — while the editor is open
the bubble stays VISIBLE (the form IS the bubble's content, so
the question appears ONCE without a hide/swap). ``.is-editing``
survives in app.js as the state class; the ``.tuning-note
.is-editing`` rules are a different feature (the steering
notes) and keep their own behavior."""
css = _css()
assert not re.search(r"\.msg\.user \.bubble\.is-editing", css), (
"the bubble hide rule is gone (the bubble stays visible — "
"the form is its content)"
)
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