Fix completion notification preview to use settled final answer

The SSE done handler previously used assistantText.slice(0, 100), which
captures the start of the live-stream accumulator (early tokens and interim
updates) rather than the final answer shown after tools settle.

Add _completionNotificationPreviewText() using anchor final-answer projection
and settled message content, with a cleaned live-stream fallback. Hoist
lastAsst so background completions on non-active panes still get preview text.

Update PWA notification regression tests.

Closes #5035
This commit is contained in:
b3nw authored and nesquena-hermes committed 2026-06-27 04:50:08 +00:00
1 parent 173eb67715
commit a84fc8e5c2
2 files changed
+54 -3

No files matched your search

+40 -2
View File
@@ -4652,6 +4652,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(isActiveSession){
S.activeStreamId=null;
}
let lastAsst=null;
if(isActiveSession){
// Capture previous session totals BEFORE overwriting S.session with the new
// cumulative values from the done event. prevIn/prevOut are the totals as of
@@ -4681,7 +4682,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
}
// Find the last assistant message once for both reasoning persistence and timestamp
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
// Persist reasoning trace for Worklog Thinking Cards; normal transcript
// rendering keeps provider reasoning out of the final answer.
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
@@ -4805,6 +4806,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
if(!lastAsst&&d.session&&Array.isArray(d.session.messages)){
lastAsst=[...d.session.messages].reverse().find(m=>m&&m.role==='assistant')||null;
}
if(isActiveSession&&_pendingGoalContinuation&&typeof queueSessionMessage==='function'){
const _goalNext=_pendingGoalContinuation;
_pendingGoalContinuation=null;
@@ -4827,7 +4831,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// If the user watched the whole stream, _wasEverHidden stays false and
// the notification is suppressed (matches Slack/Discord/Gmail/Claude).
const _wasEverBackgrounded=_shouldForceCompletionNotification(activeSid, streamId);
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid});
const _completionPreview=_completionNotificationPreviewText(lastAsst,{
sessionId:completedSid,
liveDisplayText:typeof _streamDisplay==='function'?_streamDisplay():assistantText,
});
sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid});
};
if(_shouldUseStreamFade()&&assistantBody){
_cancelAnimationFramePendingStreamRender();
@@ -6724,6 +6732,36 @@ function stopClarifyPolling() {
// ── Notifications and Sound ──────────────────────────────────────────────────
function _completionNotificationPreviewText(lastAssistantMessage, options){
const opts=(options&&typeof options==='object')?options:{};
const sessionId=String(opts.sessionId||'').trim();
let text='';
if(lastAssistantMessage&&typeof lastAssistantMessage==='object'){
if(typeof _assistantTurnAnchorSettledFinalAnswer==='function'){
const anchorFinal=_assistantTurnAnchorSettledFinalAnswer(
lastAssistantMessage,
lastAssistantMessage.content,
{session_id:sessionId||undefined}
);
if(anchorFinal!==null&&anchorFinal!==undefined) text=String(anchorFinal||'').trim();
}
if(!text&&typeof msgContent==='function') text=msgContent(lastAssistantMessage);
if(!text){
let raw=lastAssistantMessage.content||'';
if(Array.isArray(raw)) raw=raw.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim();
text=String(raw||'').trim();
}
if(text&&typeof _extractInlineThinkingFromContent==='function'){
const split=_extractInlineThinkingFromContent(text, lastAssistantMessage.reasoning, {streaming:false});
if(split&&typeof split.content==='string') text=split.content.trim();
}
}
if(!text&&typeof opts.liveDisplayText==='string') text=opts.liveDisplayText.trim();
if(!text) return '';
const normalized=text.replace(/\s+/g,' ').trim();
return normalized.length>100?`${normalized.slice(0,100)}…`:normalized;
}
function playNotificationSound(){
if(!window._soundEnabled) return;
try{
+14 -1
View File
@@ -37,11 +37,24 @@ def test_notification_payload_uses_completion_session_when_provided():
assert "_sessionUrlForSid(sid)" in MESSAGES_JS
assert "data:{url}" in MESSAGES_JS
assert "tag:sid?`hermes-${sid}`" in MESSAGES_JS
assert "sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid})" in MESSAGES_JS
assert "function _completionNotificationPreviewText" in MESSAGES_JS
assert "_completionNotificationPreviewText(lastAsst," in MESSAGES_JS
assert "sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid})" in MESSAGES_JS
assert "assistantText?assistantText.slice(0,100)" not in MESSAGES_JS
assert "sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid})" in MESSAGES_JS
def test_completion_notification_preview_uses_settled_message_not_live_prefix():
"""Background completion preview must not slice the live-stream accumulator."""
assert "function _completionNotificationPreviewText" in MESSAGES_JS
assert "_assistantTurnAnchorSettledFinalAnswer" in MESSAGES_JS
done_block = _source_between("source.addEventListener('done'", "source.addEventListener('stream_end'")
assert "let lastAsst=null;" in done_block
assert "d.session.messages" in done_block
assert "liveDisplayText:typeof _streamDisplay==='function'?_streamDisplay():assistantText" in done_block
def test_completion_notification_fires_when_tab_was_hidden_during_stream():
"""#4416: a throttled background-tab SSE delivers `done` late (after the user
returns, document.hidden=false), which silently dropped the completion