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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user