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:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
+61 -18
View File
@@ -211,8 +211,9 @@ def _last_query_log() -> QueryLog:
# --------------------------------------------------------------------------
#: Records every value #send-label takes during the turn (a
#: MutationObserver on the element), so the transient "Calling tool…"
#: state is captured deterministically — no polling race.
#: MutationObserver on the element), so the in-flight label state is
#: captured deterministically — no polling race. Phase 48: the label is
#: the Send↔Stop morph ("Stop" holds for the whole in-flight turn).
LABEL_RECORDER = """
() => {
if (window.__labelsInstalled) return;
@@ -232,6 +233,29 @@ LABEL_RECORDER = """
}
"""
#: Records every value #send-status takes during the turn (phase 48:
#: the transient "… is listing documents" / "… is reading <source/path>"
#: calling-tool states moved here from the button label), so their order
#: is captured deterministically — no polling race.
STATUS_RECORDER = """
() => {
if (window.__statusesInstalled) return;
window.__statusesInstalled = true;
window.__statuses = [];
const el = document.querySelector('#send-status');
if (!el) return;
const rec = (v) => {
const l = window.__statuses;
if (!l.length || l[l.length - 1] !== v) l.push(v);
};
rec(el.textContent);
new MutationObserver(() => rec(el.textContent)).observe(el, {
childList: true,
subtree: true,
});
}
"""
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level assertions for
#: the ``tool`` frames, independent of the UI rendering.
@@ -266,12 +290,14 @@ def _install_page_hooks(page: Page) -> None:
"""Install both hooks on the loaded page (post-goto, pre-submit).
The fetch wrapper only needs to be in place before the turn's
``fetch("/api/chat")`` call; the label observer needs the rendered
``#send-label``. (``add_init_script`` would not do — it binds to the
NEXT navigation, and the story page is navigated exactly once.)
``fetch("/api/chat")`` call; the observers need the rendered
``#send-label`` / ``#send-status``. (``add_init_script`` would not do
— it binds to the NEXT navigation, and the story page is navigated
exactly once.)
"""
page.evaluate(SSE_HOOK)
page.evaluate(LABEL_RECORDER)
page.evaluate(STATUS_RECORDER)
def _frames(page: Page) -> list[dict]:
@@ -307,10 +333,16 @@ def _submit(page: Page, question: str) -> None:
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button recovered."""
"""The turn is complete: answer text in the bubble, button recovered.
Phase 48: the label assertion carries the settle wait with an
explicit timeout — the in-flight button is the enabled Stop control
(never disabled), so ``to_be_enabled`` no longer blocks until the
turn settles, and Playwright expect's default (5s) does not inherit
the page default."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# --------------------------------------------------------------------------
@@ -327,20 +359,31 @@ def test_marker_question_lists_reads_and_quotes(
_install_page_hooks(page)
_submit(page, MARKER_QUESTION)
# The "calling tool" label window is transient: the first `tool`
# frame sets it and it holds until the FIRST answer delta (the agent
# loop completes before the answer stream) — ~0.4 s at the mock's
# 0.1 s tool-frame pacing. A polling expect can stride straight over
# that window (observed flake, fixed in phase 44 task 03), so the
# pre-submit MutationObserver record below is the deterministic
# source of truth for the label transition.
# The "calling tool" STATUS window is transient: the first `tool`
# frame sets #send-status and it holds until the FIRST answer delta
# (the agent loop completes before the answer stream) — ~0.4 s at
# the mock's 0.1 s tool-frame pacing. A polling expect can stride
# straight over that window (observed flake, fixed in phase 44 task
# 03), so the pre-submit MutationObserver records below are the
# deterministic source of truth for the label/status transitions.
_wait_settled(page)
# The label transition, recorded deterministically (no race):
# Thinking… → Calling tool… → … → Send.
# Phase 48 (owner-locked): the in-flight button is the Stop control —
# the label holds "Stop" for the whole turn (it no longer relabels to
# "Calling tool…"), and the transient calling-tool state moved to
# #send-status: "… is listing documents" then "… is reading <sp>",
# in order (both recorded deterministically — no race).
labels = page.evaluate("() => window.__labels")
assert "Calling tool…" in labels, labels
assert labels.index("Calling tool…") > labels.index("Thinking…")
assert "Stop" in labels, labels
statuses = page.evaluate("() => window.__statuses")
i_list = next(
(i for i, s in enumerate(statuses) if "is listing documents" in s), None
)
i_read = next(
(i for i, s in enumerate(statuses) if f"is reading {READ_SP}" in s), None
)
assert i_list is not None and i_read is not None, statuses
assert i_list < i_read, statuses
# Wire level: exactly two `tool` frames — list then read — and both
# ahead of the first `delta` frame.
+13 -6
View File
@@ -365,10 +365,16 @@ def _submit(page: Page, question: str) -> None:
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button recovered."""
"""The turn is complete: answer text in the bubble, button recovered.
Phase 48: the label assertion carries the settle wait with an
explicit timeout — the in-flight button is the enabled Stop control
(never disabled), so ``to_be_enabled`` no longer blocks until the
turn settles, and Playwright expect's default (5s) does not inherit
the page default."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# --------------------------------------------------------------------------
@@ -403,13 +409,14 @@ def test_multi_read_turn(
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False
# The transient "calling tool" states, recorded deterministically:
# the label shows "Calling tool…" and #send-status walked through
# The transient "calling tool" states, recorded deterministically.
# Phase 48 (owner-locked): the in-flight button is the Stop control —
# the label holds "Stop" for the whole turn (it no longer relabels
# to "Calling tool…"), and #send-status walked through
# "… is listing documents" then "… is reading <sp>" for BOTH reads,
# in order.
labels = page.evaluate("() => window.__labels")
assert "Calling tool…" in labels, labels
assert labels.index("Calling tool…") > labels.index("Thinking…")
assert "Stop" in labels, labels
statuses = page.evaluate("() => window.__statuses")
i_list = next(
(i for i, s in enumerate(statuses) if "is listing documents" in s), None
+19 -10
View File
@@ -9,7 +9,8 @@ Determinism comes from two mock-LLM behaviors (tests/e2e/mock_llm.py):
* ``pretend to think slowly`` in the user message → a 3s warm-up before
the first token, wide enough to assert the pre-token UI (typing dots +
busy "Thinking…" button) at a known timestamp;
the enabled Stop button — phase 48 revised the busy-button contract) at
a known timestamp;
* the ``POST /__shutdown__`` hook → the ``llm_down`` fixture stops the
shared mock to simulate an LLM outage, then restores a fresh instance
on the same port so later tests keep working.
@@ -205,8 +206,10 @@ def test_typing_indicator_during_slow_think(
def test_button_state_machine(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC1/AC3: disabled + spinner + 'Thinking…' while in flight; enabled
+ 'Send' + focused input after done."""
"""AC1/AC3 (phase 48 revised contract, owner-locked 2026-08-29):
in flight the button is the enabled Stop control — "Stop" label,
.is-stop class, spinner hidden; after done it is enabled + "Send"
(class removed) + the input is focused back."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
@@ -216,15 +219,17 @@ def test_button_state_machine(
btn = page.locator("#send-btn")
label = page.locator("#send-label")
expect(btn).to_be_disabled(timeout=500)
expect(label).to_have_text("Thinking…")
expect(btn.locator(".spinner")).to_be_visible()
expect(btn).to_be_enabled(timeout=500)
expect(label).to_have_text("Stop")
expect(btn).to_have_class(re.compile(r"is-stop"))
expect(btn.locator(".spinner")).to_be_hidden()
expect(page.locator("#send-status")).to_contain_text("thinking")
# Done: button recovers and the input is focused back.
# Done: button recovers to the Send state and the input is focused back.
page.locator(ANSWER).wait_for(state="visible", timeout=30_000)
expect(label).to_have_text("Send", timeout=30_000)
expect(btn).to_be_enabled()
expect(btn).not_to_have_class(re.compile(r"is-stop"))
expect(btn.locator(".spinner")).to_be_hidden()
expect(page.locator("#message-input")).to_be_focused()
@@ -266,8 +271,10 @@ def test_streaming_appends_live(
def test_reduced_motion_keeps_feedback(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC7: under prefers-reduced-motion the dots/spinner stay visible
(static/slower) — feedback is never removed, only calmed."""
"""AC7: under prefers-reduced-motion the typing dots stay visible
(static) — feedback is never removed, only calmed. Phase 48: the
in-flight button is the enabled Stop control (the spinner is no
longer part of the in-flight feedback)."""
_reset_db(mock_llm, seed=True)
page.emulate_media(reduced_motion="reduce")
page.set_default_timeout(30_000)
@@ -278,7 +285,9 @@ def test_reduced_motion_keeps_feedback(
expect(page.locator(TYPING)).to_be_visible(timeout=500)
expect(page.locator(TYPING).locator(".bubble span")).to_have_count(3)
expect(page.locator("#send-btn .spinner")).to_be_visible()
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn .spinner")).to_be_hidden()
# And the turn still completes and recovers.
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
+14 -4
View File
@@ -165,9 +165,16 @@ def brain_bubble_longer_than(n: int, min_bubbles: int = 1) -> str:
def wait_settled(page: Page) -> None:
"""The turn is over: the never-stale contract re-enabled the button."""
"""The turn is over: the label is back to "Send" (phase 48: the
button is never disabled — the in-flight state is the enabled Stop
control, so the label is the settle marker). Phase 48 note: the old
contract let ``to_be_enabled`` block until the turn settled (the
in-flight button was disabled); now the label assertion carries the
wait — with an explicit timeout, since Playwright expect's default
(5s) is shorter than the mock's long turn (~9.5s) and does not
inherit the page default."""
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def wait_scroll_still(page: Page, timeout: float = 10.0) -> float:
@@ -259,15 +266,18 @@ def test_no_autoscroll_during_long_answer(
assert abs(scroll_state(page)["y"] - target) <= STABLE_PX
# Sample the viewport across the rest of the stream ...
# Phase 48: in-flight detection rides the Stop label (the button is
# the enabled Stop control while a turn runs — it is no longer
# disabled, so the disabled-state proxy is gone).
samples: list[float] = []
mid_stream = 0
deadline = time.monotonic() + 40
while time.monotonic() < deadline:
time.sleep(0.25)
samples.append(scroll_state(page)["y"])
if not page.locator("#send-btn").is_enabled():
if page.locator("#send-label").inner_text() == "Stop":
mid_stream += 1
if len(samples) >= 10 and page.locator("#send-btn").is_enabled():
if len(samples) >= 10 and page.locator("#send-label").inner_text() == "Send":
# ... and a few more AFTER `done` (the turn is over; nothing
# queued behind the stream may move the page either).
for _ in range(3):
+8 -5
View File
@@ -188,8 +188,10 @@ def test_partial_answer_survives_sources_nav_midstream(
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
# The turn is still in flight (the stream runs ~9s; navigation takes
# well under that).
expect(page.locator("#send-btn")).to_be_disabled()
# well under that) — phase 48: the in-flight button is the enabled
# Stop control, not the old disabled busy state.
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# THE BUG REPORT, VERBATIM: click "Sources" while chat is generating.
page.click("#nav-sources")
@@ -257,9 +259,10 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
thinking = page.locator(".msg.brain").last.locator("details.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(THINKING_TAIL)
# Still pre-token: the button is busy with the Thinking state.
expect(page.locator("#send-btn")).to_be_disabled()
expect(page.locator("#send-label")).to_have_text("Thinking…")
# Still pre-token: the button is the enabled Stop control (phase 48 —
# the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Leave during the pause (no answer token has streamed — acc is empty,
# so the pagehide save point must persist nothing brain-side).
+334
View File
@@ -0,0 +1,334 @@
"""Phase 48 E2E (Playwright): stop / cancel an in-flight answer.
Source: ``TODO.md`` L3 — "Need a way to stop or cancel generation of text
in the chat" (no user story file — TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_stop_generation.py -v --no-cov
Mock-only, no admin login (chat is public). The owner-locked contract
(2026-08-29) under test:
* while a turn is in flight the Send button is the enabled **Stop**
control ("Stop" label, ``.is-stop`` rose treatment) — a click *or* an
Enter in the focused input stops the turn;
* a mid-stream stop keeps the partial answer on screen, shows the
``.stopped-note`` "Stopped" marker, no error banner, and persists the
partial with the optional ``stopped: true`` marker in ``bor.chat.v1``
(a reload restores it, conversation order intact);
* a pre-token stop leaves only the question in the conversation (no
brain bubble, no brain-side record — phase-20 convention);
* the server tears the model stream down on the client disconnect and
writes **no** ``query_log`` row for a cancelled turn (unit-proven in
``tests/unit/test_chat_cancel.py``; asserted here against the live
database after the browser-side stop settles).
Determinism: the mock streams 12 chars / 0.02 s, so the ~900-word long
answer ("write a long answer" trigger, the phase-11 on-topic phrasing)
takes ~8 s — a comfortable stop window; the pre-token window is the
mock's 3 s "pretend to think slowly" warm-up (phase-06 phrasing). The
tests wait for observable states only (the one sanctioned 1.5 s
re-read is the partial-stability check from the task).
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: On-topic question + the mock's long-answer trigger — the phase-11
#: phrasing, so the honesty gate is HIGH and the ~900-word answer
#: streams for ~8 s (12 chars / 0.02 s): the stop window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: Phase-06 phrasing: the mock's 3 s warm-up before the first token —
#: the observable pre-token window.
SLOW_QUESTION = "pretend to think slowly then tell me about kubernetes"
SLOW_QUESTION_2 = "pretend to think slowly then tell me about backups"
#: The long answer's unique final line — absent from any partial.
LONG_ANSWER_END = "LONG-ANSWER-END"
STORAGE_KEY = "bor.chat.v1"
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 02/03/06/14 story suites)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int) -> ImportSummary:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _assert_no_error_banner(page: Page) -> None:
"""A stop settles to idle through the same finally path — never the
red role=alert error banner (the KB-offline banner is a separate,
health-driven state the db_ready fixture keeps away)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
# ---------------------------------------------------------------------------
# Shared flows
# ---------------------------------------------------------------------------
def _stop_mid_stream(page: Page) -> str:
"""Ask the long on-topic question and Stop it once a few words of
answer have streamed. Returns the rendered partial text (stable
after the stop)."""
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
# First deltas: the brain bubble exists and grows past a few words.
answer = page.locator(ANSWER)
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the stop"
# In flight: the button IS the enabled Stop control.
btn = page.locator("#send-btn")
expect(btn).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
expect(btn).to_have_class(re.compile(r"is-stop"))
# Stop it.
btn.click()
# Settled to idle: Send again, stop treatment gone, no error banner.
expect(page.locator("#send-label")).to_have_text("Send", timeout=5_000)
expect(btn).to_be_enabled()
expect(btn).not_to_have_class(re.compile(r"is-stop"))
_assert_no_error_banner(page)
# The partial is kept on screen — real answer text, and far shorter
# than the mock's full long answer (its unique final line never
# arrived).
stopped_text = answer.inner_text()
assert stopped_text.strip()
assert "Step 1:" in stopped_text
assert LONG_ANSWER_END not in stopped_text
# Stable: no further growth ~1.5 s after the stop.
page.wait_for_timeout(1_500)
assert answer.inner_text() == stopped_text, (
"the partial answer kept growing after the stop"
)
return stopped_text
def _stop_pre_token(page: Page, question: str, via_enter: bool) -> None:
"""Ask a slow question and stop it during the 3 s pre-token warm-up —
by clicking the Stop control, or by pressing Enter in the focused
input (owner-locked: click *or* Enter)."""
page.fill("#message-input", question)
if via_enter:
page.keyboard.press("Enter") # the fill focused the input; it submits
else:
page.click("#send-btn")
# In the warm-up window the button reads Stop (thinking state).
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
if via_enter:
# The input kept focus through the submit — Enter stops the turn.
expect(page.locator("#message-input")).to_be_focused()
page.keyboard.press("Enter")
else:
page.locator("#send-btn").click()
# Settled to idle: no error banner, no brain bubble at all (the typing
# indicator is gone with it), the question survived, focus is back.
expect(page.locator("#send-label")).to_have_text("Send", timeout=5_000)
btn = page.locator("#send-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_class(re.compile(r"is-stop"))
_assert_no_error_banner(page)
expect(page.locator(".msg.brain")).to_have_count(0)
expect(page.locator(ANSWER)).to_have_count(0)
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator("#message-input")).to_be_focused()
# ---------------------------------------------------------------------------
# 1. Mid-stream stop: partial kept + "Stopped" note + stopped persistence
# ---------------------------------------------------------------------------
def test_stop_mid_stream(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
stopped_text = _stop_mid_stream(page)
# The bubble keeps the partial and carries the "Stopped" note.
expect(page.locator(ANSWER)).to_contain_text(stopped_text)
note = page.locator(".msg.brain .stopped-note")
expect(note).to_have_count(1)
expect(note.first).to_contain_text("Stopped")
# No sources on a stopped turn — it never settled.
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
# The live region confirms the stop (no error wording).
expect(page.locator("#send-status")).to_contain_text("Answer stopped.")
# Persistence: question, then the partial with the optional
# ``stopped`` marker (raw text — no HTML, no LONG-ANSWER-END).
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == LONG_QUESTION
last = stored["messages"][-1]
assert last["stopped"] is True
assert last["text"].strip()
assert "Step 1:" in last["text"]
assert LONG_ANSWER_END not in last["text"]
# Server side: the cancelled turn wrote no durable record (the
# stability wait above already gave the teardown its margin).
assert _query_log_count() == 0
# ---------------------------------------------------------------------------
# 2. Pre-token stop: question kept, nothing brain-side (click and Enter)
# ---------------------------------------------------------------------------
def test_stop_pre_token(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
_stop_pre_token(page, SLOW_QUESTION, via_enter=False)
# The conversation holds exactly the question — no brain-side record.
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user"]
assert stored["messages"][0]["text"] == SLOW_QUESTION
assert "stopped" not in stored["messages"][0]
# The owner-locked "click *or* Enter": the same pre-token stop, this
# time submitted and stopped through the focused input's Enter.
_stop_pre_token(page, SLOW_QUESTION_2, via_enter=True)
expect(page.locator(".msg.user .bubble")).to_have_count(2)
expect(page.locator(".msg.brain")).to_have_count(0)
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "user"]
assert stored["messages"][-1]["text"] == SLOW_QUESTION_2
# Server side: neither pre-token stop left a durable record. The UI
# settle was observed above; the short margin covers the server's
# disconnect teardown (mock cadence 0.02 s).
page.wait_for_timeout(500)
assert _query_log_count() == 0
# ---------------------------------------------------------------------------
# 3. A stopped turn survives a reload
# ---------------------------------------------------------------------------
def test_stopped_turn_survives_reload(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
stopped_text = _stop_mid_stream(page)
assert _query_log_count() == 0
page.reload()
# Restored: the user message first, then the stopped brain bubble —
# the same partial text, the "Stopped" note, and the idle Send button.
expect(page.locator("#empty-state")).to_be_hidden()
msgs = page.locator("#messages > .msg")
expect(msgs).to_have_count(2)
expect(msgs.nth(0)).to_have_class(re.compile(r"msg user"))
expect(msgs.nth(1)).to_have_class(re.compile(r"msg brain"))
expect(page.locator(".msg.user .bubble")).to_contain_text(LONG_QUESTION)
bubble = page.locator(ANSWER)
expect(bubble).to_have_count(1)
assert bubble.inner_text() == stopped_text, "the restored partial differs"
note = page.locator(".msg.brain .stopped-note")
expect(note).to_have_count(1)
expect(note.first).to_contain_text("Stopped")
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
# The marker still rides the stored record after the restore.
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][-1]["stopped"] is True
+5 -2
View File
@@ -115,10 +115,13 @@ def send_and_wait(page: Page, question: str) -> None:
page.evaluate("() => document.querySelector('#composer').requestSubmit()")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
# The mock streams at 0.02s/chunk, so thinking + answer land in a few
# seconds — 30s is generous on headless Chromium.
# seconds — 30s is generous on headless Chromium. Phase 48: the label
# assertion carries the settle wait with an explicit timeout — the
# in-flight button is the enabled Stop control (never disabled), so
# to_be_enabled no longer blocks until the turn settles.
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# ---------------------------------------------------------------------------
+9 -4
View File
@@ -176,10 +176,13 @@ def send_and_wait(page: Page, question: str) -> None:
page.evaluate("() => document.querySelector('#composer').requestSubmit()")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
# The mock streams at 0.02s/chunk, so thinking + answer land in a few
# seconds — 30s is generous on headless Chromium.
# seconds — 30s is generous on headless Chromium. Phase 48: the label
# assertion carries the settle wait with an explicit timeout — the
# in-flight button is the enabled Stop control (never disabled), so
# to_be_enabled no longer blocks until the turn settles.
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def submit(page: Page, question: str) -> None:
@@ -787,8 +790,10 @@ def test_thinking_window_follows_across_paragraph_breaks(
)
# The turn settles; the scratchpad text past the break is intact and
# the answer landed.
# the answer landed. Phase 48: the label assertion carries the settle
# wait (explicit timeout — the in-flight Stop button is never
# disabled, so to_be_enabled no longer blocks until the turn ends).
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(details.locator(".thinking-text")).to_contain_text("Step 3")
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)