2026-04-12 18:08:59 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Sprint 36 Tests: cancelStream cleanup no longer depends on SSE event (PR #309 / issue #299).
|
|
|
|
|
|
|
|
|
|
|
|
The old cancelStream() set "Cancelling..." status and then relied on the SSE cancel
|
|
|
|
|
|
event to clear it. If the SSE connection was already closed, the event never arrived
|
|
|
|
|
|
and "Cancelling..." lingered indefinitely.
|
|
|
|
|
|
|
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
|
|
|
|
The fix: cancelStream() now clears status, busy state, and activeStreamId directly after
|
|
|
|
|
|
the cancel API request completes — regardless of whether the SSE cancel event fires.
|
|
|
|
|
|
The SSE handler still runs if it arrives (all operations idempotent).
|
2026-04-12 18:08:59 +00:00
|
|
|
|
|
|
|
|
|
|
Covers:
|
|
|
|
|
|
1. cancelStream() clears activeStreamId unconditionally after the fetch
|
|
|
|
|
|
2. cancelStream() calls setBusy(false) unconditionally
|
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
|
|
|
|
3. cancelStream() calls setStatus('') / setComposerStatus('') unconditionally
|
|
|
|
|
|
4. cancelStream() clears composer status text unconditionally
|
2026-04-12 18:08:59 +00:00
|
|
|
|
5. The catch block no longer calls setStatus(cancel_failed) — cleanup runs even on error
|
|
|
|
|
|
6. The SSE cancel handler is still present (idempotent path)
|
|
|
|
|
|
7. cancel_failed i18n key is still defined in all locales (key exists, just not used in
|
|
|
|
|
|
the catch-path anymore — kept for potential future use)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import pathlib
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read(path):
|
|
|
|
|
|
return (REPO / path).read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 06:47:24 +00:00
|
|
|
|
def _locale_count(src: str) -> int:
|
|
|
|
|
|
pattern = re.compile(
|
|
|
|
|
|
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
|
|
|
|
|
|
re.MULTILINE,
|
|
|
|
|
|
)
|
|
|
|
|
|
return sum(1 for _ in pattern.finditer(src))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 18:08:59 +00:00
|
|
|
|
# ── 1–4. cancelStream() cleanup is unconditional ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class TestCancelStreamCleanup:
|
|
|
|
|
|
"""cancelStream() must clear all busy state regardless of SSE connection state."""
|
|
|
|
|
|
|
|
|
|
|
|
def _get_cancel_block(self):
|
|
|
|
|
|
"""Extract the cancelStream function body from boot.js."""
|
|
|
|
|
|
src = read("static/boot.js")
|
2026-07-01 15:34:35 +00:00
|
|
|
|
# Signature-tolerant: cancelStream now takes a `reason` param (#5345), so
|
|
|
|
|
|
# match the declaration regardless of its parameter list.
|
|
|
|
|
|
m = re.search(r"async function cancelStream\s*\(", src)
|
|
|
|
|
|
assert m is not None, "cancelStream not found in boot.js"
|
|
|
|
|
|
idx = m.start()
|
2026-04-12 18:08:59 +00:00
|
|
|
|
# Find the closing brace — scan for the matching }
|
|
|
|
|
|
depth = 0
|
|
|
|
|
|
end = idx
|
|
|
|
|
|
for i, ch in enumerate(src[idx:]):
|
|
|
|
|
|
if ch == '{':
|
|
|
|
|
|
depth += 1
|
|
|
|
|
|
elif ch == '}':
|
|
|
|
|
|
depth -= 1
|
|
|
|
|
|
if depth == 0:
|
|
|
|
|
|
end = idx + i + 1
|
|
|
|
|
|
break
|
|
|
|
|
|
return src[idx:end]
|
|
|
|
|
|
|
|
|
|
|
|
def test_clears_active_stream_id(self):
|
|
|
|
|
|
"""cancelStream() must null out S.activeStreamId after the request."""
|
|
|
|
|
|
block = self._get_cancel_block()
|
|
|
|
|
|
assert "S.activeStreamId=null" in block or "S.activeStreamId = null" in block, (
|
|
|
|
|
|
"cancelStream() does not clear S.activeStreamId — "
|
|
|
|
|
|
"subsequent calls could re-cancel an already-finished stream"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_calls_set_busy_false(self):
|
|
|
|
|
|
"""cancelStream() must call setBusy(false) directly."""
|
|
|
|
|
|
block = self._get_cancel_block()
|
|
|
|
|
|
assert "setBusy(false)" in block, (
|
|
|
|
|
|
"cancelStream() does not call setBusy(false) — "
|
|
|
|
|
|
"spinner may linger if SSE connection is already closed"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_calls_set_status_empty(self):
|
|
|
|
|
|
"""cancelStream() must call setStatus('') to clear 'Cancelling...' text."""
|
|
|
|
|
|
block = self._get_cancel_block()
|
|
|
|
|
|
assert "setStatus('')" in block or 'setStatus("")' in block, (
|
|
|
|
|
|
"cancelStream() does not clear status text — "
|
|
|
|
|
|
"'Cancelling...' can linger if SSE cancel event never arrives"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
|
|
|
|
def test_clears_composer_status(self):
|
|
|
|
|
|
"""cancelStream() must clear the composer status text unconditionally."""
|
2026-04-12 18:08:59 +00:00
|
|
|
|
block = self._get_cancel_block()
|
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
|
|
|
|
assert "setComposerStatus" in block or "setStatus" in block, (
|
|
|
|
|
|
"cancelStream() does not clear composer/status text — "
|
|
|
|
|
|
"'Cancelling…' or stale status can linger if SSE cancel event never arrives"
|
2026-04-12 18:08:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_cleanup_not_inside_try_block(self):
|
|
|
|
|
|
"""Cleanup must happen outside the try block so it runs even if fetch fails."""
|
|
|
|
|
|
block = self._get_cancel_block()
|
|
|
|
|
|
# The S.activeStreamId=null and setBusy(false) must appear after the try/catch
|
|
|
|
|
|
# Verify they are NOT only inside the try block by checking position relative to catch
|
|
|
|
|
|
try_idx = block.find("try{")
|
|
|
|
|
|
catch_idx = block.find("}catch(")
|
|
|
|
|
|
cleanup_idx = block.find("S.activeStreamId=null")
|
|
|
|
|
|
if cleanup_idx == -1:
|
|
|
|
|
|
cleanup_idx = block.find("S.activeStreamId = null")
|
|
|
|
|
|
assert cleanup_idx > catch_idx, (
|
|
|
|
|
|
"S.activeStreamId cleanup appears to be inside the try block — "
|
|
|
|
|
|
"it won't run if the fetch throws"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 5. Error path behavior ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class TestCancelStreamErrorPath:
|
|
|
|
|
|
"""The catch block should not prevent cleanup from running."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_catch_block_does_not_call_set_status_cancel_failed(self):
|
|
|
|
|
|
"""The catch block must not call setStatus(cancel_failed) on its own.
|
|
|
|
|
|
|
|
|
|
|
|
Previously: catch(e){setStatus(t('cancel_failed')+e.message)}
|
|
|
|
|
|
After fix: catch swallows the error; cleanup runs in the outer scope.
|
|
|
|
|
|
The status is cleared by setStatus('') unconditionally.
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = read("static/boot.js")
|
2026-07-01 15:34:35 +00:00
|
|
|
|
# Signature-tolerant match (cancelStream now takes a `reason` param, #5345).
|
|
|
|
|
|
m = re.search(r"async function cancelStream\s*\(", src)
|
|
|
|
|
|
assert m is not None, "cancelStream not found in boot.js"
|
|
|
|
|
|
idx = m.start()
|
|
|
|
|
|
# Widen the window: the provenance log + comments added for #5345 sit
|
|
|
|
|
|
# before the try/catch, so 400 chars no longer reaches the catch block.
|
|
|
|
|
|
block = src[idx:idx + 1200]
|
2026-04-12 18:08:59 +00:00
|
|
|
|
# The old pattern was setStatus inside catch; new pattern has it outside
|
|
|
|
|
|
# Look for the catch block specifically
|
|
|
|
|
|
catch_idx = block.find("}catch(")
|
|
|
|
|
|
if catch_idx == -1:
|
|
|
|
|
|
catch_idx = block.find("} catch (")
|
|
|
|
|
|
assert catch_idx != -1, "No catch block found in cancelStream"
|
|
|
|
|
|
# Get just the catch body
|
|
|
|
|
|
brace_open = block.find("{", catch_idx)
|
|
|
|
|
|
brace_close = block.find("}", brace_open)
|
|
|
|
|
|
catch_body = block[brace_open:brace_close + 1]
|
|
|
|
|
|
assert "cancel_failed" not in catch_body, (
|
|
|
|
|
|
"catch block still calls setStatus(cancel_failed) — "
|
|
|
|
|
|
"this means a failed cancel shows an error instead of cleaning up silently"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 6. SSE cancel handler still present ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_sse_cancel_handler_still_present():
|
|
|
|
|
|
"""The SSE 'cancel' event handler must still exist in messages.js.
|
|
|
|
|
|
|
|
|
|
|
|
The new cancelStream() cleanup is not a replacement — the SSE handler
|
|
|
|
|
|
provides additional cleanup (removes 'Task cancelled.' message, clears
|
|
|
|
|
|
tool cards, etc.) when the connection is still alive.
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = read("static/messages.js")
|
|
|
|
|
|
assert "addEventListener('cancel'" in src or 'addEventListener("cancel"' in src, (
|
|
|
|
|
|
"SSE cancel event handler missing from messages.js — "
|
|
|
|
|
|
"live cancellation cleanup path is broken"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sse_cancel_handler_calls_set_busy():
|
|
|
|
|
|
"""The SSE cancel handler must still call setBusy(false)."""
|
|
|
|
|
|
src = read("static/messages.js")
|
2026-04-23 05:17:40 +00:00
|
|
|
|
idx = src.find("addEventListener('cancel'")
|
2026-04-12 18:08:59 +00:00
|
|
|
|
if idx == -1:
|
|
|
|
|
|
idx = src.find('addEventListener("cancel"')
|
|
|
|
|
|
assert idx != -1
|
2026-04-23 05:17:40 +00:00
|
|
|
|
# Find the closing of this handler block (next top-level addEventListener)
|
|
|
|
|
|
next_handler = src.find("source.addEventListener(", idx + 50)
|
|
|
|
|
|
block = src[idx:next_handler] if next_handler != -1 else src[idx:idx + 3000]
|
2026-05-06 21:56:17 +00:00
|
|
|
|
assert (
|
|
|
|
|
|
"setBusy(false)" in block
|
|
|
|
|
|
or "_setActivePaneIdleIfOwner()" in block
|
|
|
|
|
|
), (
|
|
|
|
|
|
"SSE cancel handler no longer idles the owning active pane"
|
2026-04-12 18:08:59 +00:00
|
|
|
|
)
|
2026-05-06 21:56:17 +00:00
|
|
|
|
if "_setActivePaneIdleIfOwner()" in block:
|
|
|
|
|
|
helper_idx = src.find("function _setActivePaneIdleIfOwner")
|
|
|
|
|
|
assert helper_idx != -1
|
|
|
|
|
|
next_function = src.find("\n function ", helper_idx + 1)
|
|
|
|
|
|
helper = src[helper_idx:next_function if next_function != -1 else helper_idx + 800]
|
|
|
|
|
|
assert "setBusy(false)" in helper
|
chore(release): stamp v0.51.14 — 4-PR contributor batch (#1756, #1757, #1760, #1761)
Constituent PRs:
- #1760 (@ai-ag2026) preserve pending user turn on stream errors. Closes #1361.
- #1761 (@dso2ng) scope terminal stream cleanup to owner session. Refs #1694.
AUTO-FIX applied: restored !INFLIGHT[S.session.session_id] disjunct in
_setActivePaneIdleIfOwner (regression introduced by helper centralization).
- #1756 (@ng-technology-llc) isolate profile cookie per webui instance. Closes #803.
- #1757 (@skspade) tri-state gateway status (alive: True/False/None).
Tests: 4642 → 4662 collected (+20). 4649 passed, 9 skipped (test-isolation
prong-2 noise), 3 xpassed, 0 failed in 152s.
Pre-release verification:
- All 4 PRs CI-green or rebased clean (#1757 had stale base; CHANGELOG conflict
auto-resolved by dropping the PR's redundant entry).
- node -c clean on static/messages.js + static/panels.js.
- 11/11 browser API endpoints PASS.
- Pre-stamp re-fetch: all PR heads match local rebases.
- Opus advisor: SHIP, all 5 verification questions clean, 0 MUST-FIX, 0 SHOULD-FIX.
- Two NICE-TO-HAVE coverage gaps absorbed in-release:
(1) test_sprint36.py asserts !INFLIGHT[...] disjunct in helper body
(2) test_issue1361_cancel_data_loss.py adds structural-grep test to pin
_materialize_pending_user_turn_before_error call sites at error branches.
Closes #803, #1361, #1694.
2026-05-06 22:20:17 +00:00
|
|
|
|
# The helper MUST preserve the v0.51.12 (#1753) 3-way OR guard so
|
|
|
|
|
|
# idling the active pane on a background completion is gated on the
|
|
|
|
|
|
# permissive-fallback disjunct ("no other inflight on the active pane")
|
|
|
|
|
|
# in addition to "is active" / "no session". Without this, a user
|
|
|
|
|
|
# viewing pane A (idle) while pane B completes in the background
|
|
|
|
|
|
# would not get pane A's composer state cleared. Catches the exact
|
|
|
|
|
|
# regression v0.51.14's auto-fix repaired in PR #1761.
|
|
|
|
|
|
assert "!INFLIGHT[S.session.session_id]" in helper, (
|
|
|
|
|
|
"_setActivePaneIdleIfOwner must preserve the !INFLIGHT[...] "
|
|
|
|
|
|
"permissive-fallback disjunct from PR #1753 (v0.51.12)."
|
|
|
|
|
|
)
|
2026-04-12 18:08:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 7. i18n key preserved ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_failed_i18n_key_exists_in_all_locales():
|
|
|
|
|
|
"""cancel_failed key must still exist in i18n.js for all locales."""
|
|
|
|
|
|
src = read("static/i18n.js")
|
2026-04-19 06:47:24 +00:00
|
|
|
|
# Should appear once per locale (en, es, de, ru, zh, zh-Hant)
|
|
|
|
|
|
locale_count = _locale_count(src)
|
2026-04-12 18:08:59 +00:00
|
|
|
|
count = src.count("cancel_failed:")
|
2026-04-19 06:47:24 +00:00
|
|
|
|
assert count >= locale_count, (
|
2026-04-12 18:08:59 +00:00
|
|
|
|
f"cancel_failed key only found {count} times in i18n.js — "
|
2026-04-19 06:47:24 +00:00
|
|
|
|
f"expected at least {locale_count} (one per locale)"
|
2026-04-12 18:08:59 +00:00
|
|
|
|
)
|
2026-04-23 05:17:40 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 8. Server-persisted cancel marker doesn't leak into agent history ────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_marker_flagged_as_error_to_skip_in_api_history():
|
|
|
|
|
|
"""The server-side cancel marker appended in cancel_stream() must carry
|
|
|
|
|
|
_error: True so _sanitize_messages_for_api() strips it from the
|
|
|
|
|
|
conversation_history sent to the agent on the next user message.
|
|
|
|
|
|
|
2026-05-12 19:53:28 +00:00
|
|
|
|
Without this flag, the LLM sees "Task cancelled" as a prior assistant
|
2026-04-23 05:17:40 +00:00
|
|
|
|
turn and may reference it in subsequent responses ("As I mentioned, I was
|
|
|
|
|
|
cancelled...") — a behavioral regression introduced when this PR started
|
|
|
|
|
|
persisting the marker to the session.
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = read("api/streaming.py")
|
2026-05-19 13:06:19 +00:00
|
|
|
|
idx = src.find("'content': _cancelled_turn_content(message")
|
2026-05-12 19:53:28 +00:00
|
|
|
|
assert idx != -1, "cancel marker content writer not found in cancel_stream()"
|
2026-04-23 05:17:40 +00:00
|
|
|
|
|
|
|
|
|
|
# Walk back to the start of the dict literal (opening brace)
|
|
|
|
|
|
brace_open = src.rfind("{", 0, idx)
|
|
|
|
|
|
brace_close = src.find("}", idx)
|
|
|
|
|
|
assert brace_open != -1 and brace_close != -1, "couldn't locate cancel marker dict"
|
|
|
|
|
|
|
|
|
|
|
|
marker_dict = src[brace_open:brace_close + 1]
|
|
|
|
|
|
assert "_error" in marker_dict and "True" in marker_dict, (
|
|
|
|
|
|
"cancel marker is missing _error: True — it will leak into the agent's "
|
|
|
|
|
|
"conversation_history via _sanitize_messages_for_api() on the next turn. "
|
|
|
|
|
|
"See line 591-593 of api/streaming.py for the error-marker filter."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sanitize_strips_error_flagged_assistant_messages():
|
|
|
|
|
|
"""_sanitize_messages_for_api() must drop messages with _error: True —
|
|
|
|
|
|
this is the invariant the cancel marker's _error flag relies on."""
|
|
|
|
|
|
from api.streaming import _sanitize_messages_for_api
|
|
|
|
|
|
messages = [
|
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
|
{"role": "assistant", "content": "hi"},
|
|
|
|
|
|
{"role": "assistant", "content": "*Task cancelled.*", "_error": True},
|
|
|
|
|
|
{"role": "user", "content": "next"},
|
|
|
|
|
|
]
|
|
|
|
|
|
sanitized = _sanitize_messages_for_api(messages)
|
|
|
|
|
|
assert len(sanitized) == 3, (
|
|
|
|
|
|
f"expected 3 messages (cancel marker stripped), got {len(sanitized)}: {sanitized}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert all("Task cancelled" not in (m.get("content") or "") for m in sanitized), (
|
|
|
|
|
|
"_sanitize_messages_for_api must filter cancel markers from API history"
|
|
|
|
|
|
)
|