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 `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; + const openClass=open?' open':''; + return `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; } 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." )