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)