diff --git a/CHANGELOG.md b/CHANGELOG.md index ee63fe36..a143c0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [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) ### Fixed diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index d6c8e431..559368c1 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -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 diff --git a/static/messages.js b/static/messages.js index 4de862f6..76090b2d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1844,6 +1844,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } if(await _restoreSettledSession(source)) return; if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return; + _flushReasoningToAnchor(); + _scheduleAnchorRegistryCleanup(120000); _handleStreamError(source); })(); } @@ -1903,6 +1905,63 @@ 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); + } + 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 +2861,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 +2950,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 +2983,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 +3051,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 +3059,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 +3147,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 +3197,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 +3220,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 +3274,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 +3457,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 +3479,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 +3514,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 +3579,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 +3713,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 +3722,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 +3741,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 +3805,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 +3850,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _smdEndParser(); if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); _clearOwnerInflightState(); + _flushReasoningToAnchor(); _closeSource(source); _clearApprovalForOwner(); _clearClarifyForOwner('terminal'); diff --git a/static/sessions.js b/static/sessions.js index 6b78c479..e887e314 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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') { diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index 1143977d..b3f9dc27 100644 --- a/tests/test_stable_assistant_turn_anchor_normalizer.py +++ b/tests/test_stable_assistant_turn_anchor_normalizer.py @@ -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 diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py index 4e995a86..03a13ef6 100644 --- a/tests/test_stable_assistant_turn_anchor_phase0.py +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -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(): diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 4be4975d..e27583c5 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -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")