Add assistant turn anchor live shadow feed
This commit is contained in:
1 parent
ae90cf620b
commit
aa96bc52bc
7 files changed
+254
-27
No files matched your search
@@ -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
@@ -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
@@ -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")
|
||||||
Reference in new issue
Block a user