Files
brain-of-reese/tests/unit/test_frontend_edit_message.py
T
ducoterra 34d60efbc1
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 29s
phase: 164_chat_error_honesty
All gates green. Report:

- **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`.
- **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched).
- **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate.
- **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed.
- **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only).
- **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
2026-10-07 18:00:12 -04:00

2006 lines
102 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: the edit-and-resend frontend contract (phase 159, tasks 01–04).
Pins the phase-159 task-01 + task-02 client contract as source
assertions (the house ``test_frontend_*`` style — no browser): the Edit
button joins the bubble's ``.msg-actions`` row (getActionsRow — the
phase-130 house pattern) as a listitem of the ``role=list`` row,
``markLastEditable`` removes every rendered button FIRST and then
targets exactly ONE bubble — the newest question (the phase-49
single-target discipline, one ``who`` over — bailing on the in-flight
guard, a missing user record, and a missing tracked wrap),
``lastUserRecordIndex`` is a newest-first ``conversation`` scan,
``lastUserWrap``'s lifecycle sites, ``openEditor`` renders the
``openTuneForm`` family (a labeled ``textarea[maxlength]`` bound to
``MAX_QUESTION_CHARS`` — the 4000 cap stays single-sourced — prefilled
through ``.value``, never an HTML string; Save/Cancel; a ``role=alert``
rejection line), the Cancel / ``Escape`` restore paths, the
Ctrl/Cmd+Enter submit, the empty-guard rejection (the form stays
open), the in-flight save no-op, and the task-02 SAVE = replace +
truncate + re-ask flow: the guards (in-flight first, then the empty
trim) run before the record edit; the trimmed text replaces the record
in place; ``conversation.splice(editIdx + 1)`` drops the whole tail;
the matching DOM bubbles are removed from ``lastUserWrap.nextSibling``
onward; ``lastBrainWrap = null`` + the phase-73 ``leavePartialIndex``
reset (when it pointed past ``editIdx``) precede the re-ask;
``saveConversation()`` + ``persistConversation()`` persist the
truncated shape before the re-ask; ``markLastRetryable()`` re-targets
(clears — no brain record remains); the editor closes; the double
in-flight check sits on one synchronous stretch (no ``await``); and
the re-ask is exactly ``runTurn(text, { reask: true })`` — no image
(the phase-49/123 A7 text-only rule). The composer stays untouched.
And — the phase's explicit "NOT touched" contract — ``retryLastTurn``
is byte-unchanged, ``runTurn`` is pinned at exactly the state the
phase left it (its sanctioned additions — the task-01
``lastUserWrap`` assignment + the idle-settle ``markLastEditable()``
mark pass, 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 + "<span>Edit</span>";' 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 ``<label for>`` paired
with the textarea's ``id`` (a unique ``edit-input-N`` from the
module-level ``editSeq`` — the ``tuneSeq`` precedent), a textarea
with ``rows="3"`` and ``maxlength = MAX_QUESTION_CHARS`` (the 4000
cap stays single-sourced — NEVER a second literal), the prefill
through ``.value`` (NEVER an HTML string — the house
no-injection rule), the composer's placeholder reused when one
exists, a ``.edit-form-actions`` row with a submit ``.edit-save``
("Save") + a ``type=button`` ``.edit-cancel`` ("Cancel"), and a
``role=alert`` ``.edit-error`` line (hidden)."""
js = _js()
body = _fn_body(js, "openEditor")
assert 'document.querySelectorAll(".edit-form").forEach((f) => f.remove());' in body, (
"one editor at a time"
)
assert 'form.className = "edit-form";' in body
assert "form.noValidate = true;" in body
assert "let editSeq = 0;" in js, "module-level editSeq (the tuneSeq precedent)"
assert "editSeq += 1;" in body
assert "const inputId = `edit-input-${editSeq}`;" in body
assert 'label.setAttribute("for", inputId);' in body
assert 'label.textContent = "Edit your question";' in body
assert "ta.id = inputId;" in body, "the label's for pairs the textarea's id"
assert 'ta.setAttribute("rows", "3");' in body
assert "ta.setAttribute(\"maxlength\", String(MAX_QUESTION_CHARS));" in body, (
"the maxlength is bound to MAX_QUESTION_CHARS (single-sourced — the "
">4000 case is unreachable)"
)
assert "ta.value = conversation[editIdx].text;" in body, (
"the prefill goes through .value from the record — never an HTML "
"string (the house no-injection rule)"
)
assert "if (input && input.placeholder) ta.placeholder = input.placeholder;" in body, (
"the composer's placeholder is reused when one exists"
)
assert 'actions.className = "edit-form-actions";' in body
assert "editSaveBtn.type = \"submit\";" in body
assert 'editSaveBtn.className = "edit-save";' in body
assert 'editSaveBtn.textContent = "Save";' in body
assert "cancelBtn.type = \"button\";" in body
assert 'cancelBtn.className = "edit-cancel";' in body
assert 'cancelBtn.textContent = "Cancel";' in body
assert 'status.className = "edit-error";' in body
assert 'status.setAttribute("role", "alert");' in body
assert "status.hidden = true;" in body
def test_open_editor_hides_the_bubble_while_open() -> None:
"""While open the rendered ``.bubble`` gets ``.is-editing`` (the
CSS hides it — the question appears ONCE, never twice); the
editor appends into the bubble's ``.msg-body`` and focuses the
textarea (the openTuneForm convention)."""
js = _js()
body = _fn_body(js, "openEditor")
assert 'const bubble = wrap.querySelector(".bubble");' in body
assert "bubble.classList.add(\"is-editing\");" in body, (
".is-editing lands on the bubble while the form is open"
)
assert "body.appendChild(form);" in body
assert "ta.focus();" in body
# The hide is applied BEFORE the form appends (the question must not
# be visible twice for a frame).
assert body.index("bubble.classList.add(\"is-editing\");") < body.index(
"body.appendChild(form);"
)
def test_open_editor_close_paths_restore_and_refocus() -> None:
"""Cancel and Escape both cancel: ``closeEditor`` removes the
form, removes ``.is-editing`` (restoring the rendered bubble), and
returns focus to the toggle button (the openTuneForm cancel
rule). Cancel is the button's click; Escape is the textarea's
keydown (with preventDefault — the browser default never runs)."""
js = _js()
body = _fn_body(js, "openEditor")
assert "function closeEditor() {" in body
assert "form.remove();" in body
assert 'bubble.classList.remove("is-editing");' in body
assert "toggleBtn.focus();" in body
assert "cancelBtn.addEventListener(\"click\", closeEditor);" in body
# The Escape key (the keydown handler)
assert "e.key === \"Escape\"" in body
idx_esc = body.index("e.key === \"Escape\"")
idx_esc_close = body.index("closeEditor();", idx_esc)
assert idx_esc_close != -1 and idx_esc_close < body.index(
'e.key === "Enter"'
), "Escape cancels (the close before the Ctrl/Cmd+Enter branch)"
assert "e.preventDefault();" in body
def test_open_editor_ctrl_cmd_enter_submits() -> None:
"""Ctrl/Cmd+Enter in the textarea submits the form (
``form.requestSubmit()`` — the standard submit path, so the
handler's guards run exactly once)."""
js = _js()
body = _fn_body(js, "openEditor")
assert "e.key === \"Enter\" && (e.ctrlKey || e.metaKey)" in body
idx = body.index("e.key === \"Enter\" && (e.ctrlKey || e.metaKey)")
assert body.index("form.requestSubmit();", idx) != -1
def test_open_editor_save_bails_when_in_flight() -> None:
"""The submit handler re-checks the in-flight guard BEFORE any
save work (the in-flight discipline): a save can never start a
second turn (task 02's re-ask rides the same guard) — the handler
is a no-op (the form stays open; the user can Cancel or wait for
the settle)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_submit = body.index("form.addEventListener(\"submit\"")
handler = body[idx_submit:]
idx_guard = handler.index(
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
idx_trim = handler.index("const text = ta.value.trim();")
assert 0 <= idx_guard < idx_trim, (
"the in-flight guard precedes the save work (no-op — the form "
"stays open)"
)
# The editor's OPEN does not bail in-flight — the stale-click guard
# is the only open-time bail (a button left from a previous settle
# is still usable; the save guard + the settle re-mark cover the
# rest).
open_head = body[: body.index("const body = wrap.querySelector")]
assert "UI_STATE.thinking" not in open_head, (
"openEditor's open-time bails are the stale-click guard + the "
"no-body/no-record bails — NOT the in-flight guard"
)
def test_open_editor_save_empty_guard_keeps_the_form() -> None:
"""A whitespace-only save is rejected in-form: the trimmed text is
empty → the ``.edit-error`` line shows "Ask something first — the
question can't be empty." and the form STAYS open (the early
return precedes the replace work — the note is never lost, the
openTuneForm failure way). The schema backstop:
``ChatRequest.message`` / ``ChatMessage.text`` both require
``min_length=1``."""
js = _js()
body = _fn_body(js, "openEditor")
idx_trim = body.index("const text = ta.value.trim();")
idx_empty = body.index("if (!text) {", idx_trim)
idx_replace = body.index("conversation[editIdx].text = text;")
assert idx_trim < idx_empty < idx_replace, (
"the empty guard runs between the trim and the replace"
)
assert "status.textContent = \"Ask something first — the question can't be empty.\";" in body
# Inside the empty branch: reveal the alert + return (form kept).
empty_block = body[idx_empty: idx_replace]
assert "status.hidden = false;" in empty_block
assert "return;" in empty_block
assert "form.remove()" not in empty_block, "the form is NOT removed"
def test_open_editor_save_runs_the_full_task_02_flow_in_order() -> None:
"""A valid save runs the task-02 flow in order (the task-01
replace + re-render + close pins survive inside it): the empty
trim, the in-place record replace, the record-tail splice, the DOM
bubble removal, the ``lastBrainWrap`` / ``leavePartialIndex``
resets, the ``renderMarkdown`` re-render (the attachment captured
before the wipe and re-prepended after — phase 123 position),
``saveConversation()`` + ``persistConversation()``,
``markLastRetryable()``, ``closeEditor()``, the double in-flight
check, and the ``runTurn(text, { reask: true })`` re-ask. The
task-01 seam marker is GONE (the seam is filled). No new live
region (the visible re-render is the feedback — the
``#send-status`` announcer stays the sole a11y announcer)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
positions = [
body.index("const text = ta.value.trim();"),
body.index("conversation[editIdx].text = text;"),
idx_splice,
body.index("let n = lastUserWrap.nextSibling;"),
body.index("lastBrainWrap = null;"),
body.index("if (leavePartialIndex > editIdx) leavePartialIndex = -1;"),
body.index('const img = bubble.querySelector(".msg-image");'),
body.index("bubble.innerHTML = renderMarkdown(text);"),
body.index("bubble.prepend(img);"),
body.index("saveConversation();", idx_splice),
body.index("persistConversation();"),
body.index("markLastRetryable();"),
body.index("closeEditor();", body.index("markLastRetryable();")),
body.index("return runTurn(text, { reask: true });"),
]
assert positions == sorted(positions) and len(set(positions)) == len(positions), (
"the save flow is: trim → replace → splice → DOM removal → "
"bookkeeping resets → re-render → save → persist → retry "
"re-target → close → re-ask"
)
assert "task 02: truncate the tail" not in body, (
"the task-01 seam marker is gone (the seam is filled)"
)
assert "announce" not in body, (
"no new live region — the visible re-render is the feedback"
)
def test_open_editor_save_truncates_the_record_tail_outright() -> None:
"""The tail truncation is ``conversation.splice(editIdx + 1)`` —
the WHOLE tail after the edited record (a single-argument splice
removes to the end), nothing before it: the edited record itself
is kept (replaced in place), and the records the OLD question
produced are dropped outright — never rewritten (the phase-114
whole-turn rule). The splice runs after the record replace and
BEFORE any persistence — the persisted shape is the truncated
one."""
js = _js()
body = _fn_body(js, "openEditor")
idx_replace = body.index("conversation[editIdx].text = text;")
idx_splice = body.index("conversation.splice(editIdx + 1);")
idx_save = body.index("saveConversation();", idx_splice)
assert idx_replace < idx_splice < idx_save, (
"replace → splice (the whole tail) → persist (the truncated "
"shape)"
)
assert "conversation.splice(editIdx)" not in body, (
"the edited record itself is kept — only the tail goes"
)
def test_open_editor_save_removes_the_dropped_bubbles_from_the_dom() -> None:
"""The DOM removal mirrors the record splice: it walks
``lastUserWrap.nextSibling`` onward and removes every trailing
sibling with the safe walk (``nextSibling`` captured BEFORE the
``.remove()``). It runs after the record splice and before the
persistence (the DOM and the record list are truncated as one
shape — #messages' direct children are the bubbles, in order, and
the empty-state div is a leading child, so the trailing siblings
are exactly the dropped tail)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
idx_walk = body.index("let n = lastUserWrap.nextSibling;")
assert idx_splice < idx_walk, "the DOM removal follows the record splice"
idx_loop = body.index("while (n) {", idx_walk)
assert idx_walk < idx_loop
loop_close = body.index("}", idx_loop)
loop_body = body[idx_loop : loop_close + 1]
assert "const next = n.nextSibling;" in loop_body, (
"the nextSibling is captured BEFORE the removal (the safe walk)"
)
assert "n.remove();" in loop_body
assert "n = next;" in loop_body
def test_open_editor_save_resets_the_turn_bookkeeping_before_the_re_ask() -> None:
"""The drop resets the turn bookkeeping BEFORE the re-ask:
``lastBrainWrap = null`` (the removed answer is no longer a retry
target — ``markLastRetryable``'s targeting keys off it) and
``leavePartialIndex`` is cleared to -1 (the safe value — "none")
WHEN IT POINTED PAST ``editIdx`` — a phase-73 pagehide partial
saved from the DROPPED turn must not be re-persisted into the new
shape (the settle's identity guard would otherwise fall back to
an append; a partial on or before the edited record is untouched,
and the next ``runTurn`` resets it turn-locally regardless). Both
resets precede the re-ask."""
js = _js()
body = _fn_body(js, "openEditor")
idx_reset = body.index("lastBrainWrap = null;")
idx_partial = body.index(
"if (leavePartialIndex > editIdx) leavePartialIndex = -1;"
)
idx_reask = body.index("return runTurn(text, { reask: true });")
assert idx_reset < idx_reask, "lastBrainWrap = null precedes the re-ask"
assert idx_partial < idx_reask, "the leavePartialIndex reset precedes the re-ask"
assert "leavePartialIndex > editIdx" in body, (
"the reset is gated on the pointer being past editIdx"
)
def test_open_editor_save_persists_the_truncated_shape_before_the_re_ask() -> None:
"""The truncated shape persists BEFORE the re-ask: BOTH
``saveConversation()`` (localStorage — a crash never resurrects the
pre-edit text or the stale tail; ``trimToBudget`` rides it
unchanged) and ``persistConversation()`` (the phase-55 auto-save —
fire-and-forget, never blocks the turn; a shortened messages array
is already legal — no new field, no migration, A10 stays
stateless) run after the splice and before ``runTurn``."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
idx_save = body.index("saveConversation();", idx_splice)
idx_persist = body.index("persistConversation();", idx_save)
idx_reask = body.index("return runTurn(text, { reask: true });")
assert idx_splice < idx_save < idx_persist < idx_reask, (
"splice → saveConversation → persistConversation → runTurn"
)
def test_open_editor_save_re_targets_the_retry_button_on_the_removed_tail() -> None:
"""After the drop, ``markLastRetryable()`` re-targets: no brain
record exists (and ``lastBrainWrap`` is null), so the call simply
clears every ``.retry-btn`` (its bail — the removed answer is no
longer a retry target). It runs before the re-ask; the re-ask's
settle re-appends the button on the fresh bubble through its own
``markLastRetryable`` / ``markLastEditable`` pair."""
js = _js()
body = _fn_body(js, "openEditor")
idx_mark = body.index("markLastRetryable();")
idx_reask = body.index("return runTurn(text, { reask: true });")
assert 0 <= idx_mark < idx_reask, ("the retry re-target precedes the re-ask")
def test_open_editor_save_re_asks_with_reask_true_and_no_image() -> None:
"""The re-ask is EXACTLY ``return runTurn(text, { reask: true })``:
``reask`` is the whole point (``runTurn`` skips the user-bubble
append + save point 1 — the question is never duplicated — and
``history = conversation.slice(0, -1)`` is exactly the prior turns
because the edited question is the LAST record), the promise is
RETURNED (the ``retryLastTurn`` convention — ``runTurn`` never
rejects; a failure lands in the phase-120 failed-turn path), and
NO ``image`` is passed (the phase-49/123 locked A7 rule: a re-ask
sends text only — the record keeps its image key, the bubble keeps
the attachment)."""
js = _js()
body = _fn_body(js, "openEditor")
calls = re.findall(r"runTurn\([^)]*\)", body)
assert calls == ["runTurn(text, { reask: true })"], (
"the handler's only runTurn call is the re-ask — exactly "
"runTurn(text, { reask: true }), no image argument"
)
def test_open_editor_save_is_one_synchronous_stretch_with_the_double_check() -> None:
"""The in-flight double-check: the handler is ONE synchronous
stretch from the guard at the top to the ``runTurn`` call (no
``await`` in between — the ``retryLastTurn`` discipline, which
earns its double-check exactly this way), and a SECOND in-flight
guard sits before the re-ask (a save can never start a second
turn)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_submit = body.index('form.addEventListener("submit"')
handler = body[idx_submit:]
guard = (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
assert handler.count(guard) == 2, "the double in-flight check (top + pre-re-ask)"
idx_guard1 = handler.index(guard)
idx_guard2 = handler.index(guard, idx_guard1 + 1)
idx_reask = handler.index("return runTurn(text, { reask: true });")
assert idx_guard1 < idx_guard2 < idx_reask, (
"the top guard, then the pre-re-ask guard, then the re-ask"
)
stretch = handler[idx_guard1:idx_reask]
# The no-await discipline is about the CODE — strip the line
# comments (which may word the invariant) before the check.
code = re.sub(r"//[^\n]*", "", stretch)
assert not re.search(r"\bawait\b", code), (
"the handler is one synchronous stretch — the double-check is "
"earned, not simulated"
)
def test_open_editor_save_never_touches_the_composer() -> None:
"""The composer is kept out of it (the task-02 work item): the
save handler never writes ``input.value`` (the user's unsent draft
survives an edit), and never calls ``autoGrow()`` or
``updateCharCount()`` — the composer's char-count display keeps
counting the DRAFT, not the edited question. The only composer
contact is the read-only placeholder reuse at form build time.
``MAX_QUESTION_CHARS`` stays the textarea's ``maxlength`` (the
server's ``ChatRequest.message max_length=4000`` remains the real
bound — a >4000 trim is unreachable)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_submit = body.index('form.addEventListener("submit"')
handler = body[idx_submit:]
assert "input.value" not in handler, (
"editing never touches the composer's draft (input.value)"
)
assert "autoGrow()" not in handler, ("the composer's autoGrow is untouched")
assert "updateCharCount()" not in handler, ("the char count keeps counting the draft")
assert "ta.placeholder = input.placeholder" in body, (
"the only composer contact is the read-only placeholder reuse"
)
def test_open_editor_stale_click_guard() -> None:
"""The click's wrap must still be ``lastUserWrap`` (the
phase-49 stale-click discipline, the ``retryLastTurn`` guard
mirror): a superseded bubble's button is inert. The guard is the
FIRST statement — before the one-at-a-time removal, so a stale
click never closes a live editor."""
js = _js()
body = _fn_body(js, "openEditor")
idx_guard = body.index("if (wrap !== lastUserWrap) return;")
idx_remove = body.index('document.querySelectorAll(".edit-form")')
assert 0 <= idx_guard < idx_remove, (
"the stale-click guard precedes the one-at-a-time removal"
)
# ---------------------------------------------------------------------------
# Call sites — the six pairs + the runTurn idle-settle mark pass
# ---------------------------------------------------------------------------
def test_the_mark_last_retryable_sites_pair_mark_last_editable() -> None:
"""Every settle/restore ``markLastRetryable()`` call site (the
seven: ``restoreConversation``, ``restoreSavedChatFromUrl``,
``finalizeFailedTurn`` — the funnel every non-OK-HTTP / SSE
``error``-frame turn settles through, the done settle, the
zero-frame fallback, the stop finalize, and the task-03 300s
guard ``aborted`` branch) is followed IMMEDIATELY by a
``markLastEditable()`` — the paired settle (the mid-turn sites
no-op on the in-flight guard; the two restore sites land the
button at boot; the aborted site lands it in the error state,
which the guard does not block). Task 02 adds an EIGHTH call
site — the save-handler re-target (after the tail drop it
clears the buttons; it is NOT paired: the re-ask's settle
re-marks through its own pair). Total: eight call sites, seven
paired."""
js = _js()
calls = js.count("markLastRetryable();")
assert calls == 8, (
f"eight call sites: the seven paired settles + the task-02 "
f"save-handler re-target (found {calls})"
)
paired = re.findall(
r"markLastRetryable\(\);[^\n]*\n\s*markLastEditable\(\);", js
)
assert len(paired) == 7, (
"the seven settle/restore sites are each immediately followed by "
"markLastEditable() (the paired settle)"
)
# Counting both calls: the editable sites are the seven pairs + the
# runTurn finally idle-settle mark pass (the at-rest landing — no
# retryable site there, the settle re-runs the editable mark only).
assert js.count("markLastEditable();") == 8, (
"eight markLastEditable() sites: the seven pairs + the finally "
"settle"
)
def test_runturn_idle_settle_marks_editable() -> None:
"""The terminal settle (``finally``) re-runs ``markLastEditable()``
AFTER the ``setUiState(UI_STATE.idle)`` — the ONE mark pass that
lands the button at rest (the four mid-turn paired sites inside
the event handlers no-op on the in-flight guard). Exactly ONE
``markLastEditable()`` inside the finally block (the settle — the
fresh-send block does not mark; the reask path keeps the existing
bubble + button). A New-chat-mid-turn is a no-op by construction
(the records + the wrap are cleared, so the scan/wrap bails)."""
js = _js()
rt = _fn_source(js, "runTurn")
finally_idx = rt.index("} finally {")
finally_block = rt[finally_idx:]
assert finally_block.count("markLastEditable();") == 1, (
"exactly one markLastEditable() inside the finally (the idle "
"settle mark pass)"
)
settle = rt.index("if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);")
mark = rt.index("markLastEditable();", finally_idx)
assert settle < mark, "the mark pass runs AFTER the idle settle"
# The four mid-turn paired sites (done / zero-frame / stop /
# task-03 aborted) live in the event handlers, BEFORE the finally
# (they no-op on the guard; the aborted one lands in the error
# state, which the guard does not block).
assert rt.count("markLastEditable();") == 5, (
"the four mid-turn paired sites + the finally settle — five "
"mark sites inside runTurn"
)
before_finally = rt[:finally_idx]
assert before_finally.count("markLastEditable();") == 4
# ---------------------------------------------------------------------------
# NOT touched — the phase's explicit contract (byte-pinned sources)
# ---------------------------------------------------------------------------
def test_retry_last_turn_is_byte_unchanged() -> None:
"""``retryLastTurn`` is byte-unchanged by phase 159 (tasks
01–02; the redo-in-place is REUSED, not extended — the phase's
"NOT touched" contract): the full source must match the
phase-120 pin (the shared constant — one pin, no drift). Task 02
consumes its ``runTurn(text, { reask: true })`` convention (the
returned promise) without touching the function."""
assert _fn_source(_js(), "retryLastTurn") == PINNED_RETRY_LAST_TURN
#: The FULL source of ``runTurn`` as of phase 159 task 01 — the
#: The FULL source of ``runTurn`` as of phase 159 — the phase-49
#: redo-in-place turn with the phase's SANCTIONED additions: the
#: task-01 fresh-send ``lastUserWrap`` assignment (work item 3), the
#: task-01 idle-settle ``markLastEditable()`` mark pass (work item 3
#: call sites — the in-flight guard's at-rest landing), and the
#: task-03 aborted-settle mark pair (the 300s guard's ``aborted``
#: catch branch — a timed-out turn must not strand the question);
#: 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
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 <name>…"; 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 <name>…" — 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();
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
// Phase 158 (D5): the turn is over — 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);
appendSources(wrap, ev.sources);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
// under the usefulness bar — and the weak hits of a DEFLECTED
// turn, whose `ev.sources` is empty → zero chips) renders as
// the de-emphasized labeled row BELOW the citation clusters
// and ABOVE the actions row (phase 130 task 02 — the
// .msg-actions row is the bubble's LAST row, so the action
// appenders after this get-or-create it; the getActionsRow
// scoping keeps the buttons out of the related row either way
// — a deflected turn with related docs still gets its own
// actions row).
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
// final record REPLACES it in place (leavePartialIndex = -1 when
// there was none — the append, exactly as before).
// Phase 158 (D4): the turn's CHRONOLOGICAL run order persists
// with the turn — segments (non-empty only: the
// undefined-drops-the-key house pattern; a no-thinking/no-tools
// turn stores no key). Additive: thinking + tools stay UNCHANGED
// (the history wire + the old-client degradation path).
const turnSegments = buildSegments(segRuns);
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
segments: turnSegments.length ? turnSegments : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
// Phase 124 (task 06, TODO L3): the answer image persists
// with the turn (undefined drops the key from the JSON — the
// house optional-meta pattern, the `related` line below is
// the precedent): an image-less / pre-phase turn stores a
// byte-identical record.
answer_image: ev.answer_image || undefined,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
// row exactly as it looked live.
related: ev.related?.length ? ev.related : undefined,
suggestions: ev.suggestions,
// 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 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) {
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
// Phase 159 (task 03): the aborted settle must not strand the
// question — the same mark pair as the other non-`done` settles.
// markLastRetryable re-targets (a timed-out turn persists NO
// brain record — the phase-120 exclusion — so the pair re-lands
// the previous turn's target and clears stale buttons), and
// markLastEditable lands Edit on the question NOW (the guard
// bails only on thinking/streaming — the error state passes;
// the finally idle-settle re-runs it — the remove-then-append
// keeps the double pass harmless).
markLastRetryable();
markLastEditable();
} else if (stoppedByUser || err?.name === "AbortError") {
// Phase 48 (owner-locked): the STOP path — no error banner. When
// answer text streamed, keep the partial on screen and persist it
// with the optional `stopped` marker (phase-14 no-version-bump
// convention; no sources/suggestions — the turn never settled).
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
// which aborts the same fetch — from saving the partial twice.
if (wrap && acc && !persistedOnLeave) {
// Phase 158 (D5): the turn was stopped — fold every open run
// (replaces the phase-117 closeToolCalls fold + the D15 block
// close, like `done`).
closeAllRuns(wrap);
// 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): 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, and
the D5 refinement) included."""
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
def test_run_turn_pin_carries_exactly_the_sanctioned_additions() -> None:
"""Structural sanity on the pin: the fresh-send block captures the
wrap and assigns it (exactly once), the idle-settle mark pass is
the only ``markLastEditable()`` inside the finally block (the
four mid-turn paired sites — done / zero-frame / stop / task-03
aborted — are the other sanctioned call sites), and the editor
itself lives OUTSIDE ``runTurn`` (the editor is
``openEditor``'s job — ``runTurn`` is consumed as-is by task 02)."""
rt = PINNED_RUN_TURN
assert rt.count("lastUserWrap = userWrap;") == 1
finally_block = rt[rt.index("} finally {"):]
assert finally_block.count("markLastEditable();") == 1
assert ".edit-form" not in rt, "the editor form is not built inside runTurn"
assert "edit-input-" not in rt
# ---------------------------------------------------------------------------
# Task 03 — the driver case: a failed or rejected question stays editable
# ---------------------------------------------------------------------------
# The in-flight guard expression — ONE string, three homes (the
# markLastEditable guard, the save handler's top guard, the save
# handler's pre-re-ask guard).
IN_FLIGHT_GUARD = (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
)
def test_mark_last_editable_guard_bails_only_on_thinking_and_streaming() -> None:
"""The error state must not lock the editor: ``markLastEditable``
bails on ``UI_STATE.thinking`` / ``UI_STATE.streaming`` ONLY —
never on ``UI_STATE.error`` or ``UI_STATE.idle`` (a failed turn
IS idle-or-error at rest — the pin of the two states the guard
checks; read ``setUiState``: the error state is a REST state,
not an in-flight one)."""
js = _js()
body = _fn_body(js, "markLastEditable")
assert IN_FLIGHT_GUARD in body, ("the guard names thinking + streaming")
assert "UI_STATE.error" not in body, (
"the guard NEVER bails on the error state — the driver case "
"(the 'Question too long' frame's turn) settles there, and the "
"Edit button must land in it"
)
assert "UI_STATE.idle" not in body, (
"the guard NEVER bails on idle — the at-rest settle lands the "
"button"
)
def test_save_editor_inflight_guard_mirrors_mark_last_editable_exactly() -> None:
"""``saveEditor``'s in-flight guard mirrors ``markLastEditable``'
guard EXACTLY (the byte-identical expression — the same two
states, the same no-op semantics): a save can never start a
second turn, and the error state (the driver case — trim + Save
right after the 'Question too long' frame) never blocks a save."""
js = _js()
assert IN_FLIGHT_GUARD in _fn_body(js, "markLastEditable")
body = _fn_body(js, "openEditor")
idx_submit = body.index('form.addEventListener("submit"')
handler = body[idx_submit:]
assert handler.count(IN_FLIGHT_GUARD) == 2, (
"the save handler's double guard (top + pre-re-ask) is "
"byte-identical to markLastEditable's guard"
)
assert "UI_STATE.error" not in handler, (
"the save guards NEVER check the error state (a save in the "
"error state — the trim-and-re-ask remedy — must run)"
)
def test_show_error_banner_is_byte_unchanged_by_phase_159() -> None:
"""``showErrorBanner`` is NOT changed by phase 159 (the phase-120
rule, re-pinned here through the SHARED phase-120 pin — one pin,
no drift): its ``opts.retryable && lastBrainWrap`` condition stays
byte-identical — Edit is an in-bubble affordance beside Retry,
NEVER a banner control."""
assert _fn_source(_js(), "showErrorBanner") == PINNED_SHOW_ERROR_BANNER
body = _fn_source(_js(), "showErrorBanner")
assert "if (opts.retryable) {" in body, ("the phase-111 retryable gate")
assert "if (btn && lastBrainWrap) {" in body, ("the phase-111 bubble gate")
def test_failed_turn_record_after_the_edited_question_is_dropped_not_kept() -> None:
"""The failed answer record is part of the stale tail: after an
edit-save, the phase-120 ``{ who: "brain", failed: true, error }``
record that follows the edited question is DROPPED by the
single-argument ``conversation.splice(editIdx + 1)`` (a
single-argument splice drops to the END — nothing after the
edited record survives) and its bubble (with the ``.failed-note``)
is removed by the ``lastUserWrap.nextSibling`` DOM walk; and
``lastBrainWrap = null`` precedes the re-ask so no stale Retry
target survives the drop."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
assert idx_splice != -1, "the tail splice exists"
assert "conversation.splice(editIdx + 1," not in body, (
"the splice is SINGLE-argument — it drops to the END, so the "
"failed record after the question goes with the rest of the "
"tail (dropped, not kept, not rewritten)"
)
# The DOM walk (which removes the failed bubble, .failed-note
# riding it) runs after the record splice.
idx_walk = body.index("let n = lastUserWrap.nextSibling;")
assert idx_walk > idx_splice, "the DOM removal follows the record splice"
idx_loop = body.index("while (n) {", idx_walk)
loop_close = body.index("}", idx_loop)
loop_body = body[idx_loop : loop_close + 1]
assert "n.remove();" in loop_body, ("every trailing bubble is removed")
# The retry target dies with the drop — BEFORE the re-ask.
idx_lastbrain = body.index("lastBrainWrap = null;")
idx_reask = body.index("return runTurn(text, { reask: true });")
assert idx_lastbrain < idx_reask, (
"lastBrainWrap = null precedes the re-ask — the failed bubble "
"is no longer a retry target (no stale Retry survives)"
)
def test_truncation_ordering_note_pins_the_remember_brainturn_safety() -> None:
"""The splice carries the phase-159 task-03 ordering note: the
truncation happens BEFORE the re-ask's turn starts, so the new
turn's ``rememberBrainTurn`` sees the post-splice array (its
index bookkeeping — the ``replaceIndex`` / ``leavePartialIndex``
correlation, whose ``who === "brain"`` guard falls back to an
append — can never be confused by the drop)."""
js = _js()
body = _fn_body(js, "openEditor")
idx_splice = body.index("conversation.splice(editIdx + 1);")
preceding = body[max(0, idx_splice - 1500) : idx_splice]
assert "BEFORE the re-ask's turn starts" in preceding, (
"the ordering note states the truncation precedes the re-ask"
)
assert "rememberBrainTurn" in preceding, (
"the note names rememberBrainTurn's index bookkeeping"
)
assert "post-splice array" in preceding, (
"the note states the new turn sees the post-splice array"
)
def test_turn_timeout_aborted_settle_marks_retry_then_editable() -> None:
"""The 300s guard abort (``armTurnTimeout`` →
``setUiState(UI_STATE.error, …)``) settles through the catch's
``aborted`` branch, which carries the mark pair (a timed-out
turn must not strand the question): ``markLastRetryable()``
(re-target — a timed-out turn persists NO brain record, the
phase-120 exclusion, so the pair re-lands the previous turn's
target and clears stale buttons) IMMEDIATELY followed by
``markLastEditable()`` (lands Edit on the question — the guard
bails only on thinking/streaming, so the error state passes);
the finally idle-settle then re-runs ``markLastEditable()`` (the
remove-then-append keeps the double pass harmless)."""
js = _js()
rt = _fn_source(js, "runTurn")
idx_catch = rt.index("} catch (err) {")
idx_aborted = rt.index("if (aborted) {", idx_catch)
idx_stop = rt.index(
'} else if (stoppedByUser || err?.name === "AbortError") {',
idx_aborted,
)
aborted_block = rt[idx_aborted:idx_stop]
idx_retry = aborted_block.index("markLastRetryable();")
idx_edit = aborted_block.index("markLastEditable();")
assert 0 <= idx_retry < idx_edit, (
"the aborted branch marks retryable, then editable (the pair)"
)
# And the finally idle-settle still re-runs the editable mark.
idx_finally = rt.index("} finally {")
finally_block = rt[idx_finally:]
assert finally_block.count("markLastEditable();") == 1, (
"the finally idle-settle re-runs markLastEditable (the at-rest "
"landing)"
)
def test_restore_paths_put_edit_back_on_the_restored_question() -> None:
"""A reload after a failed turn restores the question with a
working Edit: ``renderStoredMessage``'s user branch tracks
``lastUserWrap`` (the restored question bubble is the target —
the mirror of the brain line below), and BOTH restore paths end
with the ``markLastRetryable()`` → ``markLastEditable()`` pair
(the pair lands Retry on the restored failed bubble and Edit on
the restored question). The ``?chat=`` boot load (admin
restore) is an EDITABLE working copy — not a read-only view (the
phase-159 task-03 finding — the assumption holds, no gate
needed): it mirrors the conversation to localStorage
(``saveConversation`` — a plain refresh returns to it), links the
row (``currentChatId`` — a subsequent Save updates THIS row), and
carries the same mark pair; no read-only render path exists in
the function."""
js = _js()
pair_re = re.compile(
r"markLastRetryable\(\);[^\n]*\n\s*markLastEditable\(\);"
)
# The restore renderer's user branch tracks the wrap.
rst = _fn_body(js, "renderStoredMessage")
idx_user = rst.index('if (m.who === "user") {')
idx_wrap = rst.index("lastUserWrap = wrap;", idx_user)
assert idx_wrap > idx_user, ("the restore user branch assigns the wrap")
# The local restore ends with the pair.
rc = _fn_body(js, "restoreConversation")
assert pair_re.search(rc), ("restoreConversation ends with the mark pair")
# The ?chat= boot load: the pair, the mirror, the link — an
# editable working copy, no read-only gate.
rsu = _fn_body(js, "restoreSavedChatFromUrl")
assert pair_re.search(rsu), ("the ?chat= boot load carries the same mark pair")
assert "saveConversation();" in rsu, (
"the ?chat= restore mirrors to localStorage — an editable "
"working copy (a plain refresh returns to it)"
)
assert "currentChatId = chatId;" in rsu, (
"the ?chat= restore links the row — a subsequent Save updates "
"THIS row (the working copy, not a read-only view)"
)
assert "readOnly" not in rsu and "readonly" not in rsu, (
"no read-only render path in the ?chat= restore (the task-03 "
"finding — Edit follows Retry for the admin working copy)"
)
# ---------------------------------------------------------------------------
# CSS — the .edit-btn pill family + the .edit-form card family
# ---------------------------------------------------------------------------
def test_edit_btn_css_is_the_retry_pill_family() -> None:
"""``.edit-btn`` is the exact visual family of ``.retry-btn`` (the
neutral pill: 44px floor, the 999px pill radius, the --line
hairline, transparent background, ink-soft text; the hover tints
to --brand-soft + ink — the same tint; the 14px glyph): the
meta actions read as a set. No literal colors (the phase-92
zero-literal convention — the tokens theme)."""
css = _css()
m = re.search(r"\.edit-btn \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .edit-btn"
body = m.group(1)
for decl in (
"display: inline-flex;",
"min-height: 44px;",
"border-radius: 999px;",
"border: 1px solid var(--line);",
"background: transparent;",
"color: var(--ink-soft);",
"cursor: pointer;",
):
assert decl in body, f".edit-btn must carry {decl!r} (the retry pill family)"
hover = re.search(r"\.edit-btn:hover \{([^}]*)\}", css)
assert hover, "styles.css must define .edit-btn:hover"
assert "background: var(--brand-soft);" in hover.group(1)
assert "color: var(--ink);" in hover.group(1)
svg = re.search(r"\.edit-btn svg \{([^}]*)\}", css)
assert svg, "the .edit-btn glyph must be 14px (the family)"
assert "width: 14px;" in svg.group(1) and "height: 14px;" in svg.group(1)
# The global :focus-visible ring (the 3px outline) is NOT scoped to
# the button classes — it must be a top-level bare selector, so the
# Edit button inherits it for free.
assert re.search(r"^:focus-visible \{", css, re.M), (
"the :focus-visible ring must stay GLOBAL (not scoped to the "
"tune/retry buttons) — the Edit button inherits it"
)
def test_edit_form_css_is_the_tune_card_family() -> None:
"""The ``.edit-form`` card + label + textarea + actions row are
the ``.tune-form`` family (the surface card, the --brand-soft
hairline, the --radius-sm radius; the labelled textarea with the
house input tokens; the ≥44px Save (brand) + Cancel (ghost); the
``.edit-error`` alert line on the --err-* tokens — B5: state is
text + color, never color alone)."""
css = _css()
m = re.search(r"\.edit-form \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .edit-form"
body = m.group(1)
for decl in (
"display: flex;",
"flex-direction: column;",
"background: var(--surface);",
"border: 1px solid var(--brand-soft);",
"border-radius: var(--radius-sm);",
):
assert decl in body, f".edit-form must carry {decl!r} (the tune card family)"
assert re.search(r"\.edit-form label \{", css), "a label rule (the labelled textarea)"
assert re.search(r"\.edit-form textarea \{", css), "the textarea rule"
assert re.search(r"\.edit-form-actions \{", css), "the actions row rule"
save = re.search(r"\.edit-save \{([^}]*?)\n\}", css, re.S)
assert save, "styles.css must define .edit-save"
assert "min-height: 44px;" in save.group(1)
assert "background: var(--brand);" in save.group(1)
assert "color: var(--bg);" in save.group(1)
cancel = re.search(r"\.edit-cancel \{([^}]*?)\n\}", css, re.S)
assert cancel, "styles.css must define .edit-cancel"
assert "min-height: 44px;" in cancel.group(1)
assert "border: 1px solid var(--line);" in cancel.group(1)
assert "background: transparent;" in cancel.group(1)
err = re.search(r"\.edit-error \{([^}]*)\}", css)
assert err, "styles.css must define .edit-error"
tokens = (
"background: var(--err-bg);",
"color: var(--err-ink);",
"border: 1px solid var(--err-line);",
)
for token in tokens:
assert token in err.group(1), f".edit-error must use the --err-* tokens ({token})"
def test_is_editing_hides_the_user_bubble() -> None:
"""``.msg.user .bubble.is-editing`` is ``display: none`` — while
the editor is open the rendered question is hidden so the
question appears ONCE (textarea + label, never the rendered text
twice). Scoped to USER bubbles (a brain bubble never gets
``.is-editing``)."""
css = _css()
m = re.search(r"\.msg\.user \.bubble\.is-editing \{([^}]*)\}", css)
assert m, "styles.css must hide the editing user bubble"
assert "display: none;" in m.group(1)
def test_mobile_block_carries_the_edit_btn_44px_floor() -> None:
"""The mobile block (the ≤640px media query) carries
``.edit-btn { min-height: 44px; }`` beside the existing
``.tune-btn`` / ``.retry-btn`` 44px floors — the Edit action is
a ≥44px touch target on mobile (WCAG 2.1 AA / PLAN §7.1)."""
css = _css()
idx_retry = css.index(".retry-btn { min-height: 44px; }")
idx_edit = css.index(".edit-btn { min-height: 44px; }")
assert idx_edit != -1, "the mobile block must carry the .edit-btn 44px floor"
assert 0 < idx_edit - idx_retry < 40, (
"the .edit-btn floor sits beside the .retry-btn floor (the mobile "
"block)"
)
# ---------------------------------------------------------------------------
# Task 04 — the remaining "NOT touched" regression pins: handleSend +
# rememberBrainTurn (the send path and the brain-record funnel)
# ---------------------------------------------------------------------------
#: The FULL source of ``handleSend`` as of phase 159 — the form-level
#: pre-work (the in-flight Stop branch, the empty/4000-char guards, the
#: phase-123 attached-image upload-first, the input clear +
#: ``runTurn(text, { reask: false, image })``). The phase edits the
#: LAST question through the in-bubble editor — it never routes
#: through ``handleSend``, which stays byte-unchanged. (The pin
#: matches ``_fn_source``'s extraction: the ``async`` prefix is not
#: part of the matched ``function handleSend(`` substring.)
PINNED_HANDLE_SEND = """function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
// button (setUiState keeps it enabled) — a click or an Enter-to-submit
// aborts the in-flight turn instead of starting a new one.
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) {
stopTurn();
return;
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
// Phase 104 (owner 2026-09-12, A5): maxlength caps typing + pastes, but
// a programmatic fill (the chip one-tap path) bypasses it — this guard
// is the never-stale backstop (PLAN §7.4): no turn, no clear, the user
// trims the kept text (out-of-turn banner, the saveAsDoc precedent).
if (text.length > MAX_QUESTION_CHARS) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 123 (task 02, TODO L6; locked A8): an attached image uploads
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
// send and the typed question stays exactly where the user left it
// (the question is never sent without the image the user attached;
// the banner says what failed, the attachment stays for the retry).
// The returned STORED path (never the bytes, A5) then rides runTurn
// into the record + the request body.
let image = null; // { path, src, alt } | null — null = text-only send
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
// Phase 132 (task 01, TODO L8): the uploading state is entered
// exactly ONCE per upload — after the guard, before the await —
// so the gap between the press and the resolution is never
// frozen-looking (the strip's spinner + "Uploading image…", the
// disabled "Uploading…" Send).
enterUploadState();
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
// Phase 132: exited on BOTH resolution paths — the helper never
// throws, so this line is reached on success AND failure. Before
// the success path continues to runTurn (whose setUiState takes
// the button over) and before the failure path returns below
// (banner + editable composer + the attachment for the retry).
exitUploadState();
if (path === null) return; // A8: the send is blocked — the question stays
image = {
path, // the record + the /api/chat body (A5: the path, never base64)
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
};
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false, image });
}"""
#: The FULL source of ``rememberBrainTurn`` as of phase 159 — the
#: brain-record funnel (replace-or-append + ``saveConversation`` + the
#: phase-55 auto-save). The re-ask's settle rides it UNCHANGED: the
#: truncation happens before the new turn starts, so the funnel sees
#: the post-splice array (the task-03 ordering note).
PINNED_REMEMBER_BRAIN_TURN = """function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}"""
def test_handle_send_is_byte_unchanged() -> None:
"""``handleSend`` is byte-unchanged by phase 159 (the "NOT
touched" contract, task-04 pin): the edit flow is the in-bubble
editor — ``handleSend`` stays the plain send's pre-work (the
in-flight Stop branch, the empty / >4000-char guards, the
phase-123 upload-first, the input clear, the
``runTurn(text, { reask: false, image })`` call). The
composer-draft survival (task 02) holds because the save handler
never touches ``input.value`` — ``handleSend``'s own clear is the
send path only."""
assert _fn_source(_js(), "handleSend") == PINNED_HANDLE_SEND
def test_remember_brainturn_is_byte_unchanged() -> None:
"""``rememberBrainTurn`` is byte-unchanged by phase 159 (the
"NOT touched" contract, task-04 pin): the re-ask's settle persists
the fresh brain record through this EXACT funnel (the
replace-or-append + ``saveConversation`` + the phase-55
``persistConversation`` ride) — the phase adds no parameter and
restructures nothing (the truncation-ordering note, pinned in
``test_truncation_ordering_note_pins_the_remember_brainturn_safety``,
is what keeps the funnel's index bookkeeping sound across the
edit)."""
assert _fn_source(_js(), "rememberBrainTurn") == PINNED_REMEMBER_BRAIN_TURN
# The funnel's two persistence rides — the contract the edit's
# truncated shape depends on (save point 2 + the auto-save).
body = _fn_body(_js(), "rememberBrainTurn")
assert "saveConversation();" in body
assert "persistConversation();" in body