Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.254 — Release HV (stage-r2) Phase-2 medium wave 1 — 4 PRs (UI/mobile/cancel fixes + an un-held model dedup). ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3528 | @franksong2702 | Render partial tool calls after cancel — interrupted turns keep their `_partial_tool_calls` rows in the transcript + fallback tool-cards. (Codex confirmed it stays render-only, not forwarded to the provider API.) | | #3550 | @lurebat | Android offline recovery soft-reattaches the live stream instead of hard-reloading the page on a transient background/disconnect. | | #3479 | @mvanhorn | iOS Safari no longer snaps the conversation to the top when a handoff/compression card is inserted mid-stream or on `refreshSession()`. | | #3478 | @JayC-L | **Un-held:** named custom providers (`@custom:name:model`) dedup against bare model IDs without regressing Ollama multi-colon tags (`qwen2.5:7b-instruct-q4`). Only `@custom:` IDs strip the two-segment prefix. | ### Hold-sweep note #3478/#3489 was held yesterday for an Ollama multi-colon-tag regression risk (a blanket `lastIndexOf` would lose the model). The author pushed a scoped fix (only `@custom:` IDs use `lastIndexOf`); I verified `_normId` in node against the regression cases — Ollama bare tags are preserved. Un-held + shipped. ### Gate - Full pytest suite: **7588 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — #3552 partial-tool-calls verified render-only (no `_API_SAFE_MSG_KEYS` leak / no 400-on-strict-provider, the v0.50.251 #1375 trap); #3551 no EventSource double-subscribe; #3541 no regression vs the #3525 scroll-follow shipped in v0.51.253; #3489 no over-dedup. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lurebat <lurebat@users.noreply.github.com> Co-authored-by: mvanhorn <mvanhorn@users.noreply.github.com> Co-authored-by: JayC-L <JayC-L@users.noreply.github.com>
103 lines
4.5 KiB
Python
103 lines
4.5 KiB
Python
"""Offline recovery must reattach softly, not hard-reload the whole page.
|
|
|
|
Symptom (Android PWA): backgrounding the app for even a second often showed a
|
|
"Connection lost" banner, and on returning the whole page did a multi-second
|
|
cold reload. Confirmed from the live server journal: the client's offline
|
|
recovery probe (`GET /health?offline_probe=...`) fired repeatedly from the
|
|
phone, and each successful probe ran `window.location.reload()`.
|
|
|
|
Root cause: `checkOfflineRecoveryNow()` in static/ui.js called
|
|
`window.location.reload()` on the first healthy probe. The offline banner is
|
|
raised on any fetch/SSE error — which mobile backgrounding triggers constantly
|
|
— so a transient background turned into a full app cold boot (re-run boot,
|
|
re-pull /api/sessions + /api/session). Intermittent because it only fired when
|
|
a request actually errored that cycle.
|
|
|
|
Fix: recover softly via `_recoverFromOfflineSoftly()` — hide the banner,
|
|
restart the gateway SSE, and re-fetch the active session through the existing
|
|
`refreshSession()` reattach path. The server keeps the agent running and
|
|
buffers stream events while no subscriber is attached (#2307), so a hard reload
|
|
is never required. A full `window.location.reload()` remains only as the catch
|
|
fallback if the soft reattach throws.
|
|
|
|
State layer: this only changes the *client* recovery transition (banner →
|
|
reattach). It does not touch server stream buffering, session persistence, or
|
|
the compression/replay paths.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).parent.parent
|
|
|
|
|
|
def _ui_js() -> str:
|
|
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _fn_body(src: str, marker: str) -> str:
|
|
idx = src.find(marker)
|
|
assert idx != -1, f"{marker!r} not found in ui.js"
|
|
brace = src.find("{", idx)
|
|
depth = 1
|
|
i = brace + 1
|
|
while i < len(src) and depth:
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
assert depth == 0, f"{marker!r} body did not close"
|
|
return src[brace + 1 : i - 1]
|
|
|
|
|
|
class TestOfflineSoftRecovery:
|
|
def test_recovery_does_not_hard_reload_on_success(self):
|
|
"""The success branch must call the soft recover, not location.reload()."""
|
|
src = _ui_js()
|
|
body = _fn_body(src, "async function checkOfflineRecoveryNow(")
|
|
assert "_recoverFromOfflineSoftly(" in body, (
|
|
"checkOfflineRecoveryNow must recover via _recoverFromOfflineSoftly() "
|
|
"on a healthy probe instead of hard-reloading the page"
|
|
)
|
|
assert "window.location.reload()" not in body, (
|
|
"checkOfflineRecoveryNow must not call window.location.reload() on "
|
|
"recovery — that is the multi-second cold-boot regression"
|
|
)
|
|
|
|
def test_soft_recover_helper_exists(self):
|
|
"""The soft recovery helper must exist."""
|
|
assert "async function _recoverFromOfflineSoftly(" in _ui_js(), (
|
|
"_recoverFromOfflineSoftly() helper must exist"
|
|
)
|
|
|
|
def test_soft_recover_hides_banner_and_reattaches(self):
|
|
"""Soft recovery must hide the banner, restart SSE, and refresh the session."""
|
|
body = _fn_body(_ui_js(), "async function _recoverFromOfflineSoftly(")
|
|
assert "_hideOfflineBanner()" in body, (
|
|
"_recoverFromOfflineSoftly must hide the offline banner"
|
|
)
|
|
assert "startGatewaySSE" in body, (
|
|
"_recoverFromOfflineSoftly must restart the gateway SSE "
|
|
"(background/bfcache kills the connection)"
|
|
)
|
|
assert "refreshSession" in body, (
|
|
"_recoverFromOfflineSoftly must reattach the active session via "
|
|
"refreshSession() so messages that arrived while away appear"
|
|
)
|
|
|
|
def test_soft_recover_falls_back_to_hard_reload(self):
|
|
"""A failed soft reattach must fall back to a full reload (never stuck)."""
|
|
body = _fn_body(_ui_js(), "async function _recoverFromOfflineSoftly(")
|
|
assert "window.location.reload()" in body, (
|
|
"_recoverFromOfflineSoftly must keep window.location.reload() as the "
|
|
"catch fallback so a failed soft reattach never leaves the user stuck"
|
|
)
|
|
|
|
def test_refresh_session_guarded_with_typeof(self):
|
|
"""Reattach calls must be typeof-guarded for safe degradation."""
|
|
body = _fn_body(_ui_js(), "async function _recoverFromOfflineSoftly(")
|
|
assert "typeof refreshSession==='function'" in body or \
|
|
"typeof refreshSession === 'function'" in body, (
|
|
"refreshSession() call must be typeof-guarded"
|
|
)
|