From 56a89ef11e0d023551ba8a64ec9be3cae6685336 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Thu, 11 Jun 2026 20:07:21 +0800 Subject: [PATCH 1/5] feat(anchor): add inert turn registry skeleton --- .../stable-assistant-turn-anchor-phase0.md | 30 +++ static/assistant_turn_anchors.js | 186 +++++++++++++++++- ...stable_assistant_turn_anchor_normalizer.py | 9 +- ...t_stable_assistant_turn_anchor_registry.py | 144 ++++++++++++++ 4 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 tests/test_stable_assistant_turn_anchor_registry.py diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 2f3e83a9..a3ca0b84 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -37,6 +37,36 @@ 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`. 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. + ## Source Event Classification Phase 0 classifies current sources before changing render behavior: diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index ff5ec306..42a211dd 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -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; @@ -374,6 +374,163 @@ return out; } + function _copyObject(value){ + if(!value||typeof value!=='object'||Array.isArray(value)) return {}; + return {...value}; + } + + 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:identity.session_id||_cleanString(context&&context.session_id), + turn_id:identity.turn_id||_cleanString(context&&context.turn_id), + run_id:identity.run_id||_cleanString(context&&context.run_id), + stream_id:identity.stream_id||_cleanString(context&&context.stream_id), + }; + } + + function _eventBelongsToAnchor(anchor, event){ + const identity=anchor.identity||{}; + const sessionId=_cleanString(event.session_id); + if(sessionId&&identity.session_id&&sessionId!==identity.session_id) return false; + const turnId=_cleanString(event.turn_id); + if(turnId&&identity.turn_id&&turnId!==identity.turn_id) return false; + return true; + } + + 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=[]; + return anchor; + } + + function _syncAnchorIdentity(anchor, event){ + const identity=anchor.identity||{}; + if(!identity.run_id&&_cleanString(event.run_id)) identity.run_id=_cleanString(event.run_id); + if(!identity.stream_id&&_cleanString(event.stream_id)) identity.stream_id=_cleanString(event.stream_id); + } + + function _firstTextValue(){ + for(let i=0;i0) return value; + } + return ''; + } + + function _messageRefFromPayload(payload, event){ + return _firstTextValue( + payload&&payload.message_id, + payload&&payload.id, + payload&&payload.local_id, + event&&event.local_id, + event&&event.event_id + )||null; + } + + function _updateLifecycleFromEvent(anchor, event){ + const lifecycle=anchor.lifecycle||{}; + if(!lifecycle.started_at&&event.created_at) lifecycle.started_at=event.created_at; + if((!lifecycle.status||lifecycle.status==='created')&&event.status==='running'){ + lifecycle.status='running'; + } + if(event.kind==='terminal_status'){ + const terminal=_cleanString(event.status)||'completed'; + lifecycle.status=terminal; + lifecycle.terminal_state=terminal; + lifecycle.completed_at=event.created_at||lifecycle.completed_at||null; + } + anchor.lifecycle=lifecycle; + } + + function _updateContentFromMetadata(anchor, event){ + const payload=event.payload||{}; + if(event.source_event_type==='usage'){ + anchor.usage=_copyObject(payload); + return; + } + if(event.source_event_type!=='settled_message') return; + if(payload.role&&payload.role!=='assistant') return; + const finalAnswer=_firstTextValue(payload.content,payload.text,payload.final_answer,payload.answer); + if(finalAnswer){ + anchor.content=anchor.content||{}; + anchor.content.final_answer=finalAnswer; + anchor.content.final_message_ref=_messageRefFromPayload(payload,event); + } + if(payload._turnUsage&&typeof payload._turnUsage==='object') anchor.usage=_copyObject(payload._turnUsage); + if(payload.usage&&typeof payload.usage==='object') anchor.usage=_copyObject(payload.usage); + } + + function _routeAnchorEvent(anchor, normalized){ + const event=normalized.anchor_event; + if(normalized.classification==='activity'){ + anchor.activity_events.push(event); + _updateLifecycleFromEvent(anchor,event); + }else if(normalized.classification==='artifact'){ + anchor.artifacts.push(event); + }else if(normalized.classification==='side_effect'){ + anchor.side_effects.push(event); + }else if(normalized.classification==='metadata'){ + anchor.metadata_events.push(event); + _updateContentFromMetadata(anchor,event); + }else if(normalized.classification==='transport'){ + anchor.transport_events.push(event); + } + } + + function applyAssistantTurnAnchorNormalizedEvent(registry, normalized){ + const anchor=_ensureRegistryShape(registry); + const item=(normalized&&typeof normalized==='object')?normalized:{}; + const event=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(item.dedupe_key)||assistantTurnAnchorEventDedupeKey(event); + if(dedupeKey&®istry.event_index.dedupe_keys.indexOf(dedupeKey)!==-1){ + registry.stats.skipped_duplicate+=1; + return Object.freeze({applied:false,reason:'duplicate',normalized:item}); + } + if(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 createAssistantTurnAnchorSeed(input){ const opts=(input&&typeof input==='object')?input:{}; const sessionId=_cleanString(opts.session_id); @@ -407,6 +564,8 @@ activity_events:[], artifacts:[], side_effects:[], + metadata_events:[], + transport_events:[], usage:null, presentation_state:{ compact_worklog:{expanded:false}, @@ -416,8 +575,25 @@ }; } + function createAssistantTurnAnchorRegistry(input){ + const anchor=createAssistantTurnAnchorSeed(input); + return { + identity:anchor.identity, + anchor, + event_index:{ + dedupe_keys:[], + }, + stats:{ + applied:0, + skipped_duplicate:0, + skipped_excluded:0, + skipped_mismatched:0, + }, + }; + } + ROOT.HermesAssistantTurnAnchors=Object.freeze({ - version:'slice2-normalizer', + version:'slice3-registry', activityEventKinds:ACTIVITY_EVENT_KINDS, stateLayers:STATE_LAYERS, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, @@ -427,6 +603,10 @@ classifyAssistantTurnAnchorSourceEvent, normalizeAssistantTurnAnchorSourceEvent, normalizeAssistantTurnAnchorSourceEvents, + createAssistantTurnAnchorRegistry, + applyAssistantTurnAnchorNormalizedEvent, + applyAssistantTurnAnchorSourceEvent, + applyAssistantTurnAnchorSourceEvents, isAssistantTurnAnchorActivityKind, }); })(); diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index 61f4f2d3..b2b4984d 100644 --- a/tests/test_stable_assistant_turn_anchor_normalizer.py +++ b/tests/test_stable_assistant_turn_anchor_normalizer.py @@ -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 @@ -180,7 +180,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" assert live["classification"] == "activity" assert live["dedupe_key"] == 'event_id:"run-1:7"' assert replay["dedupe_key"] == live["dedupe_key"] @@ -321,10 +321,13 @@ def test_structured_fallback_dedupe_keys_do_not_collide_on_delimiters(): assert data["localCollisionA"] != data["localCollisionB"] -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) diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py new file mode 100644 index 00000000..d91775b6 --- /dev/null +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -0,0 +1,144 @@ +"""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 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" + 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_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", + ] + 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) From acd9d2e6f48a0c39913f1d6c26e7ce47945d66cb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 12 Jun 2026 08:55:19 +0800 Subject: [PATCH 2/5] fix(anchor): carry normalizer hardening into registry slice --- tests/test_stable_assistant_turn_anchor_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index d91775b6..e7b4d8ec 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -71,8 +71,8 @@ def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events(): assert data["version"] == "slice3-registry" 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", + 'event_id:"run-1:1"', + 'event_id:"run-1:2"', ] assert registry["stats"]["applied"] == 7 assert registry["stats"]["skipped_duplicate"] == 1 From 65cb2a8b46ed7b2a5940b09077e4a1429fd6807e Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 12 Jun 2026 15:45:59 +0800 Subject: [PATCH 3/5] feat(anchor): add shadow source registry harness --- .../stable-assistant-turn-anchor-phase0.md | 22 +++++ static/assistant_turn_anchors.js | 52 +++++++++++- ...stable_assistant_turn_anchor_normalizer.py | 2 +- ...t_stable_assistant_turn_anchor_registry.py | 81 ++++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index a3ca0b84..e48c9b42 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -6,6 +6,21 @@ 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. +- 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 | @@ -67,6 +82,13 @@ This slice deliberately keeps the ownership boundary inert: `send()`, 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. + ## Source Event Classification Phase 0 classifies current sources before changing render behavior: diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 42a211dd..f6b762be 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -531,6 +531,55 @@ 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); @@ -593,7 +642,7 @@ } ROOT.HermesAssistantTurnAnchors=Object.freeze({ - version:'slice3-registry', + version:'slice3-registry-shadow', activityEventKinds:ACTIVITY_EVENT_KINDS, stateLayers:STATE_LAYERS, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, @@ -607,6 +656,7 @@ applyAssistantTurnAnchorNormalizedEvent, applyAssistantTurnAnchorSourceEvent, applyAssistantTurnAnchorSourceEvents, + createAssistantTurnAnchorShadowSnapshot, isAssistantTurnAnchorActivityKind, }); })(); diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index b2b4984d..79dda90a 100644 --- a/tests/test_stable_assistant_turn_anchor_normalizer.py +++ b/tests/test_stable_assistant_turn_anchor_normalizer.py @@ -180,7 +180,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity(): live = data["liveToken"] replay = data["replayToken"] - assert data["version"] == "slice3-registry" + 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"] diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index e7b4d8ec..4e0f1a8b 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -63,12 +63,61 @@ console.log(JSON.stringify({{ 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 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" + 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"', @@ -121,6 +170,35 @@ def test_registry_updates_lifecycle_and_settled_final_projection(): assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13} +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"] @@ -137,6 +215,7 @@ def test_slice3_registry_is_still_unwired_from_rendering_hot_paths(): "applyAssistantTurnAnchorNormalizedEvent", "applyAssistantTurnAnchorSourceEvent", "applyAssistantTurnAnchorSourceEvents", + "createAssistantTurnAnchorShadowSnapshot", ] for helper in helper_names: assert helper not in _read(UI_JS) From 53051ad4a367c561325383d87c9c1876a7c5681c Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 12 Jun 2026 16:15:09 +0800 Subject: [PATCH 4/5] fix(anchor): harden registry review findings --- .../stable-assistant-turn-anchor-phase0.md | 3 +- static/assistant_turn_anchors.js | 157 +++++++++++++----- ...stable_assistant_turn_anchor_normalizer.py | 11 +- ...est_stable_assistant_turn_anchor_phase0.py | 10 +- ...t_stable_assistant_turn_anchor_registry.py | 157 ++++++++++++++++++ 5 files changed, 283 insertions(+), 55 deletions(-) diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index e48c9b42..15a6cf70 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -113,7 +113,8 @@ 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. diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index f6b762be..f9f787ba 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -242,10 +242,10 @@ } function _statusForSourceEvent(sourceType, kind, payload){ - const explicit=_cleanString(payload&&(payload.status||payload.state||payload.phase)); + const explicit=_cleanString(_firstOwn(payload,['status','state','phase'])); if(explicit) return 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'; @@ -280,7 +280,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 ''; } @@ -379,6 +380,16 @@ 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 @@ -390,22 +401,40 @@ const identity=anchor&&anchor.identity?anchor.identity:{}; return { ..._copyObject(context), - session_id:identity.session_id||_cleanString(context&&context.session_id), - turn_id:identity.turn_id||_cleanString(context&&context.turn_id), - run_id:identity.run_id||_cleanString(context&&context.run_id), - stream_id:identity.stream_id||_cleanString(context&&context.stream_id), + 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(event.session_id); - if(sessionId&&identity.session_id&&sessionId!==identity.session_id) return false; - const turnId=_cleanString(event.turn_id); - if(turnId&&identity.turn_id&&turnId!==identity.turn_id) return false; + 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'); @@ -420,79 +449,109 @@ 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||{}; - if(!identity.run_id&&_cleanString(event.run_id)) identity.run_id=_cleanString(event.run_id); - if(!identity.stream_id&&_cleanString(event.stream_id)) identity.stream_id=_cleanString(event.stream_id); + 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 _firstTextValue(){ - for(let i=0;i0) return value; + 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;i0) return value; } return ''; } function _messageRefFromPayload(payload, event){ return _firstTextValue( - payload&&payload.message_id, - payload&&payload.id, - payload&&payload.local_id, - event&&event.local_id, - event&&event.event_id + _firstOwn(payload,['message_id','id','local_id']), + _firstOwn(event,['local_id','event_id']) )||null; } function _updateLifecycleFromEvent(anchor, event){ const lifecycle=anchor.lifecycle||{}; - if(!lifecycle.started_at&&event.created_at) lifecycle.started_at=event.created_at; - if((!lifecycle.status||lifecycle.status==='created')&&event.status==='running'){ + 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(event.kind==='terminal_status'){ - const terminal=_cleanString(event.status)||'completed'; + if(kind==='terminal_status'){ + const terminal=status||'completed'; lifecycle.status=terminal; lifecycle.terminal_state=terminal; - lifecycle.completed_at=event.created_at||lifecycle.completed_at||null; + lifecycle.completed_at=createdAt||lifecycle.completed_at||null; } anchor.lifecycle=lifecycle; } function _updateContentFromMetadata(anchor, event){ - const payload=event.payload||{}; - if(event.source_event_type==='usage'){ + const payload=_own(event,'payload')||{}; + const sourceType=_cleanString(_own(event,'source_event_type')); + if(sourceType==='usage'){ anchor.usage=_copyObject(payload); return; } - if(event.source_event_type!=='settled_message') return; - if(payload.role&&payload.role!=='assistant') return; - const finalAnswer=_firstTextValue(payload.content,payload.text,payload.final_answer,payload.answer); + 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); } - if(payload._turnUsage&&typeof payload._turnUsage==='object') anchor.usage=_copyObject(payload._turnUsage); - if(payload.usage&&typeof payload.usage==='object') anchor.usage=_copyObject(payload.usage); + 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=normalized.anchor_event; - if(normalized.classification==='activity'){ + 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(normalized.classification==='artifact'){ + }else if(classification==='artifact'){ anchor.artifacts.push(event); - }else if(normalized.classification==='side_effect'){ + }else if(classification==='side_effect'){ anchor.side_effects.push(event); - }else if(normalized.classification==='metadata'){ + }else if(classification==='metadata'){ anchor.metadata_events.push(event); _updateContentFromMetadata(anchor,event); - }else if(normalized.classification==='transport'){ + }else if(classification==='transport'){ anchor.transport_events.push(event); } } @@ -500,7 +559,7 @@ function applyAssistantTurnAnchorNormalizedEvent(registry, normalized){ const anchor=_ensureRegistryShape(registry); const item=(normalized&&typeof normalized==='object')?normalized:{}; - const event=item.anchor_event; + const event=_own(item,'anchor_event'); if(!event){ registry.stats.skipped_excluded+=1; return Object.freeze({applied:false,reason:'excluded',normalized:item}); @@ -509,12 +568,16 @@ registry.stats.skipped_mismatched+=1; return Object.freeze({applied:false,reason:'mismatched_anchor',normalized:item}); } - const dedupeKey=_cleanString(item.dedupe_key)||assistantTurnAnchorEventDedupeKey(event); - if(dedupeKey&®istry.event_index.dedupe_keys.indexOf(dedupeKey)!==-1){ + 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) registry.event_index.dedupe_keys.push(dedupeKey); + if(dedupeKey){ + dedupeKeySet.add(dedupeKey); + registry.event_index.dedupe_keys.push(dedupeKey); + } _syncAnchorIdentity(anchor,event); _routeAnchorEvent(anchor,item); registry.stats.applied+=1; @@ -626,8 +689,8 @@ function createAssistantTurnAnchorRegistry(input){ const anchor=createAssistantTurnAnchorSeed(input); - return { - identity:anchor.identity, + const registry={ + identity:_frozenIdentityCopy(anchor.identity), anchor, event_index:{ dedupe_keys:[], @@ -639,6 +702,8 @@ skipped_mismatched:0, }, }; + _ensureDedupeKeySet(registry.event_index); + return registry; } ROOT.HermesAssistantTurnAnchors=Object.freeze({ diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index 79dda90a..9d63fe9f 100644 --- a/tests/test_stable_assistant_turn_anchor_normalizer.py +++ b/tests/test_stable_assistant_turn_anchor_normalizer.py @@ -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) @@ -316,9 +318,10 @@ 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_normalizer_and_registry_helpers_are_still_unwired_from_rendering_hot_paths(): diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py index ebe913aa..ff32495f 100644 --- a/tests/test_stable_assistant_turn_anchor_phase0.py +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -53,7 +53,8 @@ const out = {{ 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 +147,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] @@ -191,6 +193,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 diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 4e0f1a8b..1b36273f 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -112,6 +112,113 @@ console.log(JSON.stringify({{ 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 test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events(): data = _registry_snapshot() registry = data["registry"] @@ -170,6 +277,56 @@ def test_registry_updates_lifecycle_and_settled_final_projection(): assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13} +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"] From d516f9dd00a98232a6fc5a1411a6d01c5543f9cb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 13 Jun 2026 00:44:26 +0800 Subject: [PATCH 5/5] fix(anchor): harden registry contract before wiring --- .../stable-assistant-turn-anchor-phase0.md | 36 +++++- docs/rfcs/stable-assistant-turn-anchors.md | 55 +++++++-- static/assistant_turn_anchors.js | 70 ++++++++++-- ...est_stable_assistant_turn_anchor_phase0.py | 37 +++++- ...t_stable_assistant_turn_anchor_registry.py | 107 ++++++++++++++++++ 5 files changed, 283 insertions(+), 22 deletions(-) diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 15a6cf70..f5b84f7f 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -17,6 +17,10 @@ streaming or rendering yet. - 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. @@ -72,9 +76,10 @@ the same event-envelope dedupe rule, and route events into one owner: 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`. It rejects mismatched -session or turn identity and skips duplicate live + replay observations by the -same dedupe key. +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`, @@ -89,6 +94,26 @@ 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: @@ -118,3 +143,8 @@ The Phase 0 helper uses this order: 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. diff --git a/docs/rfcs/stable-assistant-turn-anchors.md b/docs/rfcs/stable-assistant-turn-anchors.md index 5c217224..7fea9e30 100644 --- a/docs/rfcs/stable-assistant-turn-anchors.md +++ b/docs/rfcs/stable-assistant-turn-anchors.md @@ -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 diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index f9f787ba..1aa73f34 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -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'){ @@ -243,13 +291,15 @@ function _statusForSourceEvent(sourceType, kind, payload){ const explicit=_cleanString(_firstOwn(payload,['status','state','phase'])); - if(explicit) return explicit; + if(explicit){ + return kind==='terminal_status' + ?normalizeAssistantTurnAnchorTerminalState(explicit,sourceType)||explicit + :explicit; + } if(kind==='tool_started') return 'running'; 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'; @@ -503,7 +553,10 @@ lifecycle.status='running'; } if(kind==='terminal_status'){ - const terminal=status||'completed'; + 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; @@ -679,11 +732,6 @@ metadata_events:[], transport_events:[], usage:null, - presentation_state:{ - compact_worklog:{expanded:false}, - transparent_stream:{expanded:false}, - scroll:{follow:true}, - }, }; } @@ -712,7 +760,9 @@ stateLayers:STATE_LAYERS, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, classificationOrder:CLASSIFICATION_ORDER, + terminalStates:TERMINAL_STATES, createAssistantTurnAnchorSeed, + normalizeAssistantTurnAnchorTerminalState, assistantTurnAnchorEventDedupeKey, classifyAssistantTurnAnchorSourceEvent, normalizeAssistantTurnAnchorSourceEvent, diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py index ff32495f..5a7f6583 100644 --- a/tests/test_stable_assistant_turn_anchor_phase0.py +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -48,6 +48,16 @@ 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, @@ -165,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"] @@ -176,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(): diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 1b36273f..4f947cbd 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -219,6 +219,71 @@ console.log(JSON.stringify({{ 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"] @@ -277,6 +342,48 @@ def test_registry_updates_lifecycle_and_settled_final_projection(): 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"]