Add assistant turn anchor live shadow feed

This commit is contained in:
Frank Song committed 2026-06-13 16:26:52 +08:00
1 parent ae90cf620b
commit aa96bc52bc
7 files changed
+254 -27

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased] ## [Unreleased]
### Added
- **Stable Assistant Turn Anchors live shadow feed (#3926).** `attachLiveStream()` now creates a per-stream anchor registry and shadow-feeds non-token live activity boundaries into the existing `HermesAssistantTurnAnchors` owner, including aggregate reasoning, tools, control events, compression lifecycle, app errors, cancel, and a slim `done` payload. The feed is still renderer-neutral: Compact Worklog, Transparent Stream, `renderMessages()`, `S.messages`, `INFLIGHT`, and DOM continuity do not consume the live registry yet; token events and EventSource network `error` remain outside the shadow feed. Settled active assistant messages are stamped with `_anchor_stream_id` for the next reconciliation slice, and that ephemeral stamp is carried forward across session refreshes.
## [v0.51.386] — 2026-06-13 — Release MY (voice mode survives a dropped speechSynthesis onend, #3983) ## [v0.51.386] — 2026-06-13 — Release MY (voice mode survives a dropped speechSynthesis onend, #3983)
### Fixed ### Fixed
@@ -26,10 +26,14 @@ streaming or rendering yet.
- Slice 5 starts RFC Phase 5 by projecting anchor-owned activity events into a - Slice 5 starts RFC Phase 5 by projecting anchor-owned activity events into a
renderer-neutral activity scene that Compact Worklog and Transparent Stream renderer-neutral activity scene that Compact Worklog and Transparent Stream
can later consume from the same ordered rows. can later consume from the same ordered rows.
- The next independently reviewable boundary is wiring one current renderer to - Slice 6 starts the live shadow-feed boundary: `attachLiveStream()` now creates
the activity scene. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes a per-stream anchor registry and feeds non-token live activity events into it
remain projection/cache layers outside the settled final-prose path and the without changing either current renderer.
inert activity-scene projection. - The next independently reviewable boundary is a dual-run reconciler that
compares current Compact Worklog / Transparent Stream output with the
anchor-owned activity scene before visible renderer replacement. `S.messages`,
`INFLIGHT`, stream-local state, and DOM nodes remain projection/cache layers
outside the settled final-prose path and the live shadow registry.
## State Layers ## State Layers
@@ -153,7 +157,44 @@ IDs, and sanitized payloads with a chronological display hint. This pins the
shared input shape before either renderer is rewired. shared input shape before either renderer is rewired.
This slice is still inert. No current UI module consumes the activity scene. This slice is still inert. No current UI module consumes the activity scene.
`renderMessages()` and the live streaming hot path are unchanged by this slice. `renderMessages()` and the live streaming hot path were unchanged by Slice 5.
## Slice 6 Live Shadow Feed
`attachLiveStream()` now creates or reuses a per-stream local registry in
`window._liveAnchorRegistries` and feeds current live activity events through
`HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent()`. This is a
shadow feed only: Compact Worklog, Transparent Stream, `renderMessages()`,
`S.messages`, `INFLIGHT`, and DOM continuity do not read from the registry yet.
The feed intentionally skips `token` events. Token events can arrive at high
frequency and would turn the anchor into a per-token append log before the
renderer reconciliation slice has proven the row model. Reasoning deltas are
also not fed one-by-one; Slice 6 flushes one aggregate reasoning event before a
terminal or settled-restore path. The feed captures the non-token activity
boundaries that define the future scene: interim assistant segments, tool
start/complete, approval, clarify, goal continuation, pending steer leftovers,
compression lifecycle, app errors, cancel, and done.
The SSE `Last-Event-ID` value is copied into the source event before applying it
to the registry, with current event-id fallbacks preserved. Existing registries
are reused by `stream_id` so a reconnect continues the same dedupe ring instead
of starting a parallel owner. Completed, errored, or cancelled streams schedule
registry cleanup after a retention window. Permanently failed network-error
paths schedule a shorter cleanup window after recovery/restore options are
exhausted.
The `done` feed is deliberately slim: status, usage, and creation timestamp are
copied, but the full settled session payload is not duplicated into the live
registry. When the active settled assistant message is available, it is stamped
with `_anchor_stream_id` so later reconciliation can associate the settled
message with the live shadow registry. That field is treated as client-side
ephemeral turn metadata and is carried forward across session refreshes.
EventSource network `error` remains a transport/recovery signal and is not fed
as an anchor terminal event in this slice. Runtime app errors continue through
the existing `apperror` event path and are fed as terminal activity only when
they match the current session.
## Source Event Classification ## Source Event Classification
+106 -1
View File
@@ -1844,6 +1844,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} }
if(await _restoreSettledSession(source)) return; if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return; if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source); _handleStreamError(source);
})(); })();
} }
@@ -1903,6 +1905,63 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _STREAM_FADE_STAGGER_MS=16; const _STREAM_FADE_STAGGER_MS=16;
const _STREAM_FADE_DONE_MAX_MS=320; const _STREAM_FADE_DONE_MAX_MS=320;
const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; const _STREAM_FADE_DONE_DRAIN_MAX_MS=900;
const _anchorApi=(typeof window!=='undefined'&&window.HermesAssistantTurnAnchors)
? window.HermesAssistantTurnAnchors
: null;
const _anchorRegistryMap=(typeof window!=='undefined')
? (window._liveAnchorRegistries=window._liveAnchorRegistries||new Map())
: null;
const _existingAnchorRegistry=_anchorRegistryMap?_anchorRegistryMap.get(streamId):null;
const _anchorRegistry=_existingAnchorRegistry||(_anchorApi&&typeof _anchorApi.createAssistantTurnAnchorRegistry==='function'
? _anchorApi.createAssistantTurnAnchorRegistry({
session_id:activeSid,
stream_id:streamId,
run_id:null,
})
: null);
let _anchorShadowWarned=false;
let _anchorReasoningFlushed=false;
if(_anchorRegistryMap&&_anchorRegistry) _anchorRegistryMap.set(streamId,_anchorRegistry);
function _scheduleAnchorRegistryCleanup(delayMs=600000){
if(!_anchorRegistryMap||!_anchorRegistry) return;
setTimeout(()=>{
if(_anchorRegistryMap.get(streamId)===_anchorRegistry) _anchorRegistryMap.delete(streamId);
},delayMs);
}
function _applyToAnchor(sourceEventType, rawEventData, sseEvent){
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return null;
const raw=(rawEventData&&typeof rawEventData==='object')?rawEventData:{};
const eventId=(sseEvent&&sseEvent.lastEventId)||raw.event_id||raw.lastEventId||raw.last_event_id||'';
const sourceEvent={
...raw,
source_event_type:sourceEventType,
activitySegmentSeq:_assistantSegmentSeq,
activityBurstId:_currentActivityBurstId,
};
if(eventId) sourceEvent.event_id=eventId;
try{
return _anchorApi.applyAssistantTurnAnchorSourceEvent(
_anchorRegistry,
sourceEvent,
{session_id:activeSid,stream_id:streamId}
);
}catch(err){
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
_anchorShadowWarned=true;
console.warn('assistant turn anchor live shadow feed failed',err);
}
return null;
}
}
function _flushReasoningToAnchor(){
if(_anchorReasoningFlushed||!reasoningText) return;
_anchorReasoningFlushed=true;
_applyToAnchor('reasoning',{
text:reasoningText,
local_id:'live-reasoning',
seq:_runJournalReplayAfterSeq(),
},null);
}
function _mergeSettledToolCallsWithLiveMetadata(rawCalls){ function _mergeSettledToolCallsWithLiveMetadata(rawCalls){
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[]; const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
@@ -2802,6 +2861,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!visible){ if(!visible){
return; return;
} }
_applyToAnchor('interim_assistant',d,e);
liveReasoningText=''; liveReasoningText='';
if(alreadyStreamed){ if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid){ if(!S.session||S.session.session_id!==activeSid){
@@ -2890,6 +2950,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_completeAutomaticCompressionOnLiveProgress(activeSid); _completeAutomaticCompressionOnLiveProgress(activeSid);
const tc=upsertLiveToolCall(d,'start'); const tc=upsertLiveToolCall(d,'start');
if(!tc) return; if(!tc) return;
_applyToAnchor('tool',{...d,...tc},e);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts(); if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return; if(!S.session||S.session.session_id!==activeSid) return;
@@ -2922,6 +2983,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const tc=upsertLiveToolCall(d,'complete'); const tc=upsertLiveToolCall(d,'complete');
if(!tc) return; if(!tc) return;
tc.is_error=!!d.is_error; tc.is_error=!!d.is_error;
_applyToAnchor('tool_complete',{...d,...tc,is_error:!!d.is_error},e);
if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc); if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts(); if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return; if(!S.session||S.session.session_id!==activeSid) return;
@@ -2989,6 +3051,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('approval',e=>{ source.addEventListener('approval',e=>{
const d=JSON.parse(e.data); const d=JSON.parse(e.data);
_applyToAnchor('approval',d,e);
showApprovalForSession(activeSid, d, 1); showApprovalForSession(activeSid, d, 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1)); playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid}); sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
@@ -2996,6 +3059,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('clarify',e=>{ source.addEventListener('clarify',e=>{
const d=JSON.parse(e.data); const d=JSON.parse(e.data);
_applyToAnchor('clarify',d,e);
showClarifyForSession(activeSid, d); showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1)); playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid}); sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
@@ -3083,6 +3147,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const sid=d.session_id||activeSid; const sid=d.session_id||activeSid;
const continuation_prompt=String(d.continuation_prompt||d.text||'').trim(); const continuation_prompt=String(d.continuation_prompt||d.text||'').trim();
if(!continuation_prompt||sid!==activeSid)return; if(!continuation_prompt||sid!==activeSid)return;
_applyToAnchor('goal_continue',d,e);
const _modelState=_chatPayloadModelState(); const _modelState=_chatPayloadModelState();
_pendingGoalContinuation={ _pendingGoalContinuation={
sid, sid,
@@ -3132,6 +3197,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true; _terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
const _doneData=JSON.parse(e.data); const _doneData=JSON.parse(e.data);
const _doneEvent=e;
const _finishDone=()=>{ const _finishDone=()=>{
// Bug A fix: cancel any pending rAF and mark stream finalized before // Bug A fix: cancel any pending rAF and mark stream finalized before
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF // the DOM is settled by renderMessages, so no trailing token/reasoning rAF
@@ -3154,6 +3220,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser(); _smdEndParser();
} }
const d=_doneData; const d=_doneData;
_flushReasoningToAnchor();
_applyToAnchor('done',{
status:d.status||'completed',
usage:d.usage||null,
created_at:d.created_at||null,
},_doneEvent);
_scheduleAnchorRegistryCleanup();
const isActiveSession=_isSessionCurrentPane(activeSid); const isActiveSession=_isSessionCurrentPane(activeSid);
const isSessionViewed=_isSessionActivelyViewed(activeSid); const isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSession=d.session||{session_id:activeSid}; const completedSession=d.session||{session_id:activeSid};
@@ -3201,6 +3274,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} }
// Find the last assistant message once for both reasoning persistence and timestamp // Find the last assistant message once for both reasoning persistence and timestamp
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant'); const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
if(_anchorRegistry&&lastAsst) lastAsst._anchor_stream_id=streamId;
// Persist reasoning trace for Worklog Thinking Cards; normal transcript // Persist reasoning trace for Worklog Thinking Cards; normal transcript
// rendering keeps provider reasoning out of the final answer. // rendering keeps provider reasoning out of the final answer.
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText; if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
@@ -3383,6 +3457,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const sid=d.session_id||activeSid; const sid=d.session_id||activeSid;
const txt=String(d.text||'').trim(); const txt=String(d.text||'').trim();
if(!txt||sid!==activeSid) return; if(!txt||sid!==activeSid) return;
_applyToAnchor('pending_steer_leftover',d,e);
if(typeof queueSessionMessage==='function'){ if(typeof queueSessionMessage==='function'){
const _modelState=_chatPayloadModelState(); const _modelState=_chatPayloadModelState();
queueSessionMessage(sid,{ queueSessionMessage(sid,{
@@ -3404,6 +3479,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let d={}; let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; } try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
if(d.session_id&&d.session_id!==activeSid) return; if(d.session_id&&d.session_id!==activeSid) return;
_applyToAnchor('compressing',d,e);
const state={ const state={
sessionId:activeSid, sessionId:activeSid,
phase:'running', phase:'running',
@@ -3438,6 +3514,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const continuationSid=d.new_session_id||d.continuation_session_id||''; const continuationSid=d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||d.new_session_id===currentSid||d.continuation_session_id===currentSid)); const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||d.new_session_id===currentSid||d.continuation_session_id===currentSid));
if(!eventMatchesCurrent) return; if(!eventMatchesCurrent) return;
_applyToAnchor('compressed',d,e);
const displaySid=currentSid; const displaySid=currentSid;
if(d.usage&&typeof _syncCtxIndicator==='function'){ if(d.usage&&typeof _syncCtxIndicator==='function'){
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function' S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
@@ -3502,8 +3579,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const eventSid=d.old_session_id||d.session_id||''; const eventSid=d.old_session_id||d.session_id||'';
const continuationSid=(d.session&&d.session.session_id)||d.new_session_id||d.continuation_session_id||''; const continuationSid=(d.session&&d.session.session_id)||d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||continuationSid===currentSid)); const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||continuationSid===currentSid));
if(eventMatchesCurrent){
_flushReasoningToAnchor();
_applyToAnchor('apperror',{
type:d.type||'error',
status:d.status||d.type||'error',
message:d.message||'',
hint:d.hint||'',
details:d.details||'',
session_id:d.session_id||eventSid||activeSid,
old_session_id:d.old_session_id||null,
new_session_id:d.new_session_id||d.continuation_session_id||null,
},e);
}
if(S.session&&eventMatchesCurrent){ if(S.session&&eventMatchesCurrent){
S.activeStreamId=null; S.activeStreamId=null;
_scheduleAnchorRegistryCleanup();
clearLiveToolCards();if(!assistantText)removeThinking(); clearLiveToolCards();if(!assistantText)removeThinking();
let isRecoveryControlMessage=false; let isRecoveryControlMessage=false;
try{ try{
@@ -3622,6 +3713,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(await _restoreSettledSession(source)) return; if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return; if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return; if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source); _handleStreamError(source);
},1500); },1500);
return; return;
@@ -3629,6 +3722,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(await _restoreSettledSession(source)) return; if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return; if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return; if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source); _handleStreamError(source);
}); });
@@ -3646,6 +3741,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_clearOwnerInflightState(); _clearOwnerInflightState();
_clearApprovalForOwner(); _clearApprovalForOwner();
_clearClarifyForOwner('cancelled'); _clearClarifyForOwner('cancelled');
let _cancelData={};
try{ _cancelData=JSON.parse(e.data||'{}')||{}; }catch(_){ _cancelData={}; }
_flushReasoningToAnchor();
_applyToAnchor('cancel',{
status:_cancelData.status||_cancelData.type||'cancelled',
message:_cancelData.message||'',
session_id:_cancelData.session_id||activeSid,
},e);
_scheduleAnchorRegistryCleanup();
if(S.session&&S.session.session_id===activeSid){ if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null; S.activeStreamId=null;
} }
@@ -3701,7 +3805,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} }
return `${m.role}|${ts}|${body.slice(0,160)}`; return `${m.role}|${ts}|${body.slice(0,160)}`;
} }
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard']; const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id'];
function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){ function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages; if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
if(!prevMessages.length||!nextMessages.length) return nextMessages; if(!prevMessages.length||!nextMessages.length) return nextMessages;
@@ -3746,6 +3850,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser(); _smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState(); _clearOwnerInflightState();
_flushReasoningToAnchor();
_closeSource(source); _closeSource(source);
_clearApprovalForOwner(); _clearApprovalForOwner();
_clearClarifyForOwner('terminal'); _clearClarifyForOwner('terminal');
+7 -6
View File
@@ -22,7 +22,7 @@ let _loadingSessionId = null;
// clears them on a force-reload of the active session. Consumed by // clears them on a force-reload of the active session. Consumed by
// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so // _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
// ephemeral fields (_turnUsage, _turnDuration, _turnTps, _gatewayRouting, // ephemeral fields (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
// _statusCard) survive the wholesale replace. null when there is nothing // _statusCard, _anchor_stream_id) survive the wholesale replace. null when there is nothing
// to carry forward (initial load, switch-to-different-session, etc.). // to carry forward (initial load, switch-to-different-session, etc.).
let _pendingCarryForwardSnapshot = null; let _pendingCarryForwardSnapshot = null;
@@ -900,7 +900,7 @@ async function loadSession(sid){
// poll triggering a refresh), snapshot the existing messages BEFORE we // poll triggering a refresh), snapshot the existing messages BEFORE we
// clear them. _ensureMessagesLoaded() runs the ephemeral-field // clear them. _ensureMessagesLoaded() runs the ephemeral-field
// carry-forward (_turnUsage, _turnDuration, _turnTps, _gatewayRouting, // carry-forward (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
// _statusCard) against S.messages, but by the time the API fetch returns // _statusCard, _anchor_stream_id) against S.messages, but by the time the API fetch returns
// S.messages has already been reset to [] here and the carry-forward is a // S.messages has already been reset to [] here and the carry-forward is a
// no-op. The visible symptom is the token-usage badge vanishing ~10s // no-op. The visible symptom is the token-usage badge vanishing ~10s
// after each assistant turn completes. Stash the snapshot so the // after each assistant turn completes. Stash the snapshot so the
@@ -1914,7 +1914,7 @@ async function _ensureMessagesLoaded(sid) {
} }
clearLiveToolCards(); clearLiveToolCards();
// #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration, // #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
// _turnTps, _gatewayRouting, _statusCard) across the loadSession replace. // _turnTps, _gatewayRouting, _statusCard, _anchor_stream_id) across the loadSession replace.
if(typeof window._carryForwardEphemeralTurnFields==='function'){ if(typeof window._carryForwardEphemeralTurnFields==='function'){
// #3306: Prefer the pre-clear snapshot stashed by loadSession() on a // #3306: Prefer the pre-clear snapshot stashed by loadSession() on a
// force-reload of the active session; S.messages was reset to [] there // force-reload of the active session; S.messages was reset to [] there
@@ -2391,7 +2391,7 @@ async function _loadOlderMessages() {
const container = $('messages'); const container = $('messages');
const prevScrollH = container ? container.scrollHeight : 0; const prevScrollH = container ? container.scrollHeight : 0;
// Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/ // Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/
// _gatewayRouting/_statusCard) before the wholesale replace so the badge // _gatewayRouting/_statusCard/_anchor_stream_id) before the wholesale replace so the badge
// does not briefly appear and disappear during older-message expansion. // does not briefly appear and disappear during older-message expansion.
if (typeof window._carryForwardEphemeralTurnFields === 'function') { if (typeof window._carryForwardEphemeralTurnFields === 'function') {
nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages); nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages);
@@ -2482,7 +2482,7 @@ async function _ensureAllMessagesLoaded() {
// #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded. // #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded.
// Loading older messages also does a wholesale replace of S.messages // Loading older messages also does a wholesale replace of S.messages
// and would otherwise drop _turnUsage/_turnDuration/_turnTps/ // and would otherwise drop _turnUsage/_turnDuration/_turnTps/
// _gatewayRouting/_statusCard on the existing turns. // _gatewayRouting/_statusCard/_anchor_stream_id on the existing turns.
let _msgsToAssign = msgs; let _msgsToAssign = msgs;
if (typeof window._carryForwardEphemeralTurnFields === 'function') { if (typeof window._carryForwardEphemeralTurnFields === 'function') {
_msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs); _msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs);
@@ -3823,7 +3823,8 @@ function startGatewaySSE(){
if (next.length < prev) return; if (next.length < prev) return;
if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return; if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return;
// Carry forward ephemeral turn fields (_turnUsage/ // Carry forward ephemeral turn fields (_turnUsage/
// _turnDuration/_turnTps/_gatewayRouting/_statusCard) so // _turnDuration/_turnTps/_gatewayRouting/_statusCard/
// _anchor_stream_id) so
// gateway-driven CLI refreshes do not drop the badge. // gateway-driven CLI refreshes do not drop the badge.
let _nextToAssign = next; let _nextToAssign = next;
if (typeof window._carryForwardEphemeralTurnFields === 'function') { if (typeof window._carryForwardEphemeralTurnFields === 'function') {
@@ -324,15 +324,17 @@ def test_structured_fallback_dedupe_keys_do_not_collide_on_delimiters():
assert data["localNoSeqKey"] == "" assert data["localNoSeqKey"] == ""
def test_normalizer_and_registry_helpers_are_still_unwired_from_rendering_hot_paths(): def test_normalizer_helpers_remain_unwired_from_rendering_and_live_hot_paths():
helper_names = [ helper_names = [
"normalizeAssistantTurnAnchorSourceEvent", "normalizeAssistantTurnAnchorSourceEvent",
"normalizeAssistantTurnAnchorSourceEvents", "normalizeAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents", "applyAssistantTurnAnchorSourceEvents",
] ]
for helper in helper_names: for helper in helper_names:
assert helper not in _read(UI_JS) assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS) assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS) assert helper not in _read(MESSAGES_JS)
messages_src = _read(MESSAGES_JS)
assert "createAssistantTurnAnchorRegistry" in messages_src
assert "applyAssistantTurnAnchorSourceEvent" in messages_src
@@ -1,8 +1,7 @@
"""Phase 0 contract tests for Stable Assistant Turn Anchors (#3926). """Phase 0 contract tests for Stable Assistant Turn Anchors (#3926).
The first implementation slice is intentionally non-visual. It adds the The first implementation slice was intentionally non-visual. Later slices keep
inventory and pure helper surface that later phases can wire into send(), the same inventory contract while adding narrow, tested wiring points.
attachLiveStream(), replay hydration, and renderMessages().
""" """
from __future__ import annotations from __future__ import annotations
@@ -94,7 +93,11 @@ def test_phase0_scaffold_is_loaded_before_current_rendering_modules():
assert "createAssistantTurnAnchorRegistry" not in ui_src assert "createAssistantTurnAnchorRegistry" not in ui_src
assert "applyAssistantTurnAnchorSourceEvent" not in ui_src assert "applyAssistantTurnAnchorSourceEvent" not in ui_src
assert "HermesAssistantTurnAnchors" not in _read(SESSIONS_JS) assert "HermesAssistantTurnAnchors" not in _read(SESSIONS_JS)
assert "HermesAssistantTurnAnchors" not in _read(MESSAGES_JS) messages_src = _read(MESSAGES_JS)
assert "window._liveAnchorRegistries" in messages_src
assert "createAssistantTurnAnchorRegistry" in messages_src
assert "applyAssistantTurnAnchorSourceEvent" in messages_src
assert "projectAssistantTurnAnchorActivityScene" not in messages_src
def test_phase0_inventory_names_current_state_layers_in_authority_order(): def test_phase0_inventory_names_current_state_layers_in_authority_order():
@@ -18,6 +18,19 @@ def _read(path: Path) -> str:
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
def _event_listener_body(src: str, event_name: str) -> str:
start = src.index(f"source.addEventListener('{event_name}'")
end = src.find("\n source.addEventListener(", start + 1)
if end < 0:
end = src.find("\n source.onerror", start + 1)
if end < 0:
end = src.find("\n }catch", start + 1)
if end < 0:
end = len(src)
assert end > start
return src[start:end]
def _registry_snapshot() -> dict: def _registry_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests" assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f""" script = f"""
@@ -806,18 +819,76 @@ def test_registry_instances_do_not_share_owner_state():
assert isolated["anchor"]["activity_events"] == [] assert isolated["anchor"]["activity_events"] == []
def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot_paths(): def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot_paths():
helper_names = [ scene_helper = "projectAssistantTurnAnchorActivityScene"
"createAssistantTurnAnchorRegistry", for helper in [
"applyAssistantTurnAnchorNormalizedEvent", "applyAssistantTurnAnchorNormalizedEvent",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents", "applyAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorShadowSnapshot", "createAssistantTurnAnchorShadowSnapshot",
] scene_helper,
for helper in helper_names: ]:
assert helper not in _read(UI_JS) assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS) assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS) assert helper not in _read(MESSAGES_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS) assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(UI_JS) assert scene_helper not in _read(UI_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(SESSIONS_JS) assert scene_helper not in _read(SESSIONS_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(MESSAGES_JS) assert scene_helper not in _read(MESSAGES_JS)
def test_slice6_live_shadow_feed_wires_non_token_events_without_renderer_scene_consumption():
src = _read(MESSAGES_JS)
helper_body = src.split("function _applyToAnchor", 1)[1].split(
"function _mergeSettledToolCallsWithLiveMetadata", 1
)[0]
assert "window._liveAnchorRegistries=window._liveAnchorRegistries||new Map()" in src
assert "_anchorRegistryMap.get(streamId)" in src
assert "_anchorRegistryMap.set(streamId,_anchorRegistry)" in src
assert "createAssistantTurnAnchorRegistry" in src
assert "applyAssistantTurnAnchorSourceEvent" in src
assert "const eventId=(sseEvent&&sseEvent.lastEventId)||raw.event_id||raw.lastEventId||raw.last_event_id||'';" in helper_body
assert helper_body.index("...raw,") < helper_body.index("source_event_type:sourceEventType")
for event_name in [
"interim_assistant",
"tool",
"tool_complete",
"approval",
"clarify",
"goal_continue",
"pending_steer_leftover",
"compressing",
"compressed",
"apperror",
"cancel",
]:
assert f"_applyToAnchor('{event_name}'" in _event_listener_body(src, event_name)
token_body = _event_listener_body(src, "token")
assert "_applyToAnchor" not in token_body
reasoning_body = _event_listener_body(src, "reasoning")
assert "_applyToAnchor" not in reasoning_body
assert "function _flushReasoningToAnchor()" in src
assert "_applyToAnchor('reasoning',{" in src
assert "local_id:'live-reasoning'" in src
error_body = _event_listener_body(src, "error")
assert "_applyToAnchor('error'" not in error_body
assert "_flushReasoningToAnchor();" in error_body
assert "_scheduleAnchorRegistryCleanup(120000);" in error_body
assert "_handleStreamError(source)" in error_body
assert "projectAssistantTurnAnchorActivityScene" not in src
tool_body = _event_listener_body(src, "tool")
assert tool_body.index("upsertLiveToolCall(d,'start')") < tool_body.index(
"_applyToAnchor('tool'"
)
done_body = _event_listener_body(src, "done")
assert "_applyToAnchor('done',{" in done_body
assert "usage:d.usage||null" in done_body
assert "created_at:d.created_at||null" in done_body
assert "_applyToAnchor('done',{...d" not in done_body
assert "_flushReasoningToAnchor();" in done_body
assert "_scheduleAnchorRegistryCleanup();" in done_body
assert "lastAsst._anchor_stream_id=streamId" in done_body
assert "'_anchor_stream_id'" in src
assert src.index("'_anchor_stream_id'") < src.index("function _carryForwardEphemeralTurnFields")