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.