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