Merge pull request #4037 from franksong2702/franksong2702/stable-assistant-turn-anchors-slice3-registry
Add assistant turn anchor registry and shadow source harness
This commit is contained in:
6 files changed
+1033
-36
No files matched your search
@@ -6,6 +6,25 @@ It documents the current per-turn state layers and the event-shape contract that
|
||||
future anchor phases must consume. It does not claim that anchors are wired into
|
||||
streaming or rendering yet.
|
||||
|
||||
## RFC Phase Progress
|
||||
|
||||
- The #3962 Phase 0 scaffold shipped through #3977 / v0.51.359: inventory the
|
||||
current state layers, encode the owner seed, and pin the source classification
|
||||
contract.
|
||||
- PR #3980 / v0.51.366 delivered the first RFC Phase 2 foundation: normalize
|
||||
current live, replay, and settled source events into anchor-shaped events while
|
||||
staying unwired from rendering.
|
||||
- This slice advances RFC Phase 1 and Phase 2 together: it adds a local registry
|
||||
owner plus a shadow source-feed harness that can combine live, replay,
|
||||
settled, and in-flight observations into one anchor snapshot.
|
||||
- It also covers the RFC Phase 2.5 contract-hardening boundary: the semantic
|
||||
anchor seed excludes renderer presentation state, terminal states are exposed
|
||||
as constants with alias normalization, and replay + settlement ordering is
|
||||
pinned by tests before visible wiring begins.
|
||||
- The next independently reviewable boundary is Phase 3 settlement through the
|
||||
anchor owner. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
|
||||
projection/cache layers until that wiring lands.
|
||||
|
||||
## State Layers
|
||||
|
||||
| Layer | Current surface | Phase 0 anchor policy |
|
||||
@@ -37,6 +56,64 @@ same event-envelope key. This is still inert: `send()`, `attachLiveStream()`,
|
||||
`renderMessages()`, settlement restore, `S.messages`, `INFLIGHT`, and the DOM do
|
||||
not consume the helper yet.
|
||||
|
||||
## Slice 3 Registry / Owner Skeleton
|
||||
|
||||
`HermesAssistantTurnAnchors.createAssistantTurnAnchorRegistry()` creates a local
|
||||
owner object for one assistant turn. The registry contains the anchor seed, a
|
||||
dedupe index, and application stats. It is not a global store and is not wired
|
||||
into current runtime, session, or renderer code.
|
||||
|
||||
`HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent()` and
|
||||
`applyAssistantTurnAnchorSourceEvents()` normalize incoming source events, apply
|
||||
the same event-envelope dedupe rule, and route events into one owner:
|
||||
|
||||
- `activity_events` for visible assistant activity such as prose, reasoning,
|
||||
tools, control boundaries, and terminal status
|
||||
- `artifacts` for workspace/file references
|
||||
- `side_effects` for persisted state side effects
|
||||
- `metadata_events` for settlement/session metadata such as `settled_message`
|
||||
- `transport_events` for transport-only signals such as `stream_end`
|
||||
|
||||
The registry may fill missing `run_id` / `stream_id` identity from the first
|
||||
matching normalized event, update lifecycle on terminal status, and copy the
|
||||
settled assistant message into `content.final_answer` as a derived render
|
||||
snapshot while keeping `content.final_message_ref` as the settled transcript
|
||||
reference. It rejects mismatched session or turn identity and skips duplicate
|
||||
live + replay observations by the same dedupe key.
|
||||
|
||||
This slice deliberately keeps the ownership boundary inert: `send()`,
|
||||
`attachLiveStream()`, replay hydration, `renderMessages()`, `S.messages`,
|
||||
`INFLIGHT`, and DOM continuity still do not consume the registry. Later slices
|
||||
can replace local renderer-owned state with this owner instead of adding another
|
||||
parallel source of truth.
|
||||
|
||||
`HermesAssistantTurnAnchors.createAssistantTurnAnchorShadowSnapshot()` is the
|
||||
shadow wiring harness for this slice. It accepts grouped `live_events`,
|
||||
`replay_events` / `run_journal_events`, `settled_events`, and `inflight_events`,
|
||||
feeds them through one local registry, and returns the resulting snapshot plus
|
||||
per-source apply results. This gives later slices an invariant target without
|
||||
making the current UI consume the owner yet.
|
||||
|
||||
Renderer-only UI state such as Compact Worklog expansion, Transparent Stream
|
||||
expansion, copy-button visibility, and scroll-follow preference is intentionally
|
||||
not stored in the anchor seed. Those choices belong in renderer state or a
|
||||
separate per-session UI preference store so replay and settlement do not carry
|
||||
historic display preferences as semantic facts.
|
||||
|
||||
`HermesAssistantTurnAnchors.terminalStates` exposes the RFC terminal-state enum:
|
||||
`completed`, `cancelled`, `interrupted`, `no_response`,
|
||||
`tool_limit_reached`, `compression_exhausted`, `connection_lost`, `degraded`,
|
||||
and `error`. `normalizeAssistantTurnAnchorTerminalState()` maps current source
|
||||
aliases such as `done`, `cancel`, `apperror`, `interrupted-by-user`,
|
||||
`max_iterations`, and `lost_worker_bookkeeping` into that enum.
|
||||
|
||||
During the later `INFLIGHT` migration, the registry is the semantic owner for
|
||||
event identity, lifecycle, final answer reference, and activity events.
|
||||
`INFLIGHT.lastRunJournalSeq`, `activityBurstAnchors`, `currentLiveSegmentSeq`,
|
||||
`streamId`, and cached live text/tool state remain recovery or renderer caches
|
||||
until the matching field is explicitly moved. The fallback order is journal
|
||||
replay first, settled transcript second, `INFLIGHT` only for gaps.
|
||||
|
||||
## Source Event Classification
|
||||
|
||||
Phase 0 classifies current sources before changing render behavior:
|
||||
@@ -61,7 +138,13 @@ The Phase 0 helper uses this order:
|
||||
|
||||
1. `event_id`
|
||||
2. `run_id + seq`
|
||||
3. `session_id + local_id` as a browser fallback
|
||||
3. `session_id + source_event_type + local_id + seq` as a browser fallback only
|
||||
when a concrete local `seq` is present
|
||||
|
||||
This mirrors the RuntimeAdapter Event Envelope and keeps the browser aligned
|
||||
with run-journal replay while the anchor registry is still unwired.
|
||||
|
||||
The registry tests also pin the reconnect/settlement race shape: if one run is
|
||||
observed live, replayed, and settled in either order, duplicate event envelopes
|
||||
are skipped and the resulting anchor has the same activity list, terminal state,
|
||||
final message reference, final-answer snapshot, and usage metadata.
|
||||
@@ -301,10 +301,6 @@ AssistantTurnAnchor
|
||||
artifacts[]
|
||||
side_effects[]
|
||||
usage
|
||||
presentation_state
|
||||
compact_worklog expansion state
|
||||
transparent_stream expansion state
|
||||
scroll/follow hints
|
||||
```
|
||||
|
||||
The anchor is not a backend schema requirement. It is the frontend model that
|
||||
@@ -335,6 +331,12 @@ renderers consume the anchor
|
||||
In the target model, stream `done` should not need to "make a different turn."
|
||||
It should complete the existing turn.
|
||||
|
||||
Renderer-only preferences are deliberately outside the semantic anchor. Compact
|
||||
Worklog expansion, Transparent Stream expansion, copy-button visibility, and
|
||||
scroll-follow preferences may live in renderer state or a per-session UI
|
||||
preference store, but replay and settlement must not persist those choices as
|
||||
assistant-turn facts.
|
||||
|
||||
## Anchor Creation And Identity
|
||||
|
||||
The normal creation path is:
|
||||
@@ -525,8 +527,8 @@ existing anchor:
|
||||
|
||||
1. verify that `session_id`, `run_id`, or `stream_id` still belongs to the
|
||||
anchor,
|
||||
2. write the final assistant answer into `anchor.final_answer` or bind
|
||||
`anchor.final_message_ref`,
|
||||
2. bind `anchor.content.final_message_ref` to the settled assistant message and
|
||||
write `anchor.content.final_answer` as a derived render snapshot,
|
||||
3. attach terminal state and usage metadata,
|
||||
4. merge settled reasoning/tool/artifact metadata into existing activity events,
|
||||
5. mark live-only events as settled or drop them if they were purely transient,
|
||||
@@ -542,6 +544,13 @@ Terminal state is separate from final answer text. A turn can have partial
|
||||
process prose, tool cards, reasoning, artifacts, or usage metadata and still
|
||||
fail to produce a normal final answer.
|
||||
|
||||
`content.final_message_ref` is the durable transcript reference when settled
|
||||
message identity exists. `content.final_answer` is not an independent semantic
|
||||
owner; it is a projection cache copied from the settled assistant message so
|
||||
early renderers can avoid chasing `S.messages` during every paint. If settlement
|
||||
later rewrites the transcript message, the anchor must be refreshed from that
|
||||
message instead of allowing the two copies to drift silently.
|
||||
|
||||
## Replay, Reload, And Reconstruction
|
||||
|
||||
Replay/reload should reconstruct the same Assistant Turn Anchor from durable
|
||||
@@ -569,6 +578,17 @@ anchor after `/api/chat/start` succeeds and returns `stream_id`. Reconstruction
|
||||
is for reload, reconnect, session switch, SSE replay, and recovery cases where
|
||||
the local anchor is missing or incomplete.
|
||||
|
||||
During migration, `INFLIGHT` remains a recovery cache until an anchor-backed
|
||||
field takes over. The handoff order should be:
|
||||
|
||||
| Current `INFLIGHT` field family | Anchor destination | Fallback rule |
|
||||
| --- | --- | --- |
|
||||
| `lastRunJournalSeq` / replay cursor | anchor event dedupe index plus run-journal cursor metadata | Prefer run journal replay; read `INFLIGHT` only to resume a missing browser cursor. |
|
||||
| `activityBurstAnchors` / live row anchors | `activity_events[]` identity and grouping metadata | Preserve DOM hints as renderer cache; do not let them outrank normalized events. |
|
||||
| `currentLiveSegmentSeq` / local live order | anchor-local source order when no Event Envelope exists | Use only as browser fallback identity; never dedupe by visible text. |
|
||||
| `streamId` / active transport key | `identity.stream_id` fallback below `run_id` | Do not hard-bind ownership to `stream_id` once `run_id` is known. |
|
||||
| cached assistant text / reasoning / tool state | `activity_events[]` and derived render rows | Use for reconstruction gaps only after journal and settled transcript evidence. |
|
||||
|
||||
`stream_end` deserves special care. It is a transport close signal and may
|
||||
trigger recovery. It must not be treated as proof that the turn completed.
|
||||
|
||||
@@ -651,6 +671,11 @@ replacement when no final answer exists. Transparent Stream should show the same
|
||||
terminal truth as the final chronological event. The two display modes must not
|
||||
disagree about the outcome.
|
||||
|
||||
Implementation code should expose the terminal states as constants rather than
|
||||
spread string literals across settlement, replay, and renderer code. Source
|
||||
aliases such as `done`, `cancel`, `apperror`, and runtime-specific error labels
|
||||
should normalize into this enum before renderer consumption.
|
||||
|
||||
## Artifacts And Side Effects
|
||||
|
||||
Agent turns produce more than prose and tool rows. They can create files, mutate
|
||||
@@ -765,6 +790,18 @@ changes should remain independently reviewable.
|
||||
- Keep Compact Worklog as the only visible renderer.
|
||||
- Add coverage proving all current event names have an explicit classification.
|
||||
|
||||
### Phase 2.5: Anchor contract hardening
|
||||
|
||||
- Keep renderer presentation state out of the semantic anchor seed.
|
||||
- Export terminal-state constants and normalize source aliases before Phase 3
|
||||
writes settlement outcomes.
|
||||
- Document which `INFLIGHT` fields become anchor-owned, which remain fallback
|
||||
cache, and which are renderer-only hints.
|
||||
- Define `final_message_ref` as the settled transcript authority and
|
||||
`final_answer` as a derived render snapshot.
|
||||
- Add order-invariance coverage for live + replay + settlement observations of
|
||||
the same run.
|
||||
|
||||
### Phase 3: Settlement through anchor
|
||||
|
||||
- Reconcile `done` into the existing anchor.
|
||||
@@ -832,7 +869,11 @@ An implementation should eventually satisfy:
|
||||
transport, or explicitly excluded,
|
||||
- replay/reconnect of one run is idempotent at the existing
|
||||
`(session_id, event_id)` dedupe ring: no activity event is dropped or
|
||||
duplicated when the same run is observed live and then replayed.
|
||||
duplicated when the same run is observed live and then replayed,
|
||||
- replay/reconnect and settlement are order-invariant for one run: whether
|
||||
replay events arrive before or after the settled assistant message, the anchor
|
||||
reaches the same final answer reference, terminal state, usage metadata, and
|
||||
activity event list.
|
||||
|
||||
## Review Checklist For Implementation PRs
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Stable Assistant Turn Anchors scaffold (#3926).
|
||||
//
|
||||
// This file is intentionally inert: it defines the current ownership inventory,
|
||||
// event classifications, and small pure helpers, but it does not register
|
||||
// anchors or change any renderer. Later phases can wire these helpers into
|
||||
// event classifications, and small owner helpers, but it does not register
|
||||
// anchors globally or change any renderer. Later phases can wire these helpers into
|
||||
// send(), attachLiveStream(), replay hydration, and renderMessages().
|
||||
(function(){
|
||||
const ROOT=(typeof window!=='undefined')?window:globalThis;
|
||||
@@ -113,6 +113,40 @@
|
||||
'excluded',
|
||||
]);
|
||||
|
||||
const TERMINAL_STATES=Object.freeze({
|
||||
completed:'completed',
|
||||
cancelled:'cancelled',
|
||||
interrupted:'interrupted',
|
||||
no_response:'no_response',
|
||||
tool_limit_reached:'tool_limit_reached',
|
||||
compression_exhausted:'compression_exhausted',
|
||||
connection_lost:'connection_lost',
|
||||
degraded:'degraded',
|
||||
error:'error',
|
||||
});
|
||||
|
||||
const TERMINAL_STATE_ALIASES=Object.freeze({
|
||||
completed:TERMINAL_STATES.completed,
|
||||
complete:TERMINAL_STATES.completed,
|
||||
done:TERMINAL_STATES.completed,
|
||||
cancelled:TERMINAL_STATES.cancelled,
|
||||
canceled:TERMINAL_STATES.cancelled,
|
||||
cancel:TERMINAL_STATES.cancelled,
|
||||
interrupted:TERMINAL_STATES.interrupted,
|
||||
interrupted_by_user:TERMINAL_STATES.interrupted,
|
||||
no_response:TERMINAL_STATES.no_response,
|
||||
no_response_generated:TERMINAL_STATES.no_response,
|
||||
tool_limit_reached:TERMINAL_STATES.tool_limit_reached,
|
||||
max_iterations:TERMINAL_STATES.tool_limit_reached,
|
||||
compression_exhausted:TERMINAL_STATES.compression_exhausted,
|
||||
connection_lost:TERMINAL_STATES.connection_lost,
|
||||
lost_worker_bookkeeping:TERMINAL_STATES.connection_lost,
|
||||
degraded:TERMINAL_STATES.degraded,
|
||||
error:TERMINAL_STATES.error,
|
||||
failed:TERMINAL_STATES.error,
|
||||
apperror:TERMINAL_STATES.error,
|
||||
});
|
||||
|
||||
const UNSAFE_OBJECT_KEYS=Object.freeze([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
@@ -144,6 +178,20 @@
|
||||
return typeof value==='string'?value.trim():'';
|
||||
}
|
||||
|
||||
function _terminalStateKey(value){
|
||||
return _cleanString(value).toLowerCase().replace(/[\s-]+/g,'_');
|
||||
}
|
||||
|
||||
function normalizeAssistantTurnAnchorTerminalState(value, fallback){
|
||||
const key=_terminalStateKey(value);
|
||||
if(key&&_hasOwn(TERMINAL_STATE_ALIASES,key)) return TERMINAL_STATE_ALIASES[key];
|
||||
const fallbackKey=_terminalStateKey(fallback);
|
||||
if(fallbackKey&&_hasOwn(TERMINAL_STATE_ALIASES,fallbackKey)){
|
||||
return TERMINAL_STATE_ALIASES[fallbackKey];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _coercePayload(value){
|
||||
if(value==null) return {};
|
||||
if(typeof value==='string'){
|
||||
@@ -242,14 +290,16 @@
|
||||
}
|
||||
|
||||
function _statusForSourceEvent(sourceType, kind, payload){
|
||||
const explicit=_cleanString(payload&&(payload.status||payload.state||payload.phase));
|
||||
if(explicit) return explicit;
|
||||
const explicit=_cleanString(_firstOwn(payload,['status','state','phase']));
|
||||
if(explicit){
|
||||
return kind==='terminal_status'
|
||||
?normalizeAssistantTurnAnchorTerminalState(explicit,sourceType)||explicit
|
||||
:explicit;
|
||||
}
|
||||
if(kind==='tool_started') return 'running';
|
||||
if(kind==='tool_completed') return payload&&payload.is_error?'error':'completed';
|
||||
if(kind==='tool_completed') return _own(payload,'is_error')?'error':'completed';
|
||||
if(kind==='terminal_status'){
|
||||
if(sourceType==='done') return 'completed';
|
||||
if(sourceType==='cancel') return 'cancelled';
|
||||
return 'error';
|
||||
return normalizeAssistantTurnAnchorTerminalState(sourceType)||TERMINAL_STATES.error;
|
||||
}
|
||||
if(kind==='lifecycle_status') return 'running';
|
||||
if(kind==='control_boundary') return 'pending';
|
||||
@@ -280,7 +330,8 @@
|
||||
if(runId&&seq) return 'run_seq:'+JSON.stringify([runId,seq]);
|
||||
const sid=_cleanString(_own(event,'session_id'));
|
||||
const localId=_cleanString(_own(event,'local_id'));
|
||||
if(sid&&localId) return 'local:'+JSON.stringify([sid,localId]);
|
||||
const sourceType=_cleanString(_own(event,'source_event_type'))||'event';
|
||||
if(sid&&localId&&seq&&seq!=='pending') return 'local:'+JSON.stringify([sid,sourceType,localId,seq]);
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -374,6 +425,277 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function _copyObject(value){
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)) return {};
|
||||
return {...value};
|
||||
}
|
||||
|
||||
function _frozenIdentityCopy(identity){
|
||||
const refs=Array.isArray(identity&&identity.source_message_refs)
|
||||
?identity.source_message_refs.slice()
|
||||
:[];
|
||||
return Object.freeze({
|
||||
..._copyObject(identity),
|
||||
source_message_refs:Object.freeze(refs),
|
||||
});
|
||||
}
|
||||
|
||||
function _registryAnchor(registry){
|
||||
return registry&&typeof registry==='object'&®istry.anchor&&typeof registry.anchor==='object'
|
||||
?registry.anchor
|
||||
:null;
|
||||
}
|
||||
|
||||
function _registryContext(registry, context){
|
||||
const anchor=_registryAnchor(registry);
|
||||
const identity=anchor&&anchor.identity?anchor.identity:{};
|
||||
return {
|
||||
..._copyObject(context),
|
||||
session_id:_cleanString(_own(identity,'session_id'))||_cleanString(_own(context,'session_id')),
|
||||
turn_id:_cleanString(_own(identity,'turn_id'))||_cleanString(_own(context,'turn_id')),
|
||||
run_id:_cleanString(_own(identity,'run_id'))||_cleanString(_own(context,'run_id')),
|
||||
stream_id:_cleanString(_own(identity,'stream_id'))||_cleanString(_own(context,'stream_id')),
|
||||
};
|
||||
}
|
||||
|
||||
function _eventBelongsToAnchor(anchor, event){
|
||||
const identity=anchor.identity||{};
|
||||
const sessionId=_cleanString(_own(event,'session_id'));
|
||||
const identitySessionId=_cleanString(_own(identity,'session_id'));
|
||||
if(sessionId&&identitySessionId&&sessionId!==identitySessionId) return false;
|
||||
const turnId=_cleanString(_own(event,'turn_id'));
|
||||
const identityTurnId=_cleanString(_own(identity,'turn_id'));
|
||||
if(turnId&&identityTurnId&&turnId!==identityTurnId) return false;
|
||||
const runId=_cleanString(_own(event,'run_id'));
|
||||
const identityRunId=_cleanString(_own(identity,'run_id'));
|
||||
if(runId&&identityRunId&&runId!==identityRunId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _ensureDedupeKeySet(eventIndex){
|
||||
const existing=eventIndex.dedupe_key_set;
|
||||
if(existing instanceof Set) return existing;
|
||||
const set=new Set(Array.isArray(eventIndex.dedupe_keys)?eventIndex.dedupe_keys:[]);
|
||||
Object.defineProperty(eventIndex,'dedupe_key_set',{
|
||||
value:set,
|
||||
enumerable:false,
|
||||
configurable:true,
|
||||
writable:true,
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
function _ensureRegistryShape(registry){
|
||||
const anchor=_registryAnchor(registry);
|
||||
if(!anchor) throw new Error('assistant turn anchor registry requires anchor');
|
||||
registry.event_index=registry.event_index&&typeof registry.event_index==='object'
|
||||
?registry.event_index
|
||||
:{};
|
||||
if(!Array.isArray(registry.event_index.dedupe_keys)) registry.event_index.dedupe_keys=[];
|
||||
registry.stats=registry.stats&&typeof registry.stats==='object'?registry.stats:{};
|
||||
registry.stats.applied=Number(registry.stats.applied)||0;
|
||||
registry.stats.skipped_duplicate=Number(registry.stats.skipped_duplicate)||0;
|
||||
registry.stats.skipped_excluded=Number(registry.stats.skipped_excluded)||0;
|
||||
registry.stats.skipped_mismatched=Number(registry.stats.skipped_mismatched)||0;
|
||||
if(!Array.isArray(anchor.metadata_events)) anchor.metadata_events=[];
|
||||
if(!Array.isArray(anchor.transport_events)) anchor.transport_events=[];
|
||||
_ensureDedupeKeySet(registry.event_index);
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function _syncAnchorIdentity(anchor, event){
|
||||
const identity=anchor.identity||{};
|
||||
const runId=_cleanString(_own(event,'run_id'));
|
||||
const streamId=_cleanString(_own(event,'stream_id'));
|
||||
if(!_cleanString(_own(identity,'run_id'))&&runId) identity.run_id=runId;
|
||||
if(!_cleanString(_own(identity,'stream_id'))&&streamId) identity.stream_id=streamId;
|
||||
}
|
||||
|
||||
function _textFromContentValue(value){
|
||||
if(typeof value==='string') return value;
|
||||
if(Array.isArray(value)){
|
||||
return value.map((item)=>{
|
||||
if(typeof item==='string') return item;
|
||||
if(!item||typeof item!=='object') return '';
|
||||
const text=_firstOwn(item,['text','content']);
|
||||
return typeof text==='string'?text:'';
|
||||
}).join('');
|
||||
}
|
||||
if(value&&typeof value==='object'){
|
||||
const text=_firstOwn(value,['text','content']);
|
||||
return typeof text==='string'?text:'';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _firstTextValue(...values){
|
||||
for(let i=0;i<values.length;i+=1){
|
||||
const value=_textFromContentValue(values[i]);
|
||||
if(value.length>0) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _messageRefFromPayload(payload, event){
|
||||
return _firstTextValue(
|
||||
_firstOwn(payload,['message_id','id','local_id']),
|
||||
_firstOwn(event,['local_id','event_id'])
|
||||
)||null;
|
||||
}
|
||||
|
||||
function _updateLifecycleFromEvent(anchor, event){
|
||||
const lifecycle=anchor.lifecycle||{};
|
||||
const createdAt=_own(event,'created_at');
|
||||
const status=_cleanString(_own(event,'status'));
|
||||
const kind=_cleanString(_own(event,'kind'));
|
||||
if(!lifecycle.started_at&&createdAt) lifecycle.started_at=createdAt;
|
||||
if((!lifecycle.status||lifecycle.status==='created')&&status==='running'){
|
||||
lifecycle.status='running';
|
||||
}
|
||||
if(kind==='terminal_status'){
|
||||
const terminal=normalizeAssistantTurnAnchorTerminalState(
|
||||
status,
|
||||
_own(event,'source_event_type')
|
||||
)||TERMINAL_STATES.completed;
|
||||
lifecycle.status=terminal;
|
||||
lifecycle.terminal_state=terminal;
|
||||
lifecycle.completed_at=createdAt||lifecycle.completed_at||null;
|
||||
}
|
||||
anchor.lifecycle=lifecycle;
|
||||
}
|
||||
|
||||
function _updateContentFromMetadata(anchor, event){
|
||||
const payload=_own(event,'payload')||{};
|
||||
const sourceType=_cleanString(_own(event,'source_event_type'));
|
||||
if(sourceType==='usage'){
|
||||
anchor.usage=_copyObject(payload);
|
||||
return;
|
||||
}
|
||||
if(sourceType!=='settled_message') return;
|
||||
const role=_cleanString(_own(payload,'role'));
|
||||
if(role&&role!=='assistant') return;
|
||||
const finalAnswer=_firstTextValue(
|
||||
_own(payload,'content'),
|
||||
_own(payload,'text'),
|
||||
_own(payload,'final_answer'),
|
||||
_own(payload,'answer')
|
||||
);
|
||||
if(finalAnswer){
|
||||
anchor.content=anchor.content||{};
|
||||
anchor.content.final_answer=finalAnswer;
|
||||
anchor.content.final_message_ref=_messageRefFromPayload(payload,event);
|
||||
}
|
||||
const usage=_own(payload,'usage');
|
||||
const turnUsage=_own(payload,'_turnUsage');
|
||||
if(usage&&typeof usage==='object') anchor.usage=_copyObject(usage);
|
||||
if(turnUsage&&typeof turnUsage==='object') anchor.usage=_copyObject(turnUsage);
|
||||
}
|
||||
|
||||
function _routeAnchorEvent(anchor, normalized){
|
||||
const event=_own(normalized,'anchor_event');
|
||||
const classification=_cleanString(_own(normalized,'classification'));
|
||||
if(classification==='activity'){
|
||||
anchor.activity_events.push(event);
|
||||
_updateLifecycleFromEvent(anchor,event);
|
||||
}else if(classification==='artifact'){
|
||||
anchor.artifacts.push(event);
|
||||
}else if(classification==='side_effect'){
|
||||
anchor.side_effects.push(event);
|
||||
}else if(classification==='metadata'){
|
||||
anchor.metadata_events.push(event);
|
||||
_updateContentFromMetadata(anchor,event);
|
||||
}else if(classification==='transport'){
|
||||
anchor.transport_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAssistantTurnAnchorNormalizedEvent(registry, normalized){
|
||||
const anchor=_ensureRegistryShape(registry);
|
||||
const item=(normalized&&typeof normalized==='object')?normalized:{};
|
||||
const event=_own(item,'anchor_event');
|
||||
if(!event){
|
||||
registry.stats.skipped_excluded+=1;
|
||||
return Object.freeze({applied:false,reason:'excluded',normalized:item});
|
||||
}
|
||||
if(!_eventBelongsToAnchor(anchor,event)){
|
||||
registry.stats.skipped_mismatched+=1;
|
||||
return Object.freeze({applied:false,reason:'mismatched_anchor',normalized:item});
|
||||
}
|
||||
const dedupeKey=_cleanString(_own(item,'dedupe_key'))||assistantTurnAnchorEventDedupeKey(event);
|
||||
const dedupeKeySet=_ensureDedupeKeySet(registry.event_index);
|
||||
if(dedupeKey&&dedupeKeySet.has(dedupeKey)){
|
||||
registry.stats.skipped_duplicate+=1;
|
||||
return Object.freeze({applied:false,reason:'duplicate',normalized:item});
|
||||
}
|
||||
if(dedupeKey){
|
||||
dedupeKeySet.add(dedupeKey);
|
||||
registry.event_index.dedupe_keys.push(dedupeKey);
|
||||
}
|
||||
_syncAnchorIdentity(anchor,event);
|
||||
_routeAnchorEvent(anchor,item);
|
||||
registry.stats.applied+=1;
|
||||
return Object.freeze({applied:true,reason:null,normalized:item});
|
||||
}
|
||||
|
||||
function applyAssistantTurnAnchorSourceEvent(registry, input, context){
|
||||
const normalized=normalizeAssistantTurnAnchorSourceEvent(input,_registryContext(registry,context));
|
||||
return applyAssistantTurnAnchorNormalizedEvent(registry,normalized);
|
||||
}
|
||||
|
||||
function applyAssistantTurnAnchorSourceEvents(registry, events, context){
|
||||
const list=Array.isArray(events)?events:[];
|
||||
return list.map((event)=>applyAssistantTurnAnchorSourceEvent(registry,event,context));
|
||||
}
|
||||
|
||||
function _eventsForShadowSource(sources, primaryKey, fallbackKey){
|
||||
if(!sources||typeof sources!=='object') return [];
|
||||
const primary=sources[primaryKey];
|
||||
if(Array.isArray(primary)) return primary;
|
||||
const fallback=fallbackKey?sources[fallbackKey]:null;
|
||||
return Array.isArray(fallback)?fallback:[];
|
||||
}
|
||||
|
||||
function _shadowSourceContext(context, sourceLayer){
|
||||
return {
|
||||
..._copyObject(context),
|
||||
source_layer:sourceLayer,
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantTurnAnchorShadowSnapshot(input){
|
||||
const opts=(input&&typeof input==='object')?input:{};
|
||||
const anchorInput=(opts.anchor&&typeof opts.anchor==='object')?opts.anchor:opts;
|
||||
const sources=(opts.sources&&typeof opts.sources==='object')?opts.sources:opts;
|
||||
const context=(opts.context&&typeof opts.context==='object')?opts.context:{};
|
||||
const registry=createAssistantTurnAnchorRegistry(anchorInput);
|
||||
const results={
|
||||
live:applyAssistantTurnAnchorSourceEvents(
|
||||
registry,
|
||||
_eventsForShadowSource(sources,'live_events'),
|
||||
_shadowSourceContext(context,'live')
|
||||
),
|
||||
replay:applyAssistantTurnAnchorSourceEvents(
|
||||
registry,
|
||||
_eventsForShadowSource(sources,'replay_events','run_journal_events'),
|
||||
_shadowSourceContext(context,'replay')
|
||||
),
|
||||
settled:applyAssistantTurnAnchorSourceEvents(
|
||||
registry,
|
||||
_eventsForShadowSource(sources,'settled_events'),
|
||||
_shadowSourceContext(context,'settled')
|
||||
),
|
||||
inflight:applyAssistantTurnAnchorSourceEvents(
|
||||
registry,
|
||||
_eventsForShadowSource(sources,'inflight_events'),
|
||||
_shadowSourceContext(context,'inflight')
|
||||
),
|
||||
};
|
||||
return Object.freeze({
|
||||
registry,
|
||||
results:Object.freeze(results),
|
||||
});
|
||||
}
|
||||
|
||||
function createAssistantTurnAnchorSeed(input){
|
||||
const opts=(input&&typeof input==='object')?input:{};
|
||||
const sessionId=_cleanString(opts.session_id);
|
||||
@@ -407,26 +729,49 @@
|
||||
activity_events:[],
|
||||
artifacts:[],
|
||||
side_effects:[],
|
||||
metadata_events:[],
|
||||
transport_events:[],
|
||||
usage:null,
|
||||
presentation_state:{
|
||||
compact_worklog:{expanded:false},
|
||||
transparent_stream:{expanded:false},
|
||||
scroll:{follow:true},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantTurnAnchorRegistry(input){
|
||||
const anchor=createAssistantTurnAnchorSeed(input);
|
||||
const registry={
|
||||
identity:_frozenIdentityCopy(anchor.identity),
|
||||
anchor,
|
||||
event_index:{
|
||||
dedupe_keys:[],
|
||||
},
|
||||
stats:{
|
||||
applied:0,
|
||||
skipped_duplicate:0,
|
||||
skipped_excluded:0,
|
||||
skipped_mismatched:0,
|
||||
},
|
||||
};
|
||||
_ensureDedupeKeySet(registry.event_index);
|
||||
return registry;
|
||||
}
|
||||
|
||||
ROOT.HermesAssistantTurnAnchors=Object.freeze({
|
||||
version:'slice2-normalizer',
|
||||
version:'slice3-registry-shadow',
|
||||
activityEventKinds:ACTIVITY_EVENT_KINDS,
|
||||
stateLayers:STATE_LAYERS,
|
||||
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
|
||||
classificationOrder:CLASSIFICATION_ORDER,
|
||||
terminalStates:TERMINAL_STATES,
|
||||
createAssistantTurnAnchorSeed,
|
||||
normalizeAssistantTurnAnchorTerminalState,
|
||||
assistantTurnAnchorEventDedupeKey,
|
||||
classifyAssistantTurnAnchorSourceEvent,
|
||||
normalizeAssistantTurnAnchorSourceEvent,
|
||||
normalizeAssistantTurnAnchorSourceEvents,
|
||||
createAssistantTurnAnchorRegistry,
|
||||
applyAssistantTurnAnchorNormalizedEvent,
|
||||
applyAssistantTurnAnchorSourceEvent,
|
||||
applyAssistantTurnAnchorSourceEvents,
|
||||
createAssistantTurnAnchorShadowSnapshot,
|
||||
isAssistantTurnAnchorActivityKind,
|
||||
});
|
||||
})();
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Slice 2 normalizer tests for Stable Assistant Turn Anchors (#3926)."""
|
||||
"""Normalizer tests for Stable Assistant Turn Anchors (#3926)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -144,8 +144,9 @@ const inheritedContextIdentity = api.normalizeAssistantTurnAnchorSourceEvent({{
|
||||
}}, inheritedContext);
|
||||
const runSeqCollisionA = api.assistantTurnAnchorEventDedupeKey({{run_id:'a:b', seq:'c'}});
|
||||
const runSeqCollisionB = api.assistantTurnAnchorEventDedupeKey({{run_id:'a', seq:'b:c'}});
|
||||
const localCollisionA = api.assistantTurnAnchorEventDedupeKey({{session_id:'a:b', local_id:'c'}});
|
||||
const localCollisionB = api.assistantTurnAnchorEventDedupeKey({{session_id:'a', local_id:'b:c'}});
|
||||
const localCollisionA = api.assistantTurnAnchorEventDedupeKey({{session_id:'a:b', source_event_type:'tool', local_id:'c', seq:'d'}});
|
||||
const localCollisionB = api.assistantTurnAnchorEventDedupeKey({{session_id:'a', source_event_type:'b:tool', local_id:'c', seq:'d'}});
|
||||
const localNoSeqKey = api.assistantTurnAnchorEventDedupeKey({{session_id:'a', source_event_type:'token', local_id:'b'}});
|
||||
console.log(JSON.stringify({{
|
||||
version: api.version,
|
||||
liveToken,
|
||||
@@ -168,6 +169,7 @@ console.log(JSON.stringify({{
|
||||
runSeqCollisionB,
|
||||
localCollisionA,
|
||||
localCollisionB,
|
||||
localNoSeqKey,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
||||
@@ -180,7 +182,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity():
|
||||
live = data["liveToken"]
|
||||
replay = data["replayToken"]
|
||||
|
||||
assert data["version"] == "slice2-normalizer"
|
||||
assert data["version"] == "slice3-registry-shadow"
|
||||
assert live["classification"] == "activity"
|
||||
assert live["dedupe_key"] == 'event_id:"run-1:7"'
|
||||
assert replay["dedupe_key"] == live["dedupe_key"]
|
||||
@@ -316,15 +318,19 @@ def test_structured_fallback_dedupe_keys_do_not_collide_on_delimiters():
|
||||
assert data["runSeqCollisionA"] == 'run_seq:["a:b","c"]'
|
||||
assert data["runSeqCollisionB"] == 'run_seq:["a","b:c"]'
|
||||
assert data["runSeqCollisionA"] != data["runSeqCollisionB"]
|
||||
assert data["localCollisionA"] == 'local:["a:b","c"]'
|
||||
assert data["localCollisionB"] == 'local:["a","b:c"]'
|
||||
assert data["localCollisionA"] == 'local:["a:b","tool","c","d"]'
|
||||
assert data["localCollisionB"] == 'local:["a","b:tool","c","d"]'
|
||||
assert data["localCollisionA"] != data["localCollisionB"]
|
||||
assert data["localNoSeqKey"] == ""
|
||||
|
||||
|
||||
def test_slice2_normalizer_is_still_unwired_from_rendering_hot_paths():
|
||||
def test_normalizer_and_registry_helpers_are_still_unwired_from_rendering_hot_paths():
|
||||
helper_names = [
|
||||
"normalizeAssistantTurnAnchorSourceEvent",
|
||||
"normalizeAssistantTurnAnchorSourceEvents",
|
||||
"createAssistantTurnAnchorRegistry",
|
||||
"applyAssistantTurnAnchorSourceEvent",
|
||||
"applyAssistantTurnAnchorSourceEvents",
|
||||
]
|
||||
for helper in helper_names:
|
||||
assert helper not in _read(UI_JS)
|
||||
|
||||
@@ -48,12 +48,23 @@ const out = {{
|
||||
layers: api.stateLayers,
|
||||
classifications: api.sourceEventClassification,
|
||||
classificationOrder: api.classificationOrder,
|
||||
terminalStates: api.terminalStates,
|
||||
terminalAliases: {{
|
||||
done: api.normalizeAssistantTurnAnchorTerminalState('done'),
|
||||
cancel: api.normalizeAssistantTurnAnchorTerminalState('cancel'),
|
||||
apperror: api.normalizeAssistantTurnAnchorTerminalState('apperror'),
|
||||
interruptedByUser: api.normalizeAssistantTurnAnchorTerminalState('interrupted-by-user'),
|
||||
lostBookkeeping: api.normalizeAssistantTurnAnchorTerminalState('lost_worker_bookkeeping'),
|
||||
maxIterations: api.normalizeAssistantTurnAnchorTerminalState('max_iterations'),
|
||||
unknown: api.normalizeAssistantTurnAnchorTerminalState('unknown'),
|
||||
}},
|
||||
tokenKind: api.classifyAssistantTurnAnchorSourceEvent('token').kind,
|
||||
streamEndClass: api.classifyAssistantTurnAnchorSourceEvent('stream_end').classification,
|
||||
unknownClass: api.classifyAssistantTurnAnchorSourceEvent('unknown_future').classification,
|
||||
eventIdKey: api.assistantTurnAnchorEventDedupeKey({{event_id:'run-1:2', text:'same'}}),
|
||||
runSeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:2, timestamp:123}}),
|
||||
localKey: api.assistantTurnAnchorEventDedupeKey({{session_id:'sid-1', local_id:'local-1', content:'ignored'}}),
|
||||
localKey: api.assistantTurnAnchorEventDedupeKey({{session_id:'sid-1', source_event_type:'token', local_id:'local-1', seq:2, content:'ignored'}}),
|
||||
localNoSeqKey: api.assistantTurnAnchorEventDedupeKey({{session_id:'sid-1', source_event_type:'token', local_id:'local-1', content:'ignored'}}),
|
||||
zeroSeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:0, session_id:'sid-1', local_id:'local-1'}}),
|
||||
nanSeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:NaN, session_id:'sid-1', local_id:'local-1'}}),
|
||||
emptySeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:'', session_id:'sid-1', local_id:'local-1'}}),
|
||||
@@ -146,10 +157,11 @@ def test_phase0_dedupe_prefers_event_envelope_not_visible_text_or_timestamps():
|
||||
data = _anchor_api_snapshot()
|
||||
assert data["eventIdKey"] == 'event_id:"run-1:2"'
|
||||
assert data["runSeqKey"] == 'run_seq:["run-1","2"]'
|
||||
assert data["localKey"] == 'local:["sid-1","local-1"]'
|
||||
assert data["localKey"] == 'local:["sid-1","token","local-1","2"]'
|
||||
assert data["localNoSeqKey"] == ""
|
||||
assert data["zeroSeqKey"] == 'run_seq:["run-1","0"]'
|
||||
assert data["nanSeqKey"] == 'run_seq:["run-1","NaN"]'
|
||||
assert data["emptySeqKey"] == 'local:["sid-1","local-1"]'
|
||||
assert data["emptySeqKey"] == ""
|
||||
assert data["emptyKey"] == ""
|
||||
|
||||
helper_src = _read(ANCHORS_JS).split("function assistantTurnAnchorEventDedupeKey", 1)[1]
|
||||
@@ -163,6 +175,30 @@ def test_phase0_dedupe_prefers_event_envelope_not_visible_text_or_timestamps():
|
||||
assert "created_at" not in helper_src
|
||||
|
||||
|
||||
def test_phase0_exports_terminal_state_contract_and_aliases():
|
||||
data = _anchor_api_snapshot()
|
||||
assert data["terminalStates"] == {
|
||||
"completed": "completed",
|
||||
"cancelled": "cancelled",
|
||||
"interrupted": "interrupted",
|
||||
"no_response": "no_response",
|
||||
"tool_limit_reached": "tool_limit_reached",
|
||||
"compression_exhausted": "compression_exhausted",
|
||||
"connection_lost": "connection_lost",
|
||||
"degraded": "degraded",
|
||||
"error": "error",
|
||||
}
|
||||
assert data["terminalAliases"] == {
|
||||
"done": "completed",
|
||||
"cancel": "cancelled",
|
||||
"apperror": "error",
|
||||
"interruptedByUser": "interrupted",
|
||||
"lostBookkeeping": "connection_lost",
|
||||
"maxIterations": "tool_limit_reached",
|
||||
"unknown": None,
|
||||
}
|
||||
|
||||
|
||||
def test_phase0_anchor_seed_matches_rfc_shape_without_registering_state():
|
||||
data = _anchor_api_snapshot()
|
||||
anchor = data["anchor"]
|
||||
@@ -174,8 +210,7 @@ def test_phase0_anchor_seed_matches_rfc_shape_without_registering_state():
|
||||
assert anchor["activity_events"] == []
|
||||
assert anchor["artifacts"] == []
|
||||
assert anchor["side_effects"] == []
|
||||
assert anchor["presentation_state"]["compact_worklog"]["expanded"] is False
|
||||
assert anchor["presentation_state"]["transparent_stream"]["expanded"] is False
|
||||
assert "presentation_state" not in anchor
|
||||
|
||||
|
||||
def test_phase0_inventory_doc_matches_scaffold_contract():
|
||||
@@ -191,6 +226,6 @@ def test_phase0_inventory_doc_matches_scaffold_contract():
|
||||
"Dedupe Invariant",
|
||||
"`event_id`",
|
||||
"`run_id + seq`",
|
||||
"`session_id + local_id`",
|
||||
"`session_id + source_event_type + local_id + seq`",
|
||||
]:
|
||||
assert marker in doc
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Slice 3 registry tests for Stable Assistant Turn Anchors (#3926)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
ANCHORS_JS = REPO / "static" / "assistant_turn_anchors.js"
|
||||
MESSAGES_JS = REPO / "static" / "messages.js"
|
||||
UI_JS = REPO / "static" / "ui.js"
|
||||
SESSIONS_JS = REPO / "static" / "sessions.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _registry_snapshot() -> dict:
|
||||
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const src = fs.readFileSync({json.dumps(str(ANCHORS_JS))}, 'utf8');
|
||||
const sandbox = {{window:{{}}}};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
|
||||
const api = sandbox.window.HermesAssistantTurnAnchors;
|
||||
const registry = api.createAssistantTurnAnchorRegistry({{
|
||||
session_id:'sid-1',
|
||||
turn_id:'turn-1',
|
||||
}});
|
||||
const results = api.applyAssistantTurnAnchorSourceEvents(registry, [
|
||||
{{type:'token', data:'{{"text":"live token"}}', lastEventId:'run-1:1', created_at:'2026-06-11T00:00:01Z'}},
|
||||
{{event:'token', payload:{{text:'replay token'}}, event_id:'run-1:1', seq:1}},
|
||||
{{event:'reasoning', payload:{{text:'thinking'}}, event_id:'run-1:2', seq:2}},
|
||||
{{event:'artifact_reference', payload:{{path:'answer.txt', kind:'workspace_file'}}, event_id:'run-1:3', seq:3}},
|
||||
{{event:'state_saved', payload:{{kind:'memory', name:'session-state'}}, event_id:'run-1:4', seq:4}},
|
||||
{{event:'stream_end', payload:{{}}, event_id:'run-1:5', seq:5}},
|
||||
{{event:'done', payload:{{}}, event_id:'run-1:6', seq:6, created_at:'2026-06-11T00:00:06Z'}},
|
||||
{{source_type:'settled_message', payload:{{role:'assistant', id:'message-final', content:'final answer', _turnUsage:{{input_tokens:8, output_tokens:13}}}}}},
|
||||
], {{run_id:'run-1', stream_id:'stream-1'}});
|
||||
|
||||
const isolated = api.createAssistantTurnAnchorRegistry({{session_id:'sid-1', turn_id:'turn-2'}});
|
||||
api.applyAssistantTurnAnchorSourceEvent(registry, {{
|
||||
event:'token',
|
||||
payload:{{text:'wrong session', session_id:'sid-2'}},
|
||||
event_id:'run-1:7',
|
||||
seq:7,
|
||||
}}, {{run_id:'run-1'}});
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
version:api.version,
|
||||
registry,
|
||||
isolated,
|
||||
results:results.map((item)=>({{applied:item.applied, reason:item.reason}})),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _shadow_snapshot() -> dict:
|
||||
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const src = fs.readFileSync({json.dumps(str(ANCHORS_JS))}, 'utf8');
|
||||
const sandbox = {{window:{{}}}};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
|
||||
const api = sandbox.window.HermesAssistantTurnAnchors;
|
||||
const shadow = api.createAssistantTurnAnchorShadowSnapshot({{
|
||||
anchor:{{
|
||||
session_id:'sid-shadow',
|
||||
turn_id:'turn-shadow',
|
||||
}},
|
||||
context:{{
|
||||
run_id:'run-shadow',
|
||||
stream_id:'stream-shadow',
|
||||
}},
|
||||
sources:{{
|
||||
live_events:[
|
||||
{{type:'token', data:'{{"text":"live token"}}', lastEventId:'run-shadow:1', created_at:'2026-06-11T00:00:01Z'}},
|
||||
],
|
||||
replay_events:[
|
||||
{{event:'token', payload:{{text:'replay duplicate'}}, event_id:'run-shadow:1', seq:1}},
|
||||
{{event:'tool_complete', payload:{{tool_call_id:'tool-1', result:'ok'}}, event_id:'run-shadow:2', seq:2}},
|
||||
],
|
||||
settled_events:[
|
||||
{{source_type:'settled_message', payload:{{role:'assistant', id:'message-shadow', content:'shadow final'}}}},
|
||||
],
|
||||
inflight_events:[
|
||||
{{source_type:'inflight_snapshot', payload:{{status:'restoring'}}}},
|
||||
],
|
||||
}},
|
||||
}});
|
||||
console.log(JSON.stringify({{
|
||||
version:api.version,
|
||||
registry:shadow.registry,
|
||||
results:Object.fromEntries(Object.entries(shadow.results).map(([key, value]) => [
|
||||
key,
|
||||
value.map((item)=>({{applied:item.applied, reason:item.reason}})),
|
||||
])),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _hardening_snapshot() -> dict:
|
||||
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const src = fs.readFileSync({json.dumps(str(ANCHORS_JS))}, 'utf8');
|
||||
const sandbox = {{window:{{}}}};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
|
||||
const api = sandbox.window.HermesAssistantTurnAnchors;
|
||||
|
||||
const toolRegistry = api.createAssistantTurnAnchorRegistry({{
|
||||
session_id:'sid-tool',
|
||||
turn_id:'turn-tool',
|
||||
}});
|
||||
const toolResults = api.applyAssistantTurnAnchorSourceEvents(toolRegistry, [
|
||||
{{event:'tool', payload:{{tool_call_id:'call-1', name:'shell'}}}},
|
||||
{{event:'tool_update', payload:{{tool_call_id:'call-1', text:'running'}}}},
|
||||
{{event:'tool_complete', payload:{{tool_call_id:'call-1', result:'ok'}}}},
|
||||
{{event:'token', payload:{{text:'first token'}}}},
|
||||
{{event:'token', payload:{{text:'second token'}}}},
|
||||
]);
|
||||
|
||||
const runRegistry = api.createAssistantTurnAnchorRegistry({{
|
||||
session_id:'sid-run',
|
||||
turn_id:'turn-run',
|
||||
run_id:'run-a',
|
||||
stream_id:'stream-a',
|
||||
}});
|
||||
const runMismatch = api.applyAssistantTurnAnchorSourceEvent(runRegistry, {{
|
||||
event:'token',
|
||||
payload:{{text:'wrong run'}},
|
||||
event_id:'run-b:1',
|
||||
run_id:'run-b',
|
||||
seq:1,
|
||||
}});
|
||||
const streamAccepted = api.applyAssistantTurnAnchorSourceEvent(runRegistry, {{
|
||||
event:'reasoning',
|
||||
payload:{{text:'same run new stream'}},
|
||||
event_id:'run-a:2',
|
||||
run_id:'run-a',
|
||||
stream_id:'stream-b',
|
||||
seq:2,
|
||||
}});
|
||||
|
||||
const identityRegistry = api.createAssistantTurnAnchorRegistry({{
|
||||
session_id:'sid-freeze',
|
||||
turn_id:'turn-freeze',
|
||||
}});
|
||||
identityRegistry.identity.session_id = 'mutated';
|
||||
|
||||
const metadataRegistry = api.createAssistantTurnAnchorRegistry({{
|
||||
session_id:'sid-meta',
|
||||
turn_id:'turn-meta',
|
||||
}});
|
||||
const inheritedPayload = Object.create({{
|
||||
role:'assistant',
|
||||
content:'inherited final should be ignored',
|
||||
id:'inherited-message',
|
||||
_turnUsage:{{input_tokens:99, output_tokens:99}},
|
||||
}});
|
||||
api.applyAssistantTurnAnchorNormalizedEvent(metadataRegistry, {{
|
||||
classification:'metadata',
|
||||
anchor_event:{{
|
||||
session_id:'sid-meta',
|
||||
turn_id:'turn-meta',
|
||||
source_event_type:'settled_message',
|
||||
local_id:'meta-1',
|
||||
payload:inheritedPayload,
|
||||
}},
|
||||
}});
|
||||
api.applyAssistantTurnAnchorNormalizedEvent(metadataRegistry, {{
|
||||
classification:'metadata',
|
||||
anchor_event:{{
|
||||
session_id:'sid-meta',
|
||||
turn_id:'turn-meta',
|
||||
source_event_type:'settled_message',
|
||||
local_id:'meta-2',
|
||||
payload:{{
|
||||
role:'assistant',
|
||||
id:'message-structured',
|
||||
content:[
|
||||
{{type:'text', text:'structured '}},
|
||||
{{type:'text', text:'answer'}},
|
||||
],
|
||||
usage:{{input_tokens:1, output_tokens:1}},
|
||||
_turnUsage:{{input_tokens:8, output_tokens:13}},
|
||||
}},
|
||||
}},
|
||||
}});
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
version:api.version,
|
||||
toolRegistry,
|
||||
toolResults:toolResults.map((item)=>({{applied:item.applied, reason:item.reason}})),
|
||||
runRegistry,
|
||||
runMismatch:{{applied:runMismatch.applied, reason:runMismatch.reason}},
|
||||
streamAccepted:{{applied:streamAccepted.applied, reason:streamAccepted.reason}},
|
||||
identityRegistry,
|
||||
metadataRegistry,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _race_snapshot() -> dict:
|
||||
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const src = fs.readFileSync({json.dumps(str(ANCHORS_JS))}, 'utf8');
|
||||
const sandbox = {{window:{{}}}};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
|
||||
const api = sandbox.window.HermesAssistantTurnAnchors;
|
||||
|
||||
function build(order) {{
|
||||
const registry = api.createAssistantTurnAnchorRegistry({{
|
||||
session_id:'sid-race',
|
||||
turn_id:'turn-race',
|
||||
run_id:'run-race',
|
||||
stream_id:'stream-race',
|
||||
}});
|
||||
const live = [
|
||||
{{event:'token', payload:{{text:'live token'}}, event_id:'run-race:1', run_id:'run-race', seq:1}},
|
||||
];
|
||||
const replay = [
|
||||
{{event:'token', payload:{{text:'replayed duplicate'}}, event_id:'run-race:1', run_id:'run-race', seq:1}},
|
||||
{{event:'tool_complete', payload:{{tool_call_id:'tool-1', result:'ok'}}, event_id:'run-race:2', run_id:'run-race', seq:2}},
|
||||
{{event:'done', payload:{{status:'done'}}, event_id:'run-race:3', run_id:'run-race', seq:3, created_at:'2026-06-11T00:00:03Z'}},
|
||||
];
|
||||
const settled = [
|
||||
{{source_type:'settled_message', payload:{{role:'assistant', id:'message-race', content:'race final', _turnUsage:{{input_tokens:5, output_tokens:8}}}}}},
|
||||
];
|
||||
api.applyAssistantTurnAnchorSourceEvents(registry, live);
|
||||
if (order === 'replay-first') {{
|
||||
api.applyAssistantTurnAnchorSourceEvents(registry, replay);
|
||||
api.applyAssistantTurnAnchorSourceEvents(registry, settled);
|
||||
}} else {{
|
||||
api.applyAssistantTurnAnchorSourceEvents(registry, settled);
|
||||
api.applyAssistantTurnAnchorSourceEvents(registry, replay);
|
||||
}}
|
||||
const anchor = registry.anchor;
|
||||
return {{
|
||||
stats: registry.stats,
|
||||
dedupe_keys: registry.event_index.dedupe_keys,
|
||||
activity: anchor.activity_events.map((event) => ({{
|
||||
event_id: event.event_id,
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
text: event.payload && event.payload.text || null,
|
||||
tool_call_id: event.payload && event.payload.tool_call_id || null,
|
||||
}})),
|
||||
terminal_state: anchor.lifecycle.terminal_state,
|
||||
final_answer: anchor.content.final_answer,
|
||||
final_message_ref: anchor.content.final_message_ref,
|
||||
usage: anchor.usage,
|
||||
}};
|
||||
}}
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
replayFirst: build('replay-first'),
|
||||
settledFirst: build('settled-first'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events():
|
||||
data = _registry_snapshot()
|
||||
registry = data["registry"]
|
||||
anchor = registry["anchor"]
|
||||
|
||||
assert data["version"] == "slice3-registry-shadow"
|
||||
assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
|
||||
assert registry["event_index"]["dedupe_keys"][:2] == [
|
||||
'event_id:"run-1:1"',
|
||||
'event_id:"run-1:2"',
|
||||
]
|
||||
assert registry["stats"]["applied"] == 7
|
||||
assert registry["stats"]["skipped_duplicate"] == 1
|
||||
assert registry["stats"]["skipped_mismatched"] == 1
|
||||
|
||||
assert anchor["identity"]["session_id"] == "sid-1"
|
||||
assert anchor["identity"]["turn_id"] == "turn-1"
|
||||
assert anchor["identity"]["run_id"] == "run-1"
|
||||
assert anchor["identity"]["stream_id"] == "stream-1"
|
||||
assert [event["kind"] for event in anchor["activity_events"]] == [
|
||||
"process_prose",
|
||||
"reasoning",
|
||||
"terminal_status",
|
||||
]
|
||||
assert anchor["activity_events"][0]["payload"] == {"text": "live token"}
|
||||
|
||||
|
||||
def test_registry_routes_activity_artifacts_side_effects_metadata_and_transport():
|
||||
data = _registry_snapshot()
|
||||
anchor = data["registry"]["anchor"]
|
||||
|
||||
assert len(anchor["artifacts"]) == 1
|
||||
assert anchor["artifacts"][0]["source_event_type"] == "artifact_reference"
|
||||
assert anchor["artifacts"][0]["payload"] == {
|
||||
"kind": "workspace_file",
|
||||
"path": "answer.txt",
|
||||
}
|
||||
assert len(anchor["side_effects"]) == 1
|
||||
assert anchor["side_effects"][0]["source_event_type"] == "state_saved"
|
||||
assert len(anchor["metadata_events"]) == 1
|
||||
assert anchor["metadata_events"][0]["source_event_type"] == "settled_message"
|
||||
assert len(anchor["transport_events"]) == 1
|
||||
assert anchor["transport_events"][0]["source_event_type"] == "stream_end"
|
||||
|
||||
|
||||
def test_registry_updates_lifecycle_and_settled_final_projection():
|
||||
data = _registry_snapshot()
|
||||
anchor = data["registry"]["anchor"]
|
||||
|
||||
assert anchor["lifecycle"]["status"] == "completed"
|
||||
assert anchor["lifecycle"]["terminal_state"] == "completed"
|
||||
assert anchor["lifecycle"]["started_at"] == "2026-06-11T00:00:01Z"
|
||||
assert anchor["lifecycle"]["completed_at"] == "2026-06-11T00:00:06Z"
|
||||
assert anchor["content"]["final_answer"] == "final answer"
|
||||
assert anchor["content"]["final_message_ref"] == "message-final"
|
||||
assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13}
|
||||
|
||||
|
||||
def test_registry_replay_and_settlement_order_converge_on_same_anchor_state():
|
||||
data = _race_snapshot()
|
||||
replay_first = data["replayFirst"]
|
||||
settled_first = data["settledFirst"]
|
||||
|
||||
assert replay_first == settled_first
|
||||
assert replay_first["stats"]["applied"] == 4
|
||||
assert replay_first["stats"]["skipped_duplicate"] == 1
|
||||
assert replay_first["dedupe_keys"] == [
|
||||
'event_id:"run-race:1"',
|
||||
'event_id:"run-race:2"',
|
||||
'event_id:"run-race:3"',
|
||||
]
|
||||
assert replay_first["activity"] == [
|
||||
{
|
||||
"event_id": "run-race:1",
|
||||
"kind": "process_prose",
|
||||
"status": None,
|
||||
"text": "live token",
|
||||
"tool_call_id": None,
|
||||
},
|
||||
{
|
||||
"event_id": "run-race:2",
|
||||
"kind": "tool_completed",
|
||||
"status": "completed",
|
||||
"text": None,
|
||||
"tool_call_id": "tool-1",
|
||||
},
|
||||
{
|
||||
"event_id": "run-race:3",
|
||||
"kind": "terminal_status",
|
||||
"status": "completed",
|
||||
"text": None,
|
||||
"tool_call_id": None,
|
||||
},
|
||||
]
|
||||
assert replay_first["terminal_state"] == "completed"
|
||||
assert replay_first["final_answer"] == "race final"
|
||||
assert replay_first["final_message_ref"] == "message-race"
|
||||
assert replay_first["usage"] == {"input_tokens": 5, "output_tokens": 8}
|
||||
|
||||
|
||||
def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle():
|
||||
data = _hardening_snapshot()
|
||||
registry = data["toolRegistry"]
|
||||
anchor = registry["anchor"]
|
||||
|
||||
assert data["version"] == "slice3-registry-shadow"
|
||||
assert data["toolResults"] == [
|
||||
{"applied": True, "reason": None},
|
||||
{"applied": True, "reason": None},
|
||||
{"applied": True, "reason": None},
|
||||
{"applied": True, "reason": None},
|
||||
{"applied": True, "reason": None},
|
||||
]
|
||||
assert registry["event_index"]["dedupe_keys"] == []
|
||||
assert registry["stats"]["applied"] == 5
|
||||
assert [event["kind"] for event in anchor["activity_events"]] == [
|
||||
"tool_started",
|
||||
"tool_updated",
|
||||
"tool_completed",
|
||||
"process_prose",
|
||||
"process_prose",
|
||||
]
|
||||
|
||||
|
||||
def test_registry_rejects_cross_run_events_but_allows_stream_reconnects():
|
||||
data = _hardening_snapshot()
|
||||
registry = data["runRegistry"]
|
||||
|
||||
assert data["runMismatch"] == {"applied": False, "reason": "mismatched_anchor"}
|
||||
assert data["streamAccepted"] == {"applied": True, "reason": None}
|
||||
assert registry["stats"]["skipped_mismatched"] == 1
|
||||
assert registry["stats"]["applied"] == 1
|
||||
assert registry["anchor"]["identity"]["run_id"] == "run-a"
|
||||
assert registry["anchor"]["identity"]["stream_id"] == "stream-a"
|
||||
assert registry["anchor"]["activity_events"][0]["stream_id"] == "stream-b"
|
||||
|
||||
|
||||
def test_registry_identity_copy_and_metadata_reads_are_hardened():
|
||||
data = _hardening_snapshot()
|
||||
identity_registry = data["identityRegistry"]
|
||||
metadata_anchor = data["metadataRegistry"]["anchor"]
|
||||
|
||||
assert identity_registry["identity"]["session_id"] == "sid-freeze"
|
||||
assert identity_registry["anchor"]["identity"]["session_id"] == "sid-freeze"
|
||||
assert metadata_anchor["content"]["final_answer"] == "structured answer"
|
||||
assert metadata_anchor["content"]["final_message_ref"] == "message-structured"
|
||||
assert metadata_anchor["usage"] == {"input_tokens": 8, "output_tokens": 13}
|
||||
assert len(metadata_anchor["metadata_events"]) == 2
|
||||
|
||||
|
||||
def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner():
|
||||
data = _shadow_snapshot()
|
||||
registry = data["registry"]
|
||||
anchor = registry["anchor"]
|
||||
|
||||
assert data["version"] == "slice3-registry-shadow"
|
||||
assert data["results"]["live"] == [{"applied": True, "reason": None}]
|
||||
assert data["results"]["replay"] == [
|
||||
{"applied": False, "reason": "duplicate"},
|
||||
{"applied": True, "reason": None},
|
||||
]
|
||||
assert data["results"]["settled"] == [{"applied": True, "reason": None}]
|
||||
assert data["results"]["inflight"] == [{"applied": True, "reason": None}]
|
||||
|
||||
assert registry["stats"]["applied"] == 4
|
||||
assert registry["stats"]["skipped_duplicate"] == 1
|
||||
assert anchor["identity"]["run_id"] == "run-shadow"
|
||||
assert anchor["identity"]["stream_id"] == "stream-shadow"
|
||||
assert [event["kind"] for event in anchor["activity_events"]] == [
|
||||
"process_prose",
|
||||
"tool_completed",
|
||||
]
|
||||
assert [event["source_event_type"] for event in anchor["metadata_events"]] == [
|
||||
"settled_message",
|
||||
"inflight_snapshot",
|
||||
]
|
||||
assert anchor["content"]["final_answer"] == "shadow final"
|
||||
|
||||
|
||||
def test_registry_instances_do_not_share_owner_state():
|
||||
data = _registry_snapshot()
|
||||
isolated = data["isolated"]
|
||||
|
||||
assert isolated["identity"]["turn_id"] == "turn-2"
|
||||
assert isolated["event_index"]["dedupe_keys"] == []
|
||||
assert isolated["stats"]["applied"] == 0
|
||||
assert isolated["anchor"]["activity_events"] == []
|
||||
|
||||
|
||||
def test_slice3_registry_is_still_unwired_from_rendering_hot_paths():
|
||||
helper_names = [
|
||||
"createAssistantTurnAnchorRegistry",
|
||||
"applyAssistantTurnAnchorNormalizedEvent",
|
||||
"applyAssistantTurnAnchorSourceEvent",
|
||||
"applyAssistantTurnAnchorSourceEvents",
|
||||
"createAssistantTurnAnchorShadowSnapshot",
|
||||
]
|
||||
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)
|
||||
Reference in new issue
Block a user