Preserve live agent timeline across session switches

This commit is contained in:
Frank Song
2026-05-16 10:50:16 +08:00
parent e3035b3e40
commit faedcab739
9 changed files with 288 additions and 66 deletions
+2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+34 -9
View File
@@ -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();
+57 -9
View File
@@ -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;
+119 -32
View File
@@ -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 `<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
const openClass=open?' open':'';
return `<div class="thinking-card${openClass}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
}
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 `
<div class="tool-card-row compression-card-row" data-compression-card="1">
${_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 ? '<span class="tool-card-running-dot"></span>' : li('check',13),
open: running,
variantClass: running
? 'tool-card-compress-running tool-card-compress-auto'
: 'tool-card-compress-complete tool-card-compress-auto',
})}
</div>`;
}
@@ -4757,6 +4823,26 @@ function _compressionCardsNode(state){
wrap.innerHTML=`<div class="compression-turn-blocks">${_compressionCardsHtml(state)}</div>`;
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);}
+15
View File
@@ -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():
+35 -4
View File
@@ -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):
+26 -12
View File
@@ -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."
)