diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e59c78228..a88eb02eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Fixed +- **Switching back to a reconnecting live session no longer leaves restored tool cards missing.** When a live turn was restored from the in-memory DOM snapshot and the SSE stream reattached, the restore-success path could skip replaying persisted live tool calls, so tool cards could disappear until a later stream event or final render rebuilt the turn. Reconnect now replays the persisted live tool cards after successful restore as well as on the existing fallback path. (#3707) + ## [v0.51.307] — 2026-06-06 — Release JW (stage-a3 — onboarding forwarded-IP spoof fix + update-check CSRF hardening) ### Security diff --git a/static/sessions.js b/static/sessions.js index da335bb340..be93145abe 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -961,9 +961,24 @@ async function loadSession(sid){ // replaying persisted live tools so the compact Activity count survives // switching away from and back to an active chat (#1715). S.activeStreamId=activeStreamId; + const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim(); + const replayPersistedLiveToolCards=(opts)=>{ + const liveToolCalls=Array.isArray(S.toolCalls) + ? S.toolCalls + : (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]); + const skipUnkeyedRestoredDuplicates=!!(opts&&opts.skipUnkeyedRestoredDuplicates); + const restoredLiveTurn=skipUnkeyedRestoredDuplicates?document.getElementById('liveAssistantTurn'):null; + const hasRestoredLiveToolRows=!!(restoredLiveTurn&&restoredLiveTurn.querySelector('.tool-card-row')); + for(const tc of (liveToolCalls||[])){ + if(skipUnkeyedRestoredDuplicates&&hasRestoredLiveToolRows&&!liveToolReplayId(tc)) continue; + if(tc&&tc.name) appendLiveToolCard(tc,{sessionId:sid,streamId:activeStreamId}); + } + }; + let didReconnect=false; if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){ INFLIGHT[sid].reattach=false; if (_loadingSessionId !== sid) return; + didReconnect=true; attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true}); } syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined); @@ -992,14 +1007,15 @@ async function loadSession(sid){ else restoredLiveTurn=restoreLiveTurnHtmlForSession(sid); } } + if(restoredLiveTurn&&didReconnect){ + replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true}); + } if(!restoredLiveTurn){ clearLiveToolCards(); if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost(); if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell(); else appendThinking(); - for(const tc of (S.toolCalls||[])){ - if(tc&&tc.name) appendLiveToolCard(tc); - } + replayPersistedLiveToolCards(); } if(typeof ensureLiveWorklogShell==='function'){ const liveTurn=document.getElementById('liveAssistantTurn'); diff --git a/static/ui.js b/static/ui.js index 2fba5d153a..18bb2be034 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9219,7 +9219,7 @@ function appendLiveToolCard(tc){ } const inner=_assistantTurnBlocks(turn); if(!inner) return; - const tid=tc.tid||''; + const tid=tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||''; const children=Array.from(inner.children); const burstId=tc.activityBurstId!==undefined&&tc.activityBurstId!==null&&String(tc.activityBurstId)!=='0'?String(tc.activityBurstId):''; const segmentSeq=tc.activitySegmentSeq!==undefined&&tc.activitySegmentSeq!==null&&String(tc.activitySegmentSeq)!=='0'?String(tc.activitySegmentSeq):''; diff --git a/tests/test_inflight_stream_reuse.py b/tests/test_inflight_stream_reuse.py index fe9a72e33e..1a2888f3af 100644 --- a/tests/test_inflight_stream_reuse.py +++ b/tests/test_inflight_stream_reuse.py @@ -869,7 +869,7 @@ def test_load_session_restores_worklog_shell_before_reattach_replay(): clear_pos = fallback_block.find("clearLiveToolCards();") shell_pos = fallback_block.find("ensureLiveWorklogShell()") legacy_pos = fallback_block.find("else appendThinking();") - replay_pos = fallback_block.find("appendLiveToolCard(tc);") + replay_pos = fallback_block.find("replayPersistedLiveToolCards();") invariant_pos = fallback_block.find("!liveTurn||!liveTurn.querySelector") assert clear_pos != -1, "fallback must clear stale live tool DOM first" assert shell_pos != -1, "fallback must restore a quiet live Worklog shell" @@ -880,6 +880,57 @@ def test_load_session_restores_worklog_shell_before_reattach_replay(): assert replay_pos < invariant_pos +def test_restore_succeeded_reconnect_replays_tool_cards(): + """When reconnect replay succeeds in restoring the live turn HTML, tool cards + are still repainted from the persisted live-call list instead of waiting for a + future SSE event to reintroduce them.""" + body = _function_body(SESSIONS_JS, "loadSession") + replay_fn = body.find("const replayPersistedLiveToolCards=(opts)=>{") + reattach_pos = body.find("if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function')") + restore_pos = body.find("if(typeof restoreLiveTurnHtmlForSession==='function'){", reattach_pos if reattach_pos != -1 else 0) + fallback_pos = body.find("if(!restoredLiveTurn){", restore_pos) + restore_replay_pos = body.find("if(restoredLiveTurn&&didReconnect){", restore_pos) + restore_replay_block = body[restore_replay_pos:fallback_pos] + helper_replay_call = restore_replay_block.find("replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});") + assert reattach_pos != -1, "loadSession must keep the reconnect reattach branch" + assert replay_fn != -1, "loadSession should extract live tool replay into a helper" + assert restore_pos != -1, "loadSession must still execute restoreLiveTurnHtmlForSession" + assert reattach_pos > replay_fn, "live-tool replay helper must be defined before reattach branch" + assert restore_pos > reattach_pos, "restore/fallback branch should be after reattach handling in INFLIGHT flow" + assert restore_replay_pos != -1, "restored live turns must explicitly replay tools on reconnect" + assert helper_replay_call != -1, "replay helper must be executed so reconnect can repopulate tool cards" + assert replay_fn < restore_replay_pos < fallback_pos, "restore+reconnect replay should run before fallback" + assert restore_replay_block.strip().startswith("if(restoredLiveTurn&&didReconnect){") + assert ( + "if(restoredLiveTurn&&didReconnect){" + "replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});" + "}" + ) in re.sub(r"\s+", "", restore_replay_block) + + +def test_restore_succeeded_reconnect_skips_unkeyed_restored_tool_duplicates(): + """Restored snapshots can already contain legacy tool rows without live tids. + + Replaying an unkeyed persisted tool over that restored DOM would append a + duplicate, so the restore-success reconnect path should only replay unkeyed + tools when the restored turn has no visible tool rows to preserve. + """ + body = _function_body(SESSIONS_JS, "loadSession") + replay_fn = body.find("const replayPersistedLiveToolCards=(opts)=>{") + restore_replay_pos = body.find("if(restoredLiveTurn&&didReconnect){") + fallback_pos = body.find("if(!restoredLiveTurn){", restore_replay_pos) + assert replay_fn != -1, "loadSession should keep replay options on the helper" + assert "const liveToolReplayId=(tc)=>" in body + assert "tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id" in body + helper_block = body[replay_fn:restore_replay_pos] + assert "skipUnkeyedRestoredDuplicates" in helper_block + assert "restoredLiveTurn.querySelector('.tool-card-row')" in helper_block + assert "hasRestoredLiveToolRows&&!liveToolReplayId(tc)" in helper_block + restore_block = body[restore_replay_pos:fallback_pos] + assert "replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});" in restore_block + assert "replayPersistedLiveToolCards();" in body[fallback_pos:body.find("loadDir('.')", fallback_pos)] + + def test_merge_inflight_tail_preserves_all_segmented_live_progress(): """The reattach merge must keep every projected live progress segment. diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 890413ba5f..7123eafa29 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -760,12 +760,12 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" inflight_block = src[inflight_idx:inflight_idx+4200] active_pos = inflight_block.find("S.activeStreamId=activeStreamId;") - replay_pos = inflight_block.find("appendLiveToolCard(tc);") + replay_pos = inflight_block.find("const replayPersistedLiveToolCards=(opts)=>{") attach_pos = inflight_block.find("attachLiveStream(sid, activeStreamId") assert active_pos >= 0, "loadSession INFLIGHT branch must restore S.activeStreamId" assert replay_pos >= 0, "loadSession INFLIGHT branch must replay persisted live tool cards" assert active_pos < replay_pos, \ - "S.activeStreamId must be restored before appendLiveToolCard() replays persisted tools" + "S.activeStreamId must be restored before replaying persisted tools" assert attach_pos < 0 or active_pos < attach_pos, \ "S.activeStreamId should also be restored before SSE reattach can deliver more tool events" diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 20806879e6..d4a44965ae 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -260,6 +260,9 @@ def test_live_tool_cards_use_grouping_only_when_simplified(self): assert "data-live-tid" in live_fn, ( "Live grouping must preserve data-live-tid so tool_start/tool_complete updates still replace the correct card." ) + assert "tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id" in live_fn, ( + "Live replay should replace restored cards for all known tool id aliases, not only tc.tid." + ) def test_activity_disclosure_state_is_session_and_turn_scoped(self): helper = _function_body(UI_JS, "ensureActivityGroup")