Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -992,14 +1007,15 @@ async function loadSession(sid){
else restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
}
}
if(restoredLiveTurn&&didReconnect){
replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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');
Expand Down
2 changes: 1 addition & 1 deletion static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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):'';
Expand Down
53 changes: 52 additions & 1 deletion tests/test_inflight_stream_reuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions tests/test_ui_tool_call_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading