mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Preserve live agent timeline across session switches
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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);}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user