Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d8a3dc30 | ||
|
|
310edac58e | ||
|
|
3cf96b1293 | ||
|
|
9594b97223 | ||
|
|
fcad7d6db2 | ||
|
|
30dd5647ed | ||
|
|
aa96bc52bc |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(_){}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user