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