"""Unit: the failed-turn frontend contract (phase 120, task 03). Pins the phase-120 client contract as source assertions (the house ``test_frontend_*`` style — no browser): the three live failure paths route through the single ``finalizeFailedTurn`` funnel (persist ``failed: true`` + the capped detail, end retryable), the zero-frame fallback bubble persists the marker too, ``appendFailedNote`` mirrors the stopped note (one per bubble, textContent-only detail), ``FAILED_TURN_TEXT`` is a distinct constant (NOT ``EMPTY_ANSWER_FALLBACK``), the restore branch renders the failed note and excludes the Save-as-doc / Tune buttons, and — the phase's explicit "NOT touched" contract — ``showErrorBanner`` and ``retryLastTurn`` are byte-unchanged (their full sources are pinned below). """ from __future__ import annotations import re from pathlib import Path 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 phase-111 banner-test convention, extended).""" 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 ``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 ``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] # --------------------------------------------------------------------------- # appendFailedNote — the in-bubble error note (the stopped-note mirror) # --------------------------------------------------------------------------- def test_append_failed_note_exists_and_guards_dedup() -> None: """``appendFailedNote(wrap, detail)`` exists and mirrors ``appendStoppedNote``: it joins the bubble's ``.msg-actions`` row (getActionsRow — phase 130 task 02, ASSUMPTION A7: the notes are answer-status markers, the actions row is their home) and adds at most ONE ``.failed-note`` per bubble (the duplicate guard).""" js = _js() body = _fn_body(js, "appendFailedNote") assert "getActionsRow(wrap)" in body, ( "the note joins the dedicated .msg-actions row (getActionsRow — " "phase 130 task 02), never the first .msg-meta row" ) assert '.failed-note' in body assert ( 'if (meta.querySelector(".failed-note")) return;' in body ), "one .failed-note per bubble (the duplicate guard, the stopped-note way)" assert 'note.className = "failed-note";' in body def test_append_failed_note_is_text_and_color_never_color_alone() -> None: """The note is TEXT + color (B5): the "Failed" label is set through ``textContent``, the icon is aria-hidden decoration, and the detail goes through ``textContent`` too — the error string is NEVER innerHTML (no HTML from an error, ever).""" js = _js() body = _fn_body(js, "appendFailedNote") assert 'label.textContent = "Failed";' in body assert 'd.className = "failed-detail"' in body assert "d.textContent = detail" in body, "the detail is textContent, never innerHTML" # The icon itself is the only innerHTML — the static SVG constant, # aria-hidden decoration (the accessible meaning is the label + # detail text — B5: text + color, never color alone). assert "note.innerHTML = FAILED_ICON;" in body m_icon = re.search(r"const FAILED_ICON\s*=\s*\n?\s*'([^']*)'", js) assert m_icon, "const FAILED_ICON must exist" assert 'aria-hidden="true"' in m_icon.group(1), "the icon is decoration" def test_failed_turn_text_is_a_distinct_constant() -> None: """``FAILED_TURN_TEXT`` is its OWN string literal — NOT ``EMPTY_ANSWER_FALLBACK`` (that constant stays the zero-frame-but-COMPLETED case's answer text) and a short honest "my answer didn't make it" line (not the answer text, not the raw detail).""" js = _js() m_fail = re.search(r'const FAILED_TURN_TEXT\s*=\s*\n?\s*"([^"]*)"', js) assert m_fail, "const FAILED_TURN_TEXT must be a string literal" failed_text = m_fail.group(1) assert failed_text, "FAILED_TURN_TEXT must be non-empty" m_empty = re.search(r'const EMPTY_ANSWER_FALLBACK\s*=\s*\n?\s*"([^"]*)"', js) assert m_empty, "const EMPTY_ANSWER_FALLBACK must still be a string literal" assert failed_text != m_empty.group(1), ( "FAILED_TURN_TEXT must be DISTINCT from EMPTY_ANSWER_FALLBACK" ) # The zero-frame branch uses the constant as its DEFAULT (phase 164 # D3: ``bubbleText || FAILED_TURN_TEXT`` — only the pre-stream HTTP # rejection overrides it), never a copy of the fallback. assert "bubbleText || FAILED_TURN_TEXT" in js # --------------------------------------------------------------------------- # finalizeFailedTurn — the single funnel for the live failure paths # --------------------------------------------------------------------------- def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None: """The funnel persists ``failed: true`` in BOTH shapes (partial wrap + the zero-frame bubble) with the capped detail, and the partial keeps the streamed text (``acc``).""" js = _js() body = _fn_body(js, "finalizeFailedTurn") # Both branches persist the marker… assert body.count("failed: true") == 2, ( "both funnel shapes must persist failed: true" ) # …the detail trimmed + capped at 500 before persistence (the # schema's ChatMessage.error bound is the backstop)… assert '(detail || "").trim().slice(0, 500)' in body # …the zero-frame shape creates the failed bubble (phase 164 D3: # ``bubbleText || FAILED_TURN_TEXT`` — the SAME value feeds the # bubble AND the record: stored-what-you-saw)… assert "const fwrapText = bubbleText || FAILED_TURN_TEXT;" in body assert 'addMessage("brain", fwrapText);' in body assert "rememberBrainTurn(\n fwrapText," in body assert "appendFailedNote(fwrap, error);" in body # …and the partial shape settles every open run closed (phase 158, # D5 — the stop-finalize pattern) and adds the note. assert "closeAllRuns(wrap);" in body assert "appendFailedNote(wrap, error);" in body # Both shapes land the record through rememberBrainTurn (local # storage + the phase-55 auto-save ride) and set lastBrainWrap # BEFORE the caller's setUiState(error, …). assert body.count("rememberBrainTurn(") == 2 assert body.count("lastBrainWrap =") == 2 def test_finalize_failed_turn_ends_retryable() -> None: """The funnel ENDS with ``markLastRetryable()`` immediately followed by ``markLastEditable()`` (phase 159 task 01 — the failed turn's question is editable too: the trim-and-re-ask remedy) — the in-bubble Retry + Edit buttons land on the failed bubble (the last brain wrap / the last user wrap).""" js = _js() body = _fn_body(js, "finalizeFailedTurn").rstrip() lines = [line for line in body.splitlines() if line.strip()] assert lines[-1].strip().startswith("markLastEditable();"), ( "finalizeFailedTurn must end with the markLastEditable() call " "(phase 159 — the failed turn's question stays editable)" ) assert lines[-2].strip().startswith("markLastRetryable();"), ( "the markLastEditable() call must sit immediately after " "markLastRetryable() (the paired settle)" ) def test_error_catch_else_routes_through_the_funnel() -> None: """The error catch's ``else`` branch (non-abort, non-stop — network error, pre-stream HTTP error, the SSE ``error`` frame's throw) calls ``finalizeFailedTurn`` BEFORE ``setUiState(UI_STATE.error, …)`` — the funnel sets lastBrainWrap, so the banner's EXISTING ``opts.retryable && lastBrainWrap`` condition reveals the Retry.""" js = _js() # The stop branch ends where the plain else begins. stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {') else_branch = js.index("} else {", stop_branch) finally_branch = js.index("} finally {", else_branch) else_body = js[else_branch:finally_branch] assert "finalizeFailedTurn(detail, {" in else_body # The persistence (the funnel) precedes the error state — the # banner's Retry precondition is set before the banner shows. assert else_body.index("finalizeFailedTurn(detail, {") < else_body.index( "setUiState(UI_STATE.error, detail," ) def test_navigate_away_is_not_a_failed_turn() -> None: """A REAL departure is not a failed turn (phase-120 verification fix): the pagehide handler sets a turn-scoped ``leftThePage`` flag (module scope, like ``persistedOnLeave``), and the error catch's ``else`` branch skips the failed funnel when it is set — the browser's teardown rejection of the cancelled in-flight fetch (a TypeError, NOT an AbortError) must not persist a failed brain record: the phase-20 convention stands (thinking-only navigate-away persists nothing brain-side; a partial navigate-away persists the pagehide partial as a plain record). The flag is set unconditionally on pagehide (a merely-hidden tab in some browsers also fires it — the stream keeps arriving, so no rejection follows and it stays inert there) and reset per turn at the top of ``runTurn`` with the other turn locals.""" js = _js() # Module-scope declaration (column 0), exactly once. assert re.search(r"^let leftThePage = false", js, re.M), ( "leftThePage must be a module-scope flag (the pagehide handler " "reads it), like persistedOnLeave" ) assert js.count("let leftThePage = false;") == 1 # Set as the FIRST statement of the pagehide handler — before the # persistedOnLeave early return (a thinking-only navigate-away # returns early, but the flag must be set for the funnel skip). ph = js.index('window.addEventListener("pagehide", () => {') ph_end = js.index("\n});", ph) ph_body = js[ph:ph_end] idx_flag = ph_body.find("leftThePage = true;") idx_return = ph_body.find("if (persistedOnLeave) return;") assert 0 <= idx_flag < idx_return, ( "leftThePage must be set BEFORE the pagehide early returns" ) # Reset per turn at the top of runTurn (the persistedOnLeave group). turn = js.index("async function runTurn") abort_idx = js.index("turnAbort = new AbortController()", turn) turn_top = js[turn:abort_idx] assert "leftThePage = false;" in turn_top, ( "leftThePage must be reset per turn at the top of the turn handler" ) assert turn_top.index("persistedOnLeave = false;") < turn_top.index( "leftThePage = false;" ) # The catch's else branch (non-abort, non-stop) skips the funnel # when the flag is set — the funnel call itself stays intact (the # real-network-error path, no pagehide). stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {') else_branch = js.index("} else {", stop_branch) finally_branch = js.index("} finally {", else_branch) else_body = js[else_branch:finally_branch] idx_guard = else_body.find("if (!leftThePage) {") idx_funnel = else_body.find("finalizeFailedTurn(detail, {") assert 0 <= idx_guard < idx_funnel, ( "the failed funnel must be skipped after a real page departure " "(the teardown rejection is not a failed turn)" ) def test_stream_drop_guard_routes_through_the_funnel() -> None: """The stream-drop guard (frames arrived, no ``done`` — the connection died mid-turn) routes through the SAME funnel: the half-answer persists as failed (its text + the error note + a working Retry) BEFORE the error state.""" js = _js() guard = js.index("if (!sawDone && !aborted && (acc || thinkingAcc)) {") zero_frame = js.index("if (!aborted && !wrap) {", guard) guard_body = js[guard:zero_frame] assert "finalizeFailedTurn(detail, {" in guard_body assert guard_body.index("finalizeFailedTurn(detail, {") < guard_body.index( "setUiState(UI_STATE.error, detail);" ) def test_zero_frame_fallback_persists_failed_marker() -> None: """The zero-frame-but-COMPLETED fallback bubble (the stream settled with no events) is a failed turn too (task 01 ASSUMPTION): the bubble text stays ``EMPTY_ANSWER_FALLBACK`` (a meaningful record text) but the record gains ``failed: true`` + the error note, and the bubble ends retryable.""" js = _js() zero_frame = js.index("if (!aborted && !wrap) {") catch = js.index("} catch (err) {", zero_frame) block = js[zero_frame:catch] assert "const fallback = EMPTY_ANSWER_FALLBACK;" in block, ( "the bubble text stays the EMPTY_ANSWER_FALLBACK answer text" ) assert "appendFailedNote(fwrap, nothing);" in block assert "failed: true," in block assert "error: nothing," in block assert "markLastRetryable();" in block def test_only_the_three_failed_paths_persist_failed() -> None: """No call site OUTSIDE the three failed paths persists ``failed: true`` (task 01 completion criterion, grep-level): exactly three CODE sites — two in ``finalizeFailedTurn`` (the partial + zero-frame shapes) and one in the zero-frame-but- completed fallback — the done, stop, and restore paths never set the marker (they read it, or don't touch it).""" js = _js() fn = _fn_body(js, "finalizeFailedTurn") zero_frame = js.index("if (!aborted && !wrap) {") catch = js.index("} catch (err) {", zero_frame) fallback_block = js[zero_frame:catch] fn_start = js.index("function finalizeFailedTurn(") fn_src = _fn_source(js, "finalizeFailedTurn") code_outside = js[:fn_start] + js[fn_start + len(fn_src) :] code_outside = code_outside.replace(fallback_block, "") # Comments may mention the marker; code must not (the file's # block-comment lines start with * or /* after stripping). code_lines = [ line for line in code_outside.splitlines() if not line.strip().startswith(("//", "*", "/*")) ] assert "failed: true" not in "\n".join(code_lines), ( "only the three failed paths may persist failed: true" ) assert fn.count("failed: true") == 2 assert fallback_block.count("failed: true") == 1 # --------------------------------------------------------------------------- # restore — a failed record renders as an error bubble with the note # --------------------------------------------------------------------------- def test_restore_renders_the_failed_note() -> None: """The restore branch re-renders the in-bubble error note from the persisted ``error`` detail — and only when it is present (a record whose ``error`` is null has the detail in its ``text`` already).""" js = _js() body = _fn_body(js, "renderStoredMessage") assert "if (m.failed && m.error) appendFailedNote(wrap, m.error);" in body, ( "the failed note restores from the persisted error detail" ) def test_restore_excludes_save_as_doc_and_tune_for_failed() -> None: """A failed turn is a NOTE, not an answer: the restore excludes both the Save-as-doc button (the ``m.stopped`` exclusion extended with ``!m.failed``) and the Tune button (``!m.failed``). Stopped and successful records keep their buttons — the pre-phase-120 behavior for them is byte-identical.""" js = _js() body = _fn_body(js, "renderStoredMessage") assert "if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);" in body assert "if (!m.failed) appendTuneButton(wrap);" in body # --------------------------------------------------------------------------- # NOT touched — the phase's explicit contract (byte-pinned sources) # --------------------------------------------------------------------------- #: The FULL source of ``showErrorBanner`` as of phase 120 — the #: phase-111 button + the phase-114 hint, byte-unchanged by this phase #: (the phase makes ``lastBrainWrap`` EXIST on the error paths instead #: of changing the condition). A diff here is a contract violation. PINNED_SHOW_ERROR_BANNER = """function showErrorBanner(detail, opts = {}) { banner.hidden = false; banner.classList.add("is-error"); banner.setAttribute("role", "alert"); // Phase 114 (TODO L6): a frame-carried hint (the "question too long" // case — reachability is fine, only the length is the problem) replaces // the default reachability hint when present. bannerText.textContent = detail ? `${detail} ${opts.hint ?? ERROR_HINT}` : (opts.hint ?? ERROR_HINT); // Phase 111 (task 01): reveal the banner Retry button only for failed // chat turns (opts.retryable) AND when a retryable bubble exists. if (opts.retryable) { const btn = document.querySelector("#banner-retry"); if (btn && lastBrainWrap) { btn.hidden = false; // Bind click once per reveal — the old listener is removed after // the first click, so re-binding on every reveal is safe. btn.addEventListener("click", () => retryLastTurn(lastBrainWrap)); } } }""" def test_show_error_banner_is_byte_unchanged() -> None: """``showErrorBanner`` is byte-unchanged by phase 120 (the "NOT touched" contract): its full source must match the pin — the ``opts.retryable && lastBrainWrap`` condition, the phase-114 hint merge, and the once-per-reveal binding included.""" assert _fn_source(_js(), "showErrorBanner") == PINNED_SHOW_ERROR_BANNER #: The FULL source of ``retryLastTurn`` as of phase 120 — the phase-49 #: redo-in-place (pop the last brain record, re-ask the preceding #: question). It works on a failed record UNCHANGED (locked A1): the #: question's user record immediately precedes the failed record. PINNED_RETRY_LAST_TURN = """function retryLastTurn(wrap) { if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; if (wrap !== lastBrainWrap) return; // stale click — the button moved on let lastIdx = -1; for (let i = conversation.length - 1; i >= 0; i -= 1) { if (conversation[i].who === "brain") { lastIdx = i; break; } } if (lastIdx === -1) return; // Invariant: every brain record follows its user record — the // question to re-ask is the record immediately before the popped one. const prev = conversation[lastIdx - 1]; if (!prev || prev.who !== "user") return; const text = prev.text; conversation.splice(lastIdx, 1); // redo in place: the old answer is gone // Save BEFORE the rerun: what the user saw — the removed answer — is // what is stored from this point on (the question stays, the replaced // answer never comes back). saveConversation(); wrap.remove(); lastBrainWrap = null; // Re-ask without re-adding: the reask turn skips the user append and // persistence save point 1 (the question is already in both). // Phase 53: the promise is returned (the Regenerate await above); // runTurn never rejects — a failure surfaces as the error banner. return runTurn(text, { reask: true }); }""" def test_retry_last_turn_is_byte_unchanged() -> None: """``retryLastTurn`` is byte-unchanged by phase 120 (locked A1 — the redo-in-place is REUSED, not extended): the full source must match the pin — the pop-the-last-brain-record + re-ask logic, the in-flight guard, and the stale-click guard included.""" assert _fn_source(_js(), "retryLastTurn") == PINNED_RETRY_LAST_TURN # --------------------------------------------------------------------------- # Phase 164 task 03 — the distinct PRE-STREAM failure line (locked A3) # --------------------------------------------------------------------------- #: The owner-locked bubble copy (D3) — pinned VERBATIM, exactly once. FAILED_REQUEST_TEXT_COPY = ( "I couldn't take that — my API rejected the request. Use Retry to ask again." ) #: The phase-114 banner hint the pre-stream throw carries (D3). PRESTREAM_HINT_COPY = ( "The app answered — it rejected the request before the model ran. " "Nothing was lost but this send." ) def _prestream_block(js: str) -> str: """The ``if (!res.ok || !res.body) { … }`` pre-stream handler block.""" site = js.index("if (!res.ok || !res.body) {") return js[site : js.index("await readSSE(", site)] def _catch_else_body(js: str) -> str: """``runTurn``'s catch: the plain ``else`` branch (non-abort, non-stop — the failed-turn funnel site).""" stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {') else_branch = js.index("} else {", stop_branch) return js[else_branch : js.index("} finally {", else_branch)] def test_failed_request_text_is_owner_locked_and_distinct() -> None: """``FAILED_REQUEST_TEXT`` is its OWN string literal with the exact owner-locked copy (D3), declared exactly once, DISTINCT from both ``FAILED_TURN_TEXT`` (the connection line — a lie for a 4xx/5xx the server answered deliberately) and ``EMPTY_ANSWER_FALLBACK``.""" js = _js() m_req = re.search(r'const FAILED_REQUEST_TEXT\s*=\s*\n?\s*"([^"]*)"', js) assert m_req, "const FAILED_REQUEST_TEXT must be a string literal" assert m_req.group(1) == FAILED_REQUEST_TEXT_COPY, ( "the pre-stream copy is owner-locked — verbatim or not at all" ) assert js.count("const FAILED_REQUEST_TEXT") == 1 m_turn = re.search(r'const FAILED_TURN_TEXT\s*=\s*\n?\s*"([^"]*)"', js) m_empty = re.search(r'const EMPTY_ANSWER_FALLBACK\s*=\s*\n?\s*"([^"]*)"', js) assert m_turn and m_empty assert m_req.group(1) != m_turn.group(1) assert m_req.group(1) != m_empty.group(1) # The docstring names the finding + the not-for-transport boundary. doc = js[: js.index("const FAILED_REQUEST_TEXT")][-1200:] assert "2026-10-07" in doc and "PRE-STREAM" in doc def test_prestream_throw_carries_bubble_text_and_hint() -> None: """The pre-stream ``!res.ok`` throw decorates the Error with ``bubbleText = FAILED_REQUEST_TEXT`` AND the phase-114 ``hint`` before throwing — the thrown message stays the serialized detail (task 02), so the note + banner keep the real reason while the bubble reads the honest pre-stream line.""" block = _prestream_block(_js()) assert "const err = new Error(detail);" in block assert "err.bubbleText = FAILED_REQUEST_TEXT;" in block assert f'"{PRESTREAM_HINT_COPY}"' in block, ( "the banner hint copy is locked (D3) — verbatim" ) # Both decorations ride the SAME thrown Error (set before the throw). throw = block.index("throw err;") assert 0 < block.index("err.bubbleText = FAILED_REQUEST_TEXT;") < throw assert 0 < block.index("err.hint") < throw assert "throw new Error(detail);" not in block def test_run_turn_catch_forwards_bubble_text_to_the_funnel() -> None: """``runTurn``'s catch forwards ``bubbleText: err?.bubbleText`` into ``finalizeFailedTurn`` (the ONE option addition) — optional chaining so a transport ``TypeError`` (no key, not even a custom Error) reads ``undefined`` and the settle keeps ``FAILED_TURN_TEXT``. The detail/hint/banner sequencing after the call is untouched.""" else_body = _catch_else_body(_js()) assert "bubbleText: err?.bubbleText," in else_body funnel = else_body.index("finalizeFailedTurn(detail, {") bubble = else_body.index("bubbleText: err?.bubbleText,") banner = else_body.index("setUiState(UI_STATE.error, detail,") assert funnel < bubble < banner, ( "the key rides the funnel call, before the error-state settle " "(the phase-120 ordering contract)" ) def test_finalize_reads_bubble_text_only_in_the_zero_frame_branch() -> None: """``finalizeFailedTurn`` destructures ``bubbleText`` and reads it ONLY in the zero-frame branch (the partial-stream settle ignores the key — a pre-stream throw has no ``wrap`` by construction). The zero-frame settle puts the SAME ``text`` value into the bubble AND the persisted record (stored-what-you-saw, phase-120 contract extended), and touches no ``innerHTML``.""" js = _js() src = _fn_source(js, "finalizeFailedTurn") assert "{ acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }" in src body = _fn_body(js, "finalizeFailedTurn") zero = body.index("} else {") assert "bubbleText" not in body[:zero], ( "the partial branch must never read bubbleText" ) # Code-level (comment-stripped) reads of the key: exactly one. code = "\n".join( line for line in body.splitlines() if not line.strip().startswith(("//", "*", "/*")) ) assert code.count("bubbleText") == 1 zero_part = body[zero:] assert "const fwrapText = bubbleText || FAILED_TURN_TEXT;" in zero_part assert 'addMessage("brain", fwrapText);' in zero_part assert "rememberBrainTurn(\n fwrapText," in zero_part, ( "the bubble text and the record text are the SAME value" ) assert "innerHTML" not in zero_part, "the failed bubble is text, never HTML" def test_sse_error_frame_throw_sets_no_bubble_text() -> None: """The SSE ``error``-frame throw (a model-side failure AFTER the stream opened) decorates ``err.hint`` only — NO ``bubbleText``: a zero-frame error-frame settle keeps ``FAILED_TURN_TEXT`` byte-identically (locked A3).""" js = _js() site = js.index('} else if (ev.type === "error") {') frame = js[site : js.index("throw err;", site) + len("throw err;")] assert "const err = new Error(errorDetailText(ev.detail," in frame, ( "the frame throw reads through the serializer (task 02)" ) assert '"Something went wrong on my side."));' in frame assert "err.hint = ev.hint;" in frame assert "bubbleText" not in frame def test_hint_reaches_banner_opts_for_both_throw_kinds() -> None: """The phase-114 hint channel reaches the banner the SAME way for both throw kinds: the catch passes ``{ hint: err.hint }`` into ``setUiState(UI_STATE.error, …)`` — the pre-stream throw sets a literal hint, the SSE frame throw copies ``ev.hint`` (undefined → showErrorBanner's default). The stream-drop guard's banner call keeps its optionless shape.""" js = _js() assert ( "setUiState(UI_STATE.error, detail, err instanceof Error ? { hint: err.hint } : {});" in _catch_else_body(js) ) guard = js.index("if (!sawDone && !aborted && (acc || thinkingAcc)) {") zero_frame = js.index("if (!aborted && !wrap) {", guard) assert "setUiState(UI_STATE.error, detail);" in js[guard:zero_frame] # --------------------------------------------------------------------------- # CSS — the in-bubble error line (the .stopped-note family, error color) # --------------------------------------------------------------------------- def test_failed_note_css_uses_the_error_token() -> None: """``.failed-note`` exists in styles.css, colored by the theme's error TOKEN (``--err-ink`` — the monochrome theme grays it automatically; the contrast floor is the stopped-note family's) — never a literal color (the phase-92 zero-literal convention).""" css = _css() m = re.search(r"\.failed-note \{([\s\S]*?)\n\}", css) assert m, "styles.css must define .failed-note" body = m.group(1) assert "color: var(--err-ink);" in body, ( ".failed-note must use the theme's error token" ) assert "pointer-events: none;" in body, "the note is non-interactive (the stopped-note way)" def test_failed_detail_wraps_long_details() -> None: """The detail span WRAPS (a 500-char error detail must not blow out the 46rem chat column — the stopped note's nowrap fits a one-word label, not a detail).""" css = _css() m = re.search(r"\.failed-detail \{([\s\S]*?)\n\}", css) assert m, "styles.css must define .failed-detail" assert "overflow-wrap: anywhere;" in m.group(1)