feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down
This commit is contained in:
@@ -23,6 +23,10 @@ def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_turn_timeout_constant_exported_at_120s() -> None:
|
||||
"""The 120s client-side guard (PLAN §7.4) must be an *exported*
|
||||
constant — testable, and the single value the E2E timeout story keys
|
||||
@@ -91,15 +95,22 @@ def test_reduced_motion_calm_not_removed() -> None:
|
||||
|
||||
|
||||
def test_busy_button_style_tokens() -> None:
|
||||
"""Story spec: busy send button is #a5b4fc with the 16px dark-arc
|
||||
spinner (--bg on #a5b4fc = 9.7:1, phase 08); label swaps Send ↔ Thinking…."""
|
||||
"""Phase 48 (revised contract, owner-locked 2026-08-29): in flight
|
||||
the button is the enabled Stop control — "Stop" label, .is-stop
|
||||
class (rose treatment, 6.3:1 with the #fff label), spinner hidden;
|
||||
idle/error keep the brand Send button (dark ink on brand 5.2:1).
|
||||
The spinner element stays in the markup + CSS (16px dark arc — the
|
||||
reduced-motion pin below) but the state machine never shows it: the
|
||||
Stop label + treatment carry the in-flight state."""
|
||||
css = _css()
|
||||
js = _js()
|
||||
assert ".send-btn:disabled" in css
|
||||
assert "#a5b4fc" in css
|
||||
assert ".send-btn.is-stop" in css
|
||||
assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)"
|
||||
assert ".send-btn.is-stop:hover" in css, "the darker hover step"
|
||||
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
|
||||
assert "Thinking…" in js
|
||||
assert 'sendLabel.textContent' in js
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
|
||||
assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js
|
||||
assert "sendBtn.disabled = false" in js, "the button is a control, never disabled"
|
||||
|
||||
|
||||
# ---------- thinking display (phase 17) ----------
|
||||
@@ -198,3 +209,160 @@ def test_thinking_chevron_stills_under_reduced_motion() -> None:
|
||||
"details.thinking summary::before" in b and "transition: none" in b
|
||||
for b in blocks
|
||||
), "chevron transition must still under reduced motion"
|
||||
|
||||
|
||||
# ---------- stop generation (phase 48, task 02) ----------
|
||||
|
||||
|
||||
def test_in_flight_button_is_the_stop_control() -> None:
|
||||
"""Phase 48 (owner-locked 2026-08-29): in flight the button is the
|
||||
enabled Stop control — "Stop" label, .is-stop class, spinner hidden
|
||||
(the label + the rose treatment carry the state); idle/error keep
|
||||
the Send label with the class removed. The state machine otherwise
|
||||
stays unchanged (same four states, same single entry point)."""
|
||||
js = _js()
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
|
||||
assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js
|
||||
assert 'sendBtn.querySelector(".spinner").hidden = true' in js, (
|
||||
"the spinner never shows — the Stop label carries the state"
|
||||
)
|
||||
assert "sendBtn.disabled = false" in js, "enabled in every state"
|
||||
|
||||
|
||||
def test_abort_plumbing_owns_the_fetch() -> None:
|
||||
"""The in-flight fetch is owned by an AbortController created at
|
||||
turn start (module scope, cleared in the finally), passed to the
|
||||
fetch as its signal; the 120s guard aborts the same controller as
|
||||
its backstop — with `aborted = true` FIRST, so the catch never reads
|
||||
the guard's abort as a user stop (one owner, same outcome)."""
|
||||
js = _js()
|
||||
assert "let turnAbort = null" in js, "module-scope abort owner"
|
||||
assert "turnAbort = new AbortController()" in js, "fresh controller per turn"
|
||||
assert "signal: turnAbort.signal" in js, "the fetch carries the signal"
|
||||
guard_start = js.find("armTurnTimeout(() => {")
|
||||
guard = js[guard_start : js.find("});", guard_start)]
|
||||
assert "aborted = true" in guard and "turnAbort?.abort()" in guard, (
|
||||
"the guard keeps cancelStream + the abort as backstops"
|
||||
)
|
||||
assert guard.index("aborted = true") < guard.index("turnAbort?.abort()"), (
|
||||
"aborted must be set before the guard's abort"
|
||||
)
|
||||
handle = js.find("async function handleSend")
|
||||
finally_idx = js.find("} finally {", handle)
|
||||
finally_block = js[finally_idx : finally_idx + 700]
|
||||
assert "turnAbort = null" in finally_block, "the abort owner is spent after the turn"
|
||||
|
||||
|
||||
def test_stop_turn_is_the_user_abort() -> None:
|
||||
"""stopTurn: a no-op unless a turn is in flight (thinking/streaming);
|
||||
it marks the turn as user-stopped and aborts. The in-flight guard at
|
||||
the top of handleSend routes a click / Enter-to-submit to it BEFORE
|
||||
the !text guard — the enabled in-flight button can never start a
|
||||
second turn."""
|
||||
js = _js()
|
||||
fn = js.find("function stopTurn")
|
||||
assert fn != -1, "stopTurn must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "uiState !== UI_STATE.thinking" in body
|
||||
assert "uiState !== UI_STATE.streaming" in body
|
||||
assert "stoppedByUser = true" in body
|
||||
assert "turnAbort?.abort()" in body
|
||||
handle = js.find("async function handleSend")
|
||||
guard_idx = js.find("stopTurn();", handle)
|
||||
text_idx = js.find("const text = input.value.trim()", handle)
|
||||
assert handle < guard_idx < text_idx, (
|
||||
"the in-flight guard (→ stopTurn) must precede the !text guard"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
"""The stop path in handleSend's catch: no error state, no error
|
||||
banner; when answer text streamed the partial is kept on screen
|
||||
(thinking block closed, Tune + Stopped note appended — admin parity
|
||||
with the restore path) and persisted with the owner-locked optional
|
||||
`stopped: true` marker (+ optional thinking/tools); a pre-token stop
|
||||
persists nothing brain-side (phase-20 convention). The "Answer
|
||||
stopped." live-region confirmation is set in the finally, AFTER the
|
||||
single settle, so setUiState(idle) can't overwrite it."""
|
||||
js = _js()
|
||||
handle = js.find("async function handleSend")
|
||||
catch_idx = js.find("} catch (err) {", handle)
|
||||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch_idx)
|
||||
finally_idx = js.find("} finally {", catch_idx)
|
||||
assert catch_idx < stop_idx < finally_idx, "the stop branch must live in the catch"
|
||||
# The stop branch only (the error `else` follows it and is not pinned here).
|
||||
branch = js[stop_idx : js.find("} else {", stop_idx)]
|
||||
assert "setUiState(UI_STATE.error" not in branch, "no error state on the stop path"
|
||||
assert "showErrorBanner" not in branch, "no error banner on the stop path"
|
||||
assert "if (wrap && acc && !persistedOnLeave)" in branch, (
|
||||
"only a partial WITH answer text is persisted (phase-20 dedupe)"
|
||||
)
|
||||
assert "closeThinkingBlock(wrap)" in branch
|
||||
assert "appendTuneButton(wrap)" in branch, "admin parity with the restore path"
|
||||
assert "appendStoppedNote(wrap)" in branch
|
||||
assert "stopped: true" in branch, "the owner-locked optional marker"
|
||||
assert "thinking: thinkingAcc || undefined" in branch
|
||||
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
|
||||
# The confirmation rides the single settle in the finally.
|
||||
finally_block = js[finally_idx : finally_idx + 900]
|
||||
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
|
||||
|
||||
|
||||
def test_stopped_note_helper_and_restore_path() -> None:
|
||||
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like
|
||||
appendTuneButton (role=list → the span joins as a listitem), one
|
||||
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the
|
||||
"Stopped" text (the accessible meaning). The restore path renders it
|
||||
for records with `m.stopped` (phase-14 optional-field convention —
|
||||
no version bump)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendStoppedNote")
|
||||
assert fn != -1, "appendStoppedNote must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
|
||||
assert 'className = "msg-meta"' in body, "creates it otherwise"
|
||||
assert 'className = "stopped-note"' in body
|
||||
assert 'querySelector(".stopped-note")' in body, "one note per bubble"
|
||||
assert 'note.setAttribute("role", "listitem")' in body
|
||||
assert 'aria-hidden="true"' in body, "the glyph is decoration"
|
||||
assert '"Stopped"' in body, "the text carries the accessible meaning"
|
||||
# Restore path: the same helper, gated on the stored marker.
|
||||
rfn = js.find("function renderStoredMessage")
|
||||
rbody = js[rfn : js.find("\n}\n", rfn)]
|
||||
assert "if (m.stopped) appendStoppedNote(wrap)" in rbody
|
||||
|
||||
|
||||
def test_tool_branch_no_longer_writes_the_button_label() -> None:
|
||||
"""Phase 48 (owner-locked): the `tool` frame no longer relabels the
|
||||
button — it stays "Stop" for the whole in-flight turn; the
|
||||
calling-tool status lives in #send-status + the typing indicator's
|
||||
aria-label only (exactly where the phase-37 state used to write)."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert "sendLabel" not in branch, "the button keeps its Stop label"
|
||||
assert "sendStatus.textContent = toolStatus" in branch
|
||||
assert 'setAttribute("aria-label", toolStatus)' in branch
|
||||
|
||||
|
||||
def test_composer_form_is_novalidate() -> None:
|
||||
"""Phase 48 (latent-defect fix, 2026-08-29): the composer form must
|
||||
skip browser constraint validation. The input is cleared after every
|
||||
send, so a `required` textarea would fail validation on the Stop
|
||||
click/Enter — the `submit` event never fires and handleSend's
|
||||
in-flight guard never runs, so the Stop control is dead. The `!text`
|
||||
guard in app.js is the real empty-input check (same precedent as the
|
||||
tuning form's noValidate)."""
|
||||
html = _html()
|
||||
composer = html.find('id="composer"')
|
||||
assert composer != -1, "index.html must contain #composer"
|
||||
form_tag = html[html.rfind("<form", 0, composer) : html.find(">", composer) + 1]
|
||||
assert "novalidate" in form_tag.lower(), (
|
||||
"the composer form must carry novalidate — a `required` input that "
|
||||
"is empty in flight would silently block the Stop submit"
|
||||
)
|
||||
textarea = html[composer: html.find("</textarea>", composer)]
|
||||
assert not re.search(r"\brequired\b", textarea), (
|
||||
"the composer textarea must not carry `required` (see novalidate)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user