Compare commits

...

7 Commits

Author SHA1 Message Date
nesquena-hermes
e3d8a3dc30 Merge pull request #4115 from nesquena/stage-4108
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MZ (v0.51.387): Stable Assistant Turn Anchors live shadow feed (inert) (#4108)
2026-06-13 09:36:44 -07:00
nesquena-hermes
310edac58e fix(#4108): add creation-time backstop cleanup to bound the registry-leak class
Codex found leak paths across 3 rounds (settled-restore, stream-end-fallback, reconnect-
preflight-inactive, stale-bailout, non-current-pane error, external cancelSessionStream).
Rather than chase every current+future teardown route, schedule an identity-guarded cleanup
at registry CREATION (600s) so window._liveAnchorRegistries self-expires no matter how the
stream ends — including external paths (boot.js cancelSessionStream) that bypass the SSE
handlers. Explicit terminal-path calls just expire it sooner. Shadow-only + identity-guarded,
so a rare >10min active stream only loses reconnect-continuity of an unconsumed registry.
2026-06-13 16:19:18 +00:00
nesquena-hermes
3cf96b1293 fix(#4108): cleanup anchor registry on reconnect-preflight-inactive + stale-stream-bailout paths (Codex round 2)
Two more early-return paths created the registry (early in attachLiveStream) then
returned without scheduling cleanup: (1) reconnect preflight when /api/chat/stream/status
is inactive/no-replay (~3966); (2) _bailOutOfTerminalEventsFromStaleStream (~1490) when a
stale stream's terminal event arrives after a newer stream owns the session. Both now
schedule identity-guarded cleanup (map.get(streamId)===registry, keyed on the stale
closure's own streamId so the newer registry is untouched).
2026-06-13 16:13:55 +00:00
nesquena-hermes
9594b97223 fix(#4108): schedule anchor registry cleanup on settled-restore + stream-end-fallback terminal paths (Codex leak catch)
Codex found window._liveAnchorRegistries retained registries for streams ending via
_restoreSettledSession() and _finalizeStreamEndFallback() (both terminal-close without
cleanup). Added _flushReasoningToAnchor()+_scheduleAnchorRegistryCleanup() to both —
identity-guarded delete already prevents clobbering a newer registry. All anchor helpers
are nested in attachLiveStream's closure (hoisted), ESLint runtime gate clean.
2026-06-13 16:09:20 +00:00
nesquena-hermes
fcad7d6db2 docs(changelog): stamp #4108 anchor live shadow feed (inert) as v0.51.387 (Release MZ) 2026-06-13 16:05:06 +00:00
nesquena-hermes
30dd5647ed Merge #4108 (Stable Assistant Turn Anchors: live shadow feed, slice 6) onto master 2026-06-13 16:03:35 +00:00
Frank Song
aa96bc52bc Add assistant turn anchor live shadow feed 2026-06-13 16:26:52 +08:00
7 changed files with 270 additions and 27 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.387] — 2026-06-13 — Release MZ (Stable Assistant Turn Anchors live shadow feed, inert, #3926)
### 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. Every feed call is wrapped in a guarded helper (`_applyToAnchor`) that no-ops if the anchor API is unavailable and swallows any error (warn-once), so a shadow-feed fault can never break the live stream. (#3926)
## [v0.51.386] — 2026-06-13 — Release MY (voice mode survives a dropped speechSynthesis onend, #3983)
### Fixed

View File

@@ -26,10 +26,14 @@ streaming or rendering yet.
- Slice 5 starts RFC Phase 5 by projecting anchor-owned activity events into a
renderer-neutral activity scene that Compact Worklog and Transparent Stream
can later consume from the same ordered rows.
- The next independently reviewable boundary is wiring one current renderer to
the activity scene. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes
remain projection/cache layers outside the settled final-prose path and the
inert activity-scene projection.
- Slice 6 starts the live shadow-feed boundary: `attachLiveStream()` now creates
a per-stream anchor registry and feeds non-token live activity events into it
without changing either current renderer.
- 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
@@ -153,7 +157,44 @@ IDs, and sanitized payloads with a chronological display hint. This pins the
shared input shape before either renderer is rewired.
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

View File

@@ -1488,6 +1488,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _bailOutOfTerminalEventsFromStaleStream(source){
if(_ownsActiveStreamOrBackground()) return false;
// This stale stream no longer owns the session — schedule cleanup of ITS own
// anchor registry (identity-guarded, so it can't clobber the newer stream's
// registry for the same session) before closing. (Codex leak catch.)
_scheduleAnchorRegistryCleanup(120000);
_closeSource(source);
return true;
}
@@ -1638,6 +1642,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(_isActiveSession()){
@@ -1844,6 +1850,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
})();
}
@@ -1903,6 +1911,69 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _STREAM_FADE_STAGGER_MS=16;
const _STREAM_FADE_DONE_MAX_MS=320;
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);
}
// Backstop: schedule an identity-guarded cleanup at creation so this shadow
// registry self-expires no matter which teardown path the stream takes
// (incl. external ones like sidebar cancelSessionStream() that bypass the
// in-closure SSE handlers). Explicit terminal-path calls above just expire it
// sooner; this guarantees window._liveAnchorRegistries can't grow unbounded.
_scheduleAnchorRegistryCleanup(600000);
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){
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
@@ -2802,6 +2873,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!visible){
return;
}
_applyToAnchor('interim_assistant',d,e);
liveReasoningText='';
if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid){
@@ -2890,6 +2962,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_completeAutomaticCompressionOnLiveProgress(activeSid);
const tc=upsertLiveToolCall(d,'start');
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) return;
@@ -2922,6 +2995,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const tc=upsertLiveToolCall(d,'complete');
if(!tc) return;
tc.is_error=!!d.is_error;
_applyToAnchor('tool_complete',{...d,...tc,is_error:!!d.is_error},e);
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) return;
@@ -2989,6 +3063,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('approval',d,e);
showApprovalForSession(activeSid, d, 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
@@ -2996,6 +3071,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('clarify',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('clarify',d,e);
showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
@@ -3083,6 +3159,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const sid=d.session_id||activeSid;
const continuation_prompt=String(d.continuation_prompt||d.text||'').trim();
if(!continuation_prompt||sid!==activeSid)return;
_applyToAnchor('goal_continue',d,e);
const _modelState=_chatPayloadModelState();
_pendingGoalContinuation={
sid,
@@ -3132,6 +3209,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
const _doneData=JSON.parse(e.data);
const _doneEvent=e;
const _finishDone=()=>{
// Bug A fix: cancel any pending rAF and mark stream finalized before
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
@@ -3154,6 +3232,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
}
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 isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSession=d.session||{session_id:activeSid};
@@ -3201,6 +3286,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Find the last assistant message once for both reasoning persistence and timestamp
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
// rendering keeps provider reasoning out of the final answer.
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
@@ -3383,6 +3469,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const sid=d.session_id||activeSid;
const txt=String(d.text||'').trim();
if(!txt||sid!==activeSid) return;
_applyToAnchor('pending_steer_leftover',d,e);
if(typeof queueSessionMessage==='function'){
const _modelState=_chatPayloadModelState();
queueSessionMessage(sid,{
@@ -3404,6 +3491,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
if(d.session_id&&d.session_id!==activeSid) return;
_applyToAnchor('compressing',d,e);
const state={
sessionId:activeSid,
phase:'running',
@@ -3438,6 +3526,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
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));
if(!eventMatchesCurrent) return;
_applyToAnchor('compressed',d,e);
const displaySid=currentSid;
if(d.usage&&typeof _syncCtxIndicator==='function'){
S.lastUsage=typeof _mergeUsageForCtxIndicator==='function'
@@ -3502,8 +3591,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
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 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){
S.activeStreamId=null;
_scheduleAnchorRegistryCleanup();
clearLiveToolCards();if(!assistantText)removeThinking();
let isRecoveryControlMessage=false;
try{
@@ -3622,6 +3725,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
},1500);
return;
@@ -3629,6 +3734,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(await _restoreSettledSession(source)) return;
if(_deferStreamErrorIfOffline()) return;
if(_deferStreamErrorIfPageHidden(source)) return;
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup(120000);
_handleStreamError(source);
});
@@ -3646,6 +3753,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_clearOwnerInflightState();
_clearApprovalForOwner();
_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){
S.activeStreamId=null;
}
@@ -3701,7 +3817,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
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){
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
if(!prevMessages.length||!nextMessages.length) return nextMessages;
@@ -3746,6 +3862,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_closeSource(source);
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
@@ -3855,6 +3973,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
renderMessages({preserveScroll:true});
renderSessionList();
}
_scheduleAnchorRegistryCleanup(120000);
return;
}
}catch(_){}

