feat(anchor): add shadow source registry harness
This commit is contained in:
1 parent
acd9d2e6f4
commit
65cb2a8b46
4 files changed
+154
-3
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user