"""Unit: the phase-160 (task 04, TODO L4) "Continue researching" button. When a turn ends because its tool budget was spent (the ``done`` frame's additive ``budget_exhausted`` key, task 03 — ABSENT on every non-cap turn), the answer bubble offers **Continue researching**: one click sends the next turn (a plain send — the budget is per-turn, so the fresh turn has a whole new round budget) and the marker persists with the record so a page refresh restores the affordance. The shared page never shows the button (read-only by construction). Pins (the house source-pin pattern — no browser): * ``CONTINUE_ICON`` / ``CONTINUE_QUESTION`` — the inline aria-hidden SVG in the RETRY_ICON stroke family (no icon font, no CDN — A11) and the literal continuation word (what the model is taught to ask the user for); * ``appendContinueButton`` — the house pattern through ``getActionsRow`` (the role=list row joins the button as a listitem), the one-per-bubble guard, click → ``continueTurn()``; * ``continueTurn`` — the ``retryLastTurn`` discipline: no-op while a turn is in flight, every ``.continue-btn`` removed FIRST, then a PLAIN ``runTurn(CONTINUE_QUESTION)`` (no ``reask``); * the ``done`` settle — ``budget_exhausted`` rides ``rememberBrainTurn``'s meta (``undefined`` drops the key, so every non-cap record stays byte-identical) and the button is appended ONLY when the frame is flagged, in the row order Tune → Retry → Continue → Save-as-doc (the insertion order phase 130 relies on); * the restore branch — keys on ``m.budget_exhausted``; * ``startNewChat`` needs nothing — its list reset drops the buttons; * ``retryLastTurn`` / ``markLastRetryable`` / ``rememberBrainTurn`` are byte-unchanged (the phase-120 shared pin for the first); * the shared page (work item 8) — ``shared.html`` never loads ``app.js`` and ``shared.js`` builds no buttons / actions rows, so a flagged record renders without any Continue control there. The browser-level assertions (the live click running a fresh turn, the reload restoring the button, the shared page's read-only render) are E2E-gated by tests/e2e/test_tool_budget_telemetry.py (task 05). """ from __future__ import annotations from pathlib import Path from tests.unit.test_frontend_failed_turn import PINNED_RETRY_LAST_TURN FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" STYLES_CSS = FRONTEND / "assets" / "styles.css" SHARED_HTML = FRONTEND / "shared.html" SHARED_JS = FRONTEND / "assets" / "shared.js" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _fn_body(js: str, header: str) -> str: """The full source of the function whose header is *header* — brace-matched from the first ``{`` OUTSIDE the parameter list (paren depth 0, the house convention), so destructured parameters are skipped (the pinned functions carry no string literal with a stray brace).""" start = js.index(header) i = start paren = 0 while i < len(js): c = js[i] if c == "(": paren += 1 elif c == ")": paren -= 1 elif c == "{" and paren == 0: break i += 1 depth = 0 for j in range(i, len(js)): c = js[j] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return js[start : j + 1] raise AssertionError(f"unbalanced braces in {header!r}") def _done_branch(js: str) -> str: """The SSE ``done`` branch — from the branch test to the next ``else if`` (flat block, the slice is exact).""" start = js.index('ev.type === "done"') return js[start : js.index('ev.type === "tool_result"', start)] # --------------------------------------------------------------------------- # the glyph + the continuation word # --------------------------------------------------------------------------- def test_continue_icon_is_an_inline_svg_in_the_stroke_family() -> None: """``CONTINUE_ICON`` is the inline ``aria-hidden`` SVG in the RETRY_ICON stroke family (task 01: 24×24, ``fill="none"``, ``stroke="currentColor"``, ``stroke-width="1.7"`` — the CSS themes the stroke) — no icon font, no CDN (A11).""" js = _js() start = js.index("const CONTINUE_ICON =") seg = js[start :] q1 = seg.index("'") q2 = seg.index("'", q1 + 1) literal = seg[q1 + 1 : q2] # the first string literal = the inline SVG for attr in ( 'aria-hidden="true"', 'viewBox="0 0 24 24"', 'fill="none"', 'stroke="currentColor"', 'stroke-width="1.7"', ): assert attr in literal, f"the glyph keeps the shared family: {attr!r}" assert literal.startswith("") def test_continue_question_is_the_literal_word_continue() -> None: """``CONTINUE_QUESTION`` is the literal word ``continue`` — the word the model is taught to ask the user for (task 02's ROUND_CAP_WRAPUP_INSTRUCTION / the spent-budget trailer), so the visible bubble, the typed equivalent, and the model's own suggestion all agree.""" js = _js() assert 'const CONTINUE_QUESTION = "continue";' in js def test_continue_icon_is_defined() -> None: """EXACTLY ONE ``CONTINUE_ICON`` constant (the glyph is shared by the button, never duplicated).""" js = _js() assert js.count("const CONTINUE_ICON =") == 1 assert js.count("const CONTINUE_QUESTION =") == 1 # --------------------------------------------------------------------------- # appendContinueButton — the house pattern # --------------------------------------------------------------------------- def test_append_continue_button_joins_the_actions_row() -> None: """``appendContinueButton(wrap)`` keeps the house pattern (the appendRetryButton shape, task 02): the getActionsRow lookup (the dedicated actions row, NEVER the chips row), the null no-op, the one-per-bubble guard (``.continue-btn``), the role=list → listitem ARIA join, the icon + the visible "Continue researching" label (the label carries the accessible name), and the click → ``continueTurn()`` binding.""" js = _js() body = _fn_body(js, "function appendContinueButton(wrap) {") assert "getActionsRow(wrap)" in body, "the shared get-or-create helper" assert "if (!meta) return" in body, "no-op without a .msg-body" assert 'querySelector(".continue-btn")' in body, ( "the one-per-bubble guard" ) assert 'meta.getAttribute("role") === "list"' in body, ( "the role=list check (the row is always role=list)" ) assert 'setAttribute("role", "listitem")' in body, "the listitem join" assert 'btn.className = "continue-btn"' in body assert 'btn.innerHTML = CONTINUE_ICON + "Continue researching"' in body, ( "icon + the visible label (the accessible name)" ) assert "btn.addEventListener(\"click\", () => continueTurn())" in body, ( "the click handler — a plain continueTurn() (no wrap, no args)" ) assert "meta.appendChild(btn)" in body # NOT admin-gated, like Retry (chat is public). assert "isAdmin" not in body def test_continue_button_lands_after_the_retry_button() -> None: """``appendContinueButton`` is defined DIRECTLY AFTER ``appendRetryButton`` (task 02's placement — the sibling in the actions-row family, before the Edit button's block).""" js = _js() retry = js.index("function appendRetryButton(wrap) {") cont = js.index("function appendContinueButton(wrap) {") edit = js.index("function appendEditButton(wrap) {") assert retry < cont < edit # --------------------------------------------------------------------------- # continueTurn — the retryLastTurn discipline # --------------------------------------------------------------------------- def test_continue_turn_bails_and_clears_and_sends_plain() -> None: """``continueTurn()`` (task 03): (1) no-op while a turn is in flight (the ``retryLastTurn`` guard — one turn at a time, no double-send); (2) clears EVERY rendered ``.continue-btn`` FIRST (the markLastRetryable remove-first pattern — a stale button on an earlier bubble is gone the moment the new turn's answer lands); (3) a PLAIN send — ``runTurn(CONTINUE_QUESTION)`` with NO ``reask`` argument (the default ``false``): the user bubble appears, the record is pushed + saved at the ordinary save points, and the request carries the full history.""" js = _js() body = _fn_body(js, "function continueTurn() {") assert ( "if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;" in body ), "the in-flight no-op (the retryLastTurn guard)" assert ( 'messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());' in body ), "every continue button is cleared before the send" assert "return runTurn(CONTINUE_QUESTION)" in body, ( "a PLAIN send — the returned promise (the retryLastTurn " "convention)" ) assert "reask" not in body, ( "NO reask argument — a plain send (runTurn's default false)" ) order = [ body.index("UI_STATE.thinking"), body.index(".remove()"), body.index("runTurn(CONTINUE_QUESTION)"), ] assert order == sorted(order), ( "guard → clear-every-button → send, in that order" ) def test_continue_turn_sends_one_plain_turn_with_full_history() -> None: """The plain-send contract on the ``runTurn`` side (task 03's save-point list): with ``reask`` defaulting to false, the turn appends the user bubble, pushes ``{ who: "user", text }`` (no image — a text-only record), runs the ordinary save points (``saveConversation`` + ``persistConversation``), and the request's ``history`` is the FULL ``conversation.slice(0, -1)`` — the model resumes with everything it already read.""" js = _js() rt = _fn_body(js, "function runTurn(text, { reask = false, image = null } = {}) {") assert "if (!reask) {" in rt, "the default is a plain send" assert 'conversation.push(' in rt assert 'saveConversation();' in rt assert "persistConversation();" in rt assert "const history = conversation.slice(0, -1).map(" in rt, ( "the request carries the full history (everything except the " "just-pushed question)" ) # --------------------------------------------------------------------------- # the done settle — the flag rides the record + the button is gated # --------------------------------------------------------------------------- def test_done_handler_persists_the_flag_and_appends_the_button() -> None: """The ``done`` settle (task 04): ``budget_exhausted`` rides ``rememberBrainTurn``'s meta as ``ev.budget_exhausted || undefined`` (the ``answer_image`` precedent — ``undefined`` drops the key from the JSON, so every non-cap record stays byte-identical), every earlier bubble's continue button is cleared FIRST (the completion criterion — "neither does any earlier answer once a newer turn lands" — the markLastRetryable remove-first pattern), and the button is appended ONLY when the frame is flagged.""" js = _js() branch = _done_branch(js) assert "budget_exhausted: ev.budget_exhausted || undefined" in branch, ( "the flag persists with the turn (undefined drops the key)" ) assert ( 'messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());' in branch ), "every earlier bubble's continue button is cleared on the settle" assert "if (ev.budget_exhausted) appendContinueButton(wrap);" in branch, ( "the button is appended ONLY on a flagged done" ) order = [ branch.index('querySelectorAll(".continue-btn")'), branch.index("appendContinueButton(wrap)"), ] assert order == sorted(order), "clear-every-button → gated append" def test_done_branch_row_order_is_tune_retry_continue_save() -> None: """The row order on a capped answer (task 04): Tune → Retry (the markLastRetryable re-append) → Continue (flag-gated) → Save-as-doc — the INSERTION order phase 130 relies on (the DOM order inside the row is the visual order).""" js = _js() branch = _done_branch(js) order = [ branch.index("appendTuneButton(wrap);"), branch.index("markLastRetryable();"), branch.index("appendContinueButton(wrap);"), branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'), ] assert order == sorted(order), ( "done: Tune → Retry → Continue → Save-as-doc (the insertion " "order phase 130 relies on)" ) def test_continue_button_is_not_added_on_the_other_settle_paths() -> None: """The Continue button is a CAP-REACHED-DONE affordance only: the zero-frame fallback, the stop finalize, and the failed-turn funnel (none of them sees a ``budget_exhausted`` frame — a failed or stopped turn never hit the cap) append NO continue button, and the ``done`` branch's flag-gate is the single gate.""" js = _js() assert js.count("appendContinueButton(wrap);") == 2, ( "exactly TWO call sites (with the terminating semicolon — the " "function header ends in a brace, not a semicolon): the done " "settle + the restore path" ) zero = js.index("if (!aborted && !wrap) {") block = js[zero : js.index("} catch (err) {", zero)] assert "appendContinueButton" not in block, ( "the zero-frame fallback (a failed turn) adds no continue button" ) funnel = _fn_body( js, "function finalizeFailedTurn(\n" " detail,\n" " { acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }\n" ") {", ) assert "appendContinueButton" not in funnel, ( "the failed-turn funnel adds no continue button" ) # --------------------------------------------------------------------------- # the restore path + startNewChat # --------------------------------------------------------------------------- def test_restore_branch_keys_on_the_persisted_marker() -> None: """The phase-14 restore (``renderStoredMessage``'s brain branch, task 05): the Continue affordance restores from the persisted ``m.budget_exhausted`` marker — a reload keeps the button on a flagged answer (a restored ``history`` is exactly what the continuation turn needs), and a pre-phase / non-cap record (no key) restores byte-identically (no button). The append lands BEFORE the ``lastBrainWrap`` assignment, after the per-message appends (the actions row is the bubble's LAST row, as on the live done path).""" js = _js() body = _fn_body(js, "function renderStoredMessage(m) {") assert "if (m.budget_exhausted) appendContinueButton(wrap);" in body, ( "the restore keys on the persisted marker" ) order = [ body.index("appendFailedNote(wrap, m.error);"), body.index("if (m.budget_exhausted) appendContinueButton(wrap);"), body.index("lastBrainWrap = wrap"), ] assert order == sorted(order), ( "restore: the per-message appends → the continue button → " "lastBrainWrap (the after-the-loop markLastRetryable lands the " "Retry in the same actions row, as on the live path)" ) def test_start_new_chat_unchanged_drops_the_buttons_with_the_list() -> None: """``startNewChat`` (task 05: needs nothing) — its list reset removes every ``.msg`` bubble, so the continue buttons die with the list (the phase-49 reasoning). The reset + the in-flight guard stay byte-pinned.""" js = _js() body = _fn_body(js, "function startNewChat() {") assert ( "if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;" in body ), "the in-flight guard stays" assert 'messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());' in body, ( "the list reset drops the buttons along with the list" ) assert "appendContinueButton" not in body, ( "no continue-button wiring was added to startNewChat" ) # --------------------------------------------------------------------------- # NOT touched — the phase's explicit contract (byte-pinned sources) # --------------------------------------------------------------------------- #: The FULL source of ``markLastRetryable`` as of pre-phase-160 task #: 04 — the remove-then-append shape (remove every rendered #: .retry-btn FIRST, then re-append on the last brain bubble) is #: UNCHANGED: the continue button has its OWN clear pass in #: continueTurn, and markLastRetryable's targeting is untouched. PINNED_MARK_LAST_RETRYABLE = """function markLastRetryable() { messagesEl.querySelectorAll(".retry-btn").forEach((b) => b.remove()); if (!lastBrainWrap) return; let lastIdx = -1; for (let i = conversation.length - 1; i >= 0; i -= 1) { if (conversation[i].who === "brain") { lastIdx = i; break; } } const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null; if (!prev || prev.who !== "user") return; appendRetryButton(lastBrainWrap); }""" #: The FULL source of ``rememberBrainTurn`` as of pre-phase-160 task #: 04 — the ``...meta`` spread means the new ``budget_exhausted`` #: optional key rides the record with ZERO change to this helper #: (``undefined`` drops the key from the JSON, the house #: optional-meta pattern). 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_mark_last_retryable_is_byte_unchanged() -> None: """``markLastRetryable`` is byte-unchanged by phase 160 (task 04 — the redo-in-place targeting is REUSED, not extended): the full source must match the pin (a diff is a contract violation).""" assert _fn_body(_js(), "function markLastRetryable() {") == PINNED_MARK_LAST_RETRYABLE def test_remember_brain_turn_is_byte_unchanged() -> None: """``rememberBrainTurn`` is byte-unchanged by phase 160 (task 04 — the ``budget_exhausted`` meta key rides the existing ``...meta`` spread, so the save point is consumed as-is): the full source must match the pin.""" assert _fn_body(_js(), "function rememberBrainTurn(rawText, meta, replaceIndex = -1) {") == ( PINNED_REMEMBER_BRAIN_TURN ) def test_retry_last_turn_is_byte_unchanged() -> None: """``retryLastTurn`` is byte-unchanged by phase 160 (task 04 — the continue button REUSES its discipline, the function is consumed as-is): the full source must match the phase-120 pin (the shared constant — one pin, no drift).""" assert _fn_body(_js(), "function retryLastTurn(wrap) {") == PINNED_RETRY_LAST_TURN # --------------------------------------------------------------------------- # the shared page (work item 8) — the Continue control never renders there # --------------------------------------------------------------------------- def test_shared_page_never_shows_the_continue_control() -> None: """Work item 8 (no code change expected): the shared page (``/shared/``, served server-side — ``shared.html``) renders the conversation through ``shared.js``'s OWN read-only renderer, never through ``app.js`` — so the ``budget_exhausted`` key is IGNORED there by construction: the page loads no ``app.js`` (no ``appendContinueButton`` in its script graph), and ``shared.js`` builds NO buttons, no forms, and no ``.msg-actions`` row (owner-locked: zero interactive controls — the "no buttons, no forms, no links, no click handlers" contract), so a flagged record renders without any Continue affordance.""" html = SHARED_HTML.read_text(encoding="utf-8") assert "app.js" not in html, ( "the shared page never loads app.js — its action buttons " "(incl. Continue) are not in its script graph" ) shared = SHARED_JS.read_text(encoding="utf-8") assert "continue-btn" not in shared assert "msg-actions" not in shared, ( "shared.js never builds the actions row — zero controls" ) assert 'createElement("button")' not in shared, ( "owner-locked: NO buttons are ever created on the shared page" ) assert 'addEventListener("click"' not in shared, ( "owner-locked: NO click handlers on the shared page (the img " "load-failure degradation handler is not a control — the " "zero-interactive-controls contract)" ) # --------------------------------------------------------------------------- # styles.css — the .continue-btn family # --------------------------------------------------------------------------- def test_continue_btn_css_is_the_retry_family() -> None: """``.continue-btn`` is the exact visual family of ``.retry-btn`` (task 07): the same pill size/spacing (``min-height: 44px``, ``gap: 0.35rem``, ``padding: 0.35rem 0.8rem``, ``border-radius: 999px``, ``border: 1px solid var(--line)``), the same neutral color (``--ink-soft`` → ``--ink`` on hover over ``--brand-soft``), the 14px currentColor glyph, and the shared GLOBAL ``:focus-visible`` ring (styles.css L162 — the ring rule is not per-class, so no selector-list extension is needed). No animation of its own → the ``prefers-reduced-motion`` blocks need nothing for it (the family's parity — .retry-btn / .edit-btn have none either).""" css = _css() start = css.index(".continue-btn {") end = css.index("}", start) block = css[start : end + 1] for decl in ( "display: inline-flex;", "gap: 0.35rem;", "min-height: 44px;", "padding: 0.35rem 0.8rem;", "border-radius: 999px;", "border: 1px solid var(--line);", "background: transparent;", "color: var(--ink-soft);", "font-weight: 600;", "font-size: 0.82rem;", "white-space: nowrap;", "cursor: pointer;", ): assert decl in block, f"the pill family keeps {decl!r}" assert ".continue-btn svg { width: 14px; height: 14px; display: block; }" in css assert ".continue-btn:hover { background: var(--brand-soft); color: var(--ink); }" in css def test_continue_btn_mobile_block_min_height() -> None: """The mobile block (task 07): ``.continue-btn`` joins the ``.tune-btn`` / ``.retry-btn`` / ``.edit-btn`` 44px min-height lockstep next to ``.retry-btn`` — EXACTLY ONE such single-line rule (the base rule is a multi-line block, so the one-line form occurs only in the mobile media block).""" css = _css() assert css.count(".continue-btn { min-height: 44px; }") == 1 idx = css.index(".continue-btn { min-height: 44px; }") # It sits beside the family's other lockstep lines (near them in # the same mobile block). retry = css.index(".retry-btn { min-height: 44px; }") assert abs(idx - retry) < 200, ( "the continue line is beside .retry-btn in the mobile block" )