View File

@@ -22,7 +22,7 @@ let _loadingSessionId = null;
// clears them on a force-reload of the active session. Consumed by
// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
// 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.).
let _pendingCarryForwardSnapshot = null;
@@ -900,7 +900,7 @@ async function loadSession(sid){
// poll triggering a refresh), snapshot the existing messages BEFORE we
// clear them. _ensureMessagesLoaded() runs the ephemeral-field
// 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
// no-op. The visible symptom is the token-usage badge vanishing ~10s
// after each assistant turn completes. Stash the snapshot so the
@@ -1914,7 +1914,7 @@ async function _ensureMessagesLoaded(sid) {
}
clearLiveToolCards();
// #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'){
// #3306: Prefer the pre-clear snapshot stashed by loadSession() on a
// force-reload of the active session; S.messages was reset to [] there
@@ -2391,7 +2391,7 @@ async function _loadOlderMessages() {
const container = $('messages');
const prevScrollH = container ? container.scrollHeight : 0;
// 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.
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages);
@@ -2482,7 +2482,7 @@ async function _ensureAllMessagesLoaded() {
// #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded.
// Loading older messages also does a wholesale replace of S.messages
// 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;
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
_msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs);
@@ -3823,7 +3823,8 @@ function startGatewaySSE(){
if (next.length < prev) return;
if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return;
// 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.
let _nextToAssign = next;
if (typeof window._carryForwardEphemeralTurnFields === 'function') {

View File

@@ -324,15 +324,17 @@ def test_structured_fallback_dedupe_keys_do_not_collide_on_delimiters():
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 = [
"normalizeAssistantTurnAnchorSourceEvent",
"normalizeAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents",
]
for helper in helper_names:
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS)
messages_src = _read(MESSAGES_JS)
assert "createAssistantTurnAnchorRegistry" in messages_src
assert "applyAssistantTurnAnchorSourceEvent" in messages_src

View File

@@ -1,8 +1,7 @@
"""Phase 0 contract tests for Stable Assistant Turn Anchors (#3926).
The first implementation slice is intentionally non-visual. It adds the
inventory and pure helper surface that later phases can wire into send(),
attachLiveStream(), replay hydration, and renderMessages().
The first implementation slice was intentionally non-visual. Later slices keep
the same inventory contract while adding narrow, tested wiring points.
"""
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 "applyAssistantTurnAnchorSourceEvent" not in ui_src
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():

View File

@@ -18,6 +18,19 @@ def _read(path: Path) -> str:
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:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
@@ -806,18 +819,76 @@ def test_registry_instances_do_not_share_owner_state():
assert isolated["anchor"]["activity_events"] == []
def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot_paths():
helper_names = [
"createAssistantTurnAnchorRegistry",
scene_helper = "projectAssistantTurnAnchorActivityScene"
for helper in [
"applyAssistantTurnAnchorNormalizedEvent",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorShadowSnapshot",
]
for helper in helper_names:
scene_helper,
]:
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(UI_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(SESSIONS_JS)
assert "projectAssistantTurnAnchorActivityScene" not in _read(MESSAGES_JS)
assert scene_helper not in _read(UI_JS)
assert scene_helper not in _read(SESSIONS_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")