diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6fa25bab..79b39b1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,8 @@
### Added
+- **PR TBD** by @franksong2702 — Long tool-heavy streaming turns now preserve the live Thinking / assistant progress / Tool / Command timeline when the user switches away and back. The active stream keeps accumulating token and interim-assistant state while inactive, reloads the persisted transcript before merging the live tail, restores the live turn DOM snapshot instead of replaying tools into a flat list, and anchors automatic compression cards inside the active turn to avoid duplicate cards while an answer is still streaming.
+
- **PR #2332** by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model/token/cost/duration frontmatter from cron output files and returns it from `/api/crons/history` and `/api/crons/run`; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata.
### Fixed
diff --git a/docs/pr-media/live-timeline-session-restore/after-live-timeline-preserved.png b/docs/pr-media/live-timeline-session-restore/after-live-timeline-preserved.png
new file mode 100644
index 00000000..6a3ace14
Binary files /dev/null and b/docs/pr-media/live-timeline-session-restore/after-live-timeline-preserved.png differ
diff --git a/docs/pr-media/live-timeline-session-restore/before-session-switch-rebuild.png b/docs/pr-media/live-timeline-session-restore/before-session-switch-rebuild.png
new file mode 100644
index 00000000..79b27d3e
Binary files /dev/null and b/docs/pr-media/live-timeline-session-restore/before-session-switch-rebuild.png differ
diff --git a/static/messages.js b/static/messages.js
index d21d1144..5bfdddaa 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -513,6 +513,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
toolCalls:inflight.toolCalls||[],
});
}
+ function snapshotLiveTurn(){
+ if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid);
+ }
// Throttled variant for token-by-token updates. persistInflightState()
// calls saveInflightState() which does JSON.parse + JSON.stringify + write
// on the entire inflight map every call. On a fast model at 60 tok/s with
@@ -1170,6 +1173,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
}
scrollIfPinned();
+ snapshotLiveTurn();
};
const frameIntervalMs=_shouldUseStreamFade()?33:66;
if(sinceLastMs>=frameIntervalMs){
@@ -1197,19 +1201,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('token',e=>{
if(_terminalStateReached||_streamFinalized) return;
- if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
const parsed=_parseStreamState();
+ if(_freshSegment&&window._showThinking!==false) appendThinking(_liveThinkingText());
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
_scheduleRender();
});
source.addEventListener('interim_assistant',e=>{
if(_terminalStateReached||_streamFinalized) return;
- if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
const visible=String(d&&d.text?d.text:'').trim();
const alreadyStreamed=!!(d&&d.already_streamed);
@@ -1217,19 +1220,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return;
}
if(alreadyStreamed){
+ if(!S.session||S.session.session_id!==activeSid) return;
_resetAssistantSegment();
return;
}
- assistantText+=visible;
+ assistantText += assistantText ? `\n\n${visible}` : visible;
visibleInterimSnippets.push(visible);
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
- const parsed=_parseStreamState();
if(window._showThinking!==false){
if(typeof updateThinking==='function') updateThinking(_liveThinkingText());
else appendThinking(_liveThinkingText());
}
- if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
+ ensureAssistantRow(true);
_scheduleRender();
});
@@ -1274,6 +1277,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
liveReasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
appendLiveToolCard(tc);
+ snapshotLiveTurn();
// Reset the live assistant row reference so that any text tokens arriving
// after this tool call create a NEW segment appended below the tool card,
// rather than updating the old segment that sits above it in the DOM.
@@ -1310,6 +1314,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
persistInflightState();
if(!S.session||S.session.session_id!==activeSid) return;
appendLiveToolCard(tc);
+ snapshotLiveTurn();
scrollIfPinned();
});
@@ -1603,14 +1608,25 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
if(d.session_id&&d.session_id!==activeSid) return;
if(typeof setCompressionUi==='function'){
- setCompressionUi({
+ const state={
sessionId:activeSid,
phase:'running',
automatic:true,
message:d.message||'Auto-compressing context...',
- });
+ };
+ setCompressionUi(state);
+ const liveAnswerStarted=!!(assistantRow||String(((_parseStreamState&&_parseStreamState())||{}).displayText||'').trim());
+ if(liveAnswerStarted&&typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state)){
+ // The live card is now anchored in the turn. Keeping the same running
+ // state in global transient UI makes later renderMessages() calls insert
+ // a duplicate Automatic Compression card.
+ window._compressionUi=null;
+ snapshotLiveTurn();
+ return;
+ }
}
if(typeof renderMessages==='function') renderMessages({preserveScroll:true});
+ snapshotLiveTurn();
});
source.addEventListener('compressed',e=>{
@@ -1627,13 +1643,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_syncCtxIndicator(S.lastUsage);
}
if(typeof setCompressionUi==='function'){
- setCompressionUi({
+ const state={
sessionId:activeSid,
phase:'done',
automatic:true,
message,
summary:{headline:message},
- });
+ };
+ setCompressionUi(state);
+ const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);
+ if(appended){
+ // The live card is now anchored in the turn. Do not keep the automatic
+ // completion state as global transient UI, otherwise every subsequent
+ // render projects the same Auto Compression card again.
+ window._compressionUi=null;
+ snapshotLiveTurn();
+ }
}
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(!S.busy&&typeof renderMessages==='function') renderMessages();
diff --git a/static/sessions.js b/static/sessions.js
index 771e1d80..0019b6db 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -550,8 +550,10 @@ async function loadSession(sid){
return true;
}
- // Phase 2a: If session is streaming, restore from INFLIGHT cache before
- // loading full messages (INFLIGHT state is self-contained and sufficient).
+ // Phase 2a: If session is streaming, restore the persisted transcript first,
+ // then merge the local INFLIGHT live tail. INFLIGHT is a recovery tail, not a
+ // complete transcript; treating it as the full source makes long sessions look
+ // like they lost history after switching away and back.
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
const stored=loadInflightState(sid, activeStreamId);
if(stored){
@@ -565,8 +567,15 @@ async function loadSession(sid){
}
if(INFLIGHT[sid]){
- // Streaming session: use cached INFLIGHT messages (already has pending assistant output).
- S.messages=INFLIGHT[sid].messages;
+ const inflightMessages=INFLIGHT[sid].messages||[];
+ S.messages=[];
+ S.toolCalls=[];
+ try {
+ await _ensureMessagesLoaded(sid);
+ } catch(e) {
+ S.messages=inflightMessages;
+ }
+ S.messages=_mergeInflightTailMessages(S.messages,inflightMessages);
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
if(_mergePendingSessionMessage(S.session,S.messages)){
INFLIGHT[sid].messages=S.messages;
@@ -576,12 +585,17 @@ 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;
- syncTopbar();renderMessages();appendThinking();loadDir('.');
- clearLiveToolCards();
- if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
- for(const tc of (S.toolCalls||[])){
- if(tc&&tc.name) appendLiveToolCard(tc);
+ syncTopbar();renderMessages();
+ const restoredLiveTurn=typeof restoreLiveTurnHtmlForSession==='function'&&restoreLiveTurnHtmlForSession(sid);
+ if(!restoredLiveTurn){
+ appendThinking();
+ clearLiveToolCards();
+ if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
+ for(const tc of (S.toolCalls||[])){
+ if(tc&&tc.name) appendLiveToolCard(tc);
+ }
}
+ loadDir('.');
setBusy(true);setComposerStatus('');
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
@@ -1128,6 +1142,40 @@ async function _ensureMessagesLoaded(sid) {
}
}
+function _messageComparableText(m){
+ if(!m) return '';
+ if(typeof msgContent==='function'){
+ try{return String(msgContent(m)||'').trim();}
+ catch(_){}
+ }
+ return String(m.content||'').trim();
+}
+
+function _sameTranscriptMessage(a,b){
+ return !!(a&&b) &&
+ String(a.role||'')===String(b.role||'') &&
+ _messageComparableText(a)===_messageComparableText(b);
+}
+
+function _mergeInflightTailMessages(baseMessages, inflightMessages){
+ const base=Array.isArray(baseMessages)?baseMessages:[];
+ const inflight=Array.isArray(inflightMessages)?inflightMessages:[];
+ let liveIdx=-1;
+ for(let i=inflight.length-1;i>=0;i--){
+ if(inflight[i]&&inflight[i]._live){liveIdx=i;break;}
+ }
+ if(liveIdx<0) return base;
+ let start=liveIdx;
+ if(liveIdx>0&&inflight[liveIdx-1]&&inflight[liveIdx-1].role==='user') start=liveIdx-1;
+ const tail=inflight.slice(start).filter(m=>m&&m.role);
+ const merged=[...base];
+ for(const msg of tail){
+ const duplicate=merged.slice(-Math.max(5,tail.length+2)).some(existing=>_sameTranscriptMessage(existing,msg));
+ if(!duplicate) merged.push(msg);
+ }
+ return merged;
+}
+
// Load older messages when the user scrolls to the top of the conversation.
// Prepends them to S.messages and re-renders, preserving scroll position.
let _loadingOlder = false;
diff --git a/static/ui.js b/static/ui.js
index c050811c..a7594f02 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -3717,6 +3717,64 @@ function clearInflightState(sid){
}catch(_){ }
}
+function snapshotLiveTurnHtmlForSession(sid){
+ if(!sid||!INFLIGHT[sid]) return;
+ const turn=$('liveAssistantTurn');
+ if(!turn) return;
+ if(turn.dataset&&turn.dataset.sessionId&&turn.dataset.sessionId!==sid) return;
+ INFLIGHT[sid].liveTurnHtml=turn.outerHTML;
+}
+
+function _liveAssistantSegmentTextLength(seg){
+ if(!seg) return 0;
+ const body=seg.querySelector('.msg-body')||seg;
+ return String(body.textContent||'').trim().length;
+}
+
+function _mergeRestoredLiveAssistantSegment(restored, existing){
+ if(!restored||!existing) return;
+ const existingLive=existing.querySelector('[data-live-assistant="1"]');
+ if(!existingLive) return;
+ const restoredLive=restored.querySelector('[data-live-assistant="1"]');
+ const existingLen=_liveAssistantSegmentTextLength(existingLive);
+ const restoredLen=_liveAssistantSegmentTextLength(restoredLive);
+ if(existingLen<=restoredLen) return;
+ const replacement=existingLive.cloneNode(true);
+ if(restoredLive){
+ restoredLive.replaceWith(replacement);
+ return;
+ }
+ const blocks=_assistantTurnBlocks(restored);
+ if(!blocks) return;
+ const anchor=Array.from(blocks.children).filter(el=>
+ el.matches('.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row,[data-live-assistant="1"]')
+ ).pop();
+ if(anchor) anchor.insertAdjacentElement('afterend', replacement);
+ else blocks.appendChild(replacement);
+}
+
+function restoreLiveTurnHtmlForSession(sid){
+ const inflight=INFLIGHT[sid];
+ if(!sid||!inflight||!inflight.liveTurnHtml) return false;
+ const inner=$('msgInner');
+ if(!inner) return false;
+ const template=document.createElement('template');
+ template.innerHTML=String(inflight.liveTurnHtml||'').trim();
+ const restored=template.content.firstElementChild;
+ if(!restored) return false;
+ restored.id='liveAssistantTurn';
+ if(S.session) restored.dataset.sessionId=S.session.session_id;
+ const existing=$('liveAssistantTurn');
+ _mergeRestoredLiveAssistantSegment(restored, existing);
+ if(existing) existing.replaceWith(restored);
+ else inner.appendChild(restored);
+ const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
+ if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup);
+ if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
+ requestAnimationFrame(()=>postProcessRenderedMessages(restored));
+ return true;
+}
+
function markInflight(sid, streamId) {
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
}
@@ -4543,17 +4601,18 @@ function _createAssistantTurn(tsTitle='', tpsText=''){
function _assistantTurnBlocks(turn){
return turn?turn.querySelector('.assistant-turn-blocks'):null;
}
-function _thinkingCardHtml(text){
+function _thinkingCardHtml(text, open){
const clean=_sanitizeThinkingDisplayText(text);
- return `
`;
+ const openClass=open?' open':'';
+ return ``;
}
function isSimplifiedToolCalling(){
return window._simplifiedToolCalling!==false;
}
-function _thinkingActivityNode(text){
+function _thinkingActivityNode(text, open){
const row=document.createElement('div');
row.className='agent-activity-thinking';
- row.innerHTML=_thinkingCardHtml(text);
+ row.innerHTML=_thinkingCardHtml(text, open);
return row;
}
// ── Activity-group user expand intent (#1298) ──────────────────────────────
@@ -4737,17 +4796,24 @@ function _compressionCardsHtml(state){
}
function _autoCompressionCardsHtml(state){
const fallback='Context auto-compressed to continue the conversation';
- const detail=String(state.message||fallback).trim()||fallback;
- const preview=String(state.summary?.headline||detail).trim()||detail;
+ const running=state&&state.phase==='running';
+ const detail=running
+ ? (String(state.message||'Auto-compressing context...').trim()||'Auto-compressing context...')
+ : (String(state.message||fallback).trim()||fallback);
+ const preview=running
+ ? detail
+ : (String(state.summary?.headline||detail).trim()||detail);
return `
${_compressionStatusCardHtml({
statusLabel: t('auto_compress_label'),
previewText: preview,
detail,
- icon: li('check',13),
- open: false,
- variantClass: 'tool-card-compress-complete tool-card-compress-auto',
+ icon: running ? '' : li('check',13),
+ open: running,
+ variantClass: running
+ ? 'tool-card-compress-running tool-card-compress-auto'
+ : 'tool-card-compress-complete tool-card-compress-auto',
})}
`;
}
@@ -4757,6 +4823,26 @@ function _compressionCardsNode(state){
wrap.innerHTML=`${_compressionCardsHtml(state)}
`;
return wrap;
}
+function appendLiveCompressionCard(state){
+ if(!S.session||!S.activeStreamId||!state) return false;
+ let turn=$('liveAssistantTurn');
+ if(!turn){
+ turn=_createAssistantTurn();
+ turn.id='liveAssistantTurn';
+ if(S.session) turn.dataset.sessionId=S.session.session_id;
+ $('msgInner').appendChild(turn);
+ }
+ const inner=_assistantTurnBlocks(turn);
+ if(!inner) return false;
+ const node=_compressionCardsNode(state);
+ if(!node) return false;
+ node.setAttribute('data-live-compression-card','1');
+ const existing=inner.querySelector('[data-live-compression-card="1"]');
+ if(existing) existing.replaceWith(node);
+ else inner.appendChild(node);
+ if(typeof scrollIfPinned==='function') scrollIfPinned();
+ return true;
+}
function _isHandoffSummaryToolPayload(value){
if(!value||typeof value!=='object'||Array.isArray(value)) return false;
return value._handoff_summary_card === true;
@@ -5705,14 +5791,18 @@ function renderMessages(options){
}
if(!anchorRow) continue;
const anchorParent=anchorRow.parentElement;
- const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
+ let insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
+ const thinkingText=assistantThinking.get(aIdx);
+ if(thinkingText){
+ const thinkingNode=_thinkingActivityNode(thinkingText, false);
+ anchorParent.insertBefore(thinkingNode, anchorRow);
+ }
+ if(!cards.length) continue;
const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode,activityKey:`assistant:${aIdx}`});
const sourceMsg=S.messages[aIdx]||{};
if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration));
const body=group&&group.querySelector('.tool-call-group-body');
if(!body) continue;
- const thinkingText=assistantThinking.get(aIdx);
- if(thinkingText) body.appendChild(_thinkingActivityNode(thinkingText));
for(const tc of cards){
body.appendChild(buildToolCard(tc));
}
@@ -6857,31 +6947,28 @@ function appendThinking(text=''){
}
return;
}
- if(!String(text||'').trim()){
- scrollIfPinned();
- return;
- }
- const allChildren=Array.from(blocks.children);
- const anchor=allChildren.filter(el=>
- el.id!=='toolRunningRow' &&
- el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
- ).pop();
- const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
- const body=group&&group.querySelector('.tool-call-group-body');
- if(!body) return;
- let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
+ const thinkingText=String(text||'').trim()||'Thinking…';
+ blocks.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{
+ group.removeAttribute('data-live-activity-current');
+ });
+ let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
if(!row){
- row=document.createElement('div');
- row.className='agent-activity-thinking';
+ row=_thinkingActivityNode(thinkingText, false);
row.setAttribute('data-thinking-active','1');
- body.insertBefore(row, body.firstChild);
+ const allChildren=Array.from(blocks.children);
+ const anchor=allChildren.filter(el=>
+ el.id!=='toolRunningRow' &&
+ el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
+ ).pop();
+ if(anchor) anchor.insertAdjacentElement('afterend', row);
+ else blocks.appendChild(row);
+ }else{
+ _renderThinkingInto(row,thinkingText);
}
- _renderThinkingInto(row,text);
- _syncToolCallGroupSummary(group);
scrollIfPinned();
if(_scrollPinned){
- const thinkingBody=row&&row.querySelector('.thinking-card-body');
- if(thinkingBody) thinkingBody.scrollTop=thinkingBody.scrollHeight;
+ const body=row&&row.querySelector('.thinking-card-body');
+ if(body) body.scrollTop=body.scrollHeight;
}
}
function updateThinking(text=''){appendThinking(text);}
diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py
index 21d7d5d0..d092f615 100644
--- a/tests/test_auto_compression_card.py
+++ b/tests/test_auto_compression_card.py
@@ -67,6 +67,18 @@ def test_auto_compression_completion_transition_is_preserved_after_running_liste
assert "phase:'done'" in _compressed_listener_block()
+def test_auto_compression_does_not_rerender_over_live_answer_text():
+ block = _compressing_listener_block()
+ src = _read("static/ui.js")
+
+ assert "const liveAnswerStarted=" in block
+ assert "appendLiveCompressionCard(state)" in block
+ assert block.index("appendLiveCompressionCard(state)") < block.index("renderMessages({preserveScroll:true})")
+ assert "window._compressionUi=null;" in block
+ assert "function appendLiveCompressionCard(state)" in src
+ assert 'data-live-compression-card' in src
+
+
def test_auto_compression_sse_uses_transient_card_not_fake_message():
"""Auto compression must not inject display-only text into S.messages."""
src = _read("static/messages.js")
@@ -78,6 +90,9 @@ def test_auto_compression_sse_uses_transient_card_not_fake_message():
assert "phase:'done'" in block
assert "automatic:true" in block
assert "_setCompressionSessionLock" in block
+ assert "const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);" in block
+ assert "window._compressionUi=null;" in block
+ assert block.index("appendLiveCompressionCard(state)") < block.index("window._compressionUi=null;")
def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
diff --git a/tests/test_regressions.py b/tests/test_regressions.py
index 068afdcf..1de1ef34 100644
--- a/tests/test_regressions.py
+++ b/tests/test_regressions.py
@@ -7,6 +7,7 @@ Each test is tagged with the sprint/commit where the bug was found and fixed.
import json
import os
import pathlib
+import re
import time
import urllib.error
import urllib.request
@@ -582,10 +583,23 @@ def test_live_stream_tokens_persist_partial_assistant_for_session_switch(cleanup
"messages.js must mark the persisted in-flight assistant row so renderMessages can re-anchor it"
assert "syncInflightAssistantMessage();" in messages_src, \
"token handler must update INFLIGHT state before checking the active session"
+ token_match = re.search(r"source\.addEventListener\('token',e=>\{(.*?)\n\s*\}\);", messages_src, re.S)
+ assert token_match, "token listener not found"
+ token_fn = token_match.group(1)
+ assert token_fn.find("assistantText+=d.text") < token_fn.find("if(!S.session||S.session.session_id!==activeSid) return;"), (
+ "token events must update the active stream's local state before DOM-only active-session guards"
+ )
+ assert token_fn.find("syncInflightAssistantMessage();") < token_fn.find("if(!S.session||S.session.session_id!==activeSid) return;"), (
+ "token events must persist INFLIGHT state even while another session is selected"
+ )
assert "assistantRow&&!assistantRow.isConnected" in messages_src, \
"live stream must drop stale detached assistant DOM references after session switches"
assert "data-live-assistant" in ui_src, \
"renderMessages must preserve a live-assistant DOM anchor when rebuilding the thread"
+ assert "snapshotLiveTurnHtmlForSession(activeSid)" in messages_src, \
+ "live turn DOM snapshots should preserve the interleaved timeline across session switches"
+ assert "restoreLiveTurnHtmlForSession(sid)" in (REPO_ROOT / "static/sessions.js").read_text(), \
+ "loadSession should restore the live turn snapshot before replaying flat tool cards"
def test_inflight_session_state_tracks_live_tool_cards_per_session(cleanup_test_sessions):
@@ -612,13 +626,30 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
inflight_block = src[inflight_idx:inflight_idx+700]
busy_pos = inflight_block.find("S.busy=true;")
- render_pos = inflight_block.find("renderMessages();appendThinking();")
+ render_pos = inflight_block.find("renderMessages();")
assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true"
assert render_pos >= 0, "loadSession INFLIGHT branch must call renderMessages()"
assert busy_pos < render_pos, \
"loadSession must set S.busy=true before renderMessages() to avoid duplicate tool cards"
+def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test_sessions):
+ src = (REPO_ROOT / "static/sessions.js").read_text()
+ inflight_idx = src.find("if(INFLIGHT[sid]){")
+ assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
+ inflight_block = src[inflight_idx:inflight_idx+1200]
+
+ assert "await _ensureMessagesLoaded(sid);" in inflight_block, (
+ "returning to an active stream should load the persisted transcript before adding the live tail"
+ )
+ assert "_mergeInflightTailMessages(S.messages,inflightMessages)" in inflight_block, (
+ "INFLIGHT messages should be merged as a tail, not replace the full transcript"
+ )
+ assert "function _mergeInflightTailMessages" in src, (
+ "sessions.js should centralize INFLIGHT tail merge logic for regression coverage"
+ )
+
+
def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_cards(cleanup_test_sessions):
"""#1715: returning to an active chat must replay persisted tool cards.
@@ -630,7 +661,7 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card
src = (REPO_ROOT / "static/sessions.js").read_text()
inflight_idx = src.find("if(INFLIGHT[sid]){")
assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession"
- inflight_block = src[inflight_idx:inflight_idx+1000]
+ inflight_block = src[inflight_idx:inflight_idx+1600]
active_pos = inflight_block.find("S.activeStreamId=activeStreamId;")
replay_pos = inflight_block.find("appendLiveToolCard(tc);")
attach_pos = inflight_block.find("attachLiveStream(sid, activeStreamId")
@@ -769,8 +800,8 @@ def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_
compact = src.replace(' ', '').replace('\n', '')
assert "assistantThinking.set(rawIdx,thinkingText)" in compact, \
"renderMessages must preserve reasoning text before hiding empty anchor segments"
- assert "_thinkingActivityNode(thinkingText)" in src, \
- "thinking-only assistant content should render inside the shared activity dropdown"
+ assert "_thinkingActivityNode(thinkingText, false)" in src, \
+ "thinking-only assistant content should render as a collapsed timeline Thinking card"
def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_test_sessions):
diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py
index 29fe6457..44204d00 100644
--- a/tests/test_ui_tool_call_cleanup.py
+++ b/tests/test_ui_tool_call_cleanup.py
@@ -260,35 +260,49 @@ class TestToolCallGroupingStatic:
"Thinking echo suppression should remove exact visible assistant snippets from reasoning display."
)
- def test_tools_and_thinking_share_one_collapsed_activity_dropdown(self):
+ def test_compact_activity_keeps_thinking_cards_after_session_switch(self):
ui_min = re.sub(r"\s+", "", UI_JS)
assert "functionensureActivityGroup(" in ui_min, (
- "Tool calls and thinking should share one agent-activity disclosure helper."
+ "Tool calls should still use the shared Activity disclosure helper."
)
assert "data-agent-activity-group" in UI_JS, (
- "The shared tools/thinking disclosure needs a stable data-agent-activity-group hook."
- )
- assert "agent-activity-thinking" in UI_JS, (
- "Thinking content should be nested inside the shared activity dropdown, not rendered separately."
+ "The Activity disclosure needs a stable data-agent-activity-group hook."
)
render_fn = _function_body(UI_JS, "renderMessages")
assert "isSimplifiedToolCalling()" in render_fn and "assistantThinking.set(rawIdx, thinkingText)" in render_fn, (
- "Settled thinking should move into the shared activity dropdown only when Compact tool activity is enabled."
+ "Compact settled transcript rendering should preserve Thinking cards after switching sessions."
+ )
+ assert "_thinkingActivityNode(thinkingText, false)" in render_fn, (
+ "Settled Thinking cards should render as collapsed timeline entries before related tools."
+ )
+ assert "anchorParent.insertBefore(thinkingNode, anchorRow)" in render_fn, (
+ "Settled Thinking cards should appear before their visible assistant process text."
)
assert "seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText))" in render_fn, (
"The non-simplified path should preserve standalone settled thinking cards."
)
- def test_live_thinking_uses_shared_activity_dropdown_only_when_simplified(self):
+ def test_live_thinking_is_shown_while_still_splitting_tool_bursts(self):
live_thinking_fn = _function_body(UI_JS, "appendThinking")
+ live_tool_fn = _function_body(UI_JS, "appendLiveToolCard")
+ helper = _function_body(UI_JS, "ensureActivityGroup")
assert "isSimplifiedToolCalling()" in live_thinking_fn, (
"Live thinking should branch on the Compact tool activity toggle."
)
- assert "ensureActivityGroup" in live_thinking_fn, (
- "Compact live thinking should be inserted into the shared activity dropdown."
+ assert 'data-live-activity-current' in live_thinking_fn, (
+ "Starting a new live thinking block should close the previous live tool burst."
)
- assert "thinkingRow" in live_thinking_fn, (
- "The non-simplified live thinking path should preserve the upstream #thinkingRow card."
+ assert "body.insertBefore(row, body.firstChild)" not in live_thinking_fn, (
+ "Live thinking should not be moved into the top Activity dropdown."
+ )
+ assert "_thinkingActivityNode(thinkingText, false)" in live_thinking_fn, (
+ "Compact live thinking should render a collapsed Thinking card in the timeline."
+ )
+ assert '[data-live-activity-current="1"]' in live_thinking_fn, (
+ "Starting a new Thinking card should mark the previous live tool burst as no longer current."
+ )
+ assert "body.querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, (
+ "tool_complete must still update its current live Activity burst by tool id."
)