Add assistant turn anchor dual-run reconciler

This commit is contained in:
Frank Song committed 2026-06-14 01:04:29 +08:00
1 parent e3d8a3dc30
commit c52c832d05
6 files changed
+497 -12

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Added
- **Stable Assistant Turn Anchors dual-run reconciler (#3926).** Adds `reconcileAssistantTurnAnchorActivityScene()` to compare an anchor-owned `activity_scene_v1` projection against current renderer-row snapshots before visible renderer replacement. The helper reports row-count, missing/extra row, order, and field mismatch diagnostics for Compact Worklog / Transparent Stream shadow runs while staying inert: no current `renderMessages()`, live SSE, `S.messages`, `INFLIGHT`, Compact Worklog, Transparent Stream, or DOM path consumes it yet.
## [v0.51.387] — 2026-06-13 — Release MZ (Stable Assistant Turn Anchors live shadow feed, inert, #3926)
### Added
@@ -29,11 +29,13 @@ streaming or rendering yet.
- Slice 6 starts the live shadow-feed boundary: `attachLiveStream()` now creates
a per-stream anchor registry and feeds non-token live activity events into it
without changing either current renderer.
- The next independently reviewable boundary is a dual-run reconciler that
compares current Compact Worklog / Transparent Stream output with the
anchor-owned activity scene before visible renderer replacement. `S.messages`,
`INFLIGHT`, stream-local state, and DOM nodes remain projection/cache layers
outside the settled final-prose path and the live shadow registry.
- Slice 7 adds the dual-run reconciler for the renderer handoff: it compares a
current Compact Worklog / Transparent Stream renderer-row snapshot with the
anchor-owned `activity_scene_v1` rows and reports missing rows, extra rows,
order changes, and field mismatches before visible renderer replacement.
`S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
projection/cache layers outside the settled final-prose path and the live
shadow registry.
## State Layers
@@ -196,6 +198,31 @@ as an anchor terminal event in this slice. Runtime app errors continue through
the existing `apperror` event path and are fed as terminal activity only when
they match the current session.
## Slice 7 Dual-Run Reconciler
`HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorActivityScene()` compares
the anchor-owned `activity_scene_v1` projection against a renderer-derived row
snapshot. It is a shadow harness, not a renderer. Callers pass the current
renderer's observed rows as plain summaries, and the helper returns
`activity_scene_reconciliation_v1` with:
- expected and actual row summaries,
- the comparison fields used,
- row-count, missing-row, unexpected-row, order, and field mismatch diagnostics,
- identity and terminal-state context from the anchor scene.
This slice keeps the comparison renderer-neutral. Compact Worklog and
Transparent Stream can each provide their own row snapshots, while the expected
side always comes from the same anchor scene. Matching rows prove the current
renderer can be replaced by an anchor-backed renderer for that event shape;
mismatches identify the specific event kind, tool identity, status, or ordering
gap that must be fixed before the visible handoff.
No current hot path consumes the reconciler. `renderMessages()`, live SSE
callbacks, `S.messages`, `INFLIGHT`, Compact Worklog, Transparent Stream, and
DOM continuity continue to render exactly as before until a later replacement
slice deliberately switches a renderer to anchor-owned rows.
## Source Event Classification
Phase 0 classifies current sources before changing render behavior:
+275 -1
View File
@@ -1042,6 +1042,279 @@
});
}
const ACTIVITY_RECONCILIATION_DEFAULT_FIELDS=Object.freeze([
'kind',
'role',
'source_event_type',
'status',
'tool_call_id',
'tool_name',
'tool_done',
'tool_is_error',
]);
function _activityReconciliationInputScene(input, options){
const opts=(options&&typeof options==='object')?options:{};
const item=(input&&typeof input==='object')?input:{};
if(_own(item,'version')==='activity_scene_v1') return item;
const explicitScene=_own(item,'scene')||_own(item,'activity_scene')||_own(opts,'scene')||_own(opts,'activity_scene');
if(explicitScene&&typeof explicitScene==='object'&&_own(explicitScene,'version')==='activity_scene_v1'){
return explicitScene;
}
const projectionInput=_own(item,'registry')||_own(item,'anchor_registry')||_own(item,'anchor')||item;
const requestedMode=_cleanString(_own(item,'mode'))||_cleanString(_own(opts,'mode'));
return projectAssistantTurnAnchorActivityScene(projectionInput,{mode:requestedMode});
}
function _activityReconciliationRendererRows(input, options){
const item=(input&&typeof input==='object')?input:{};
const opts=(options&&typeof options==='object')?options:{};
for(const value of [
_own(item,'renderer_rows'),
_own(item,'actual_rows'),
_own(item,'rows'),
_own(opts,'renderer_rows'),
_own(opts,'actual_rows'),
_own(opts,'rows'),
]){
if(Array.isArray(value)) return value;
}
return [];
}
function _activityReconciliationFields(input, options){
const item=(input&&typeof input==='object')?input:{};
const opts=(options&&typeof options==='object')?options:{};
const fields=_own(item,'compare_fields')||_own(item,'fields')||_own(opts,'compare_fields')||_own(opts,'fields');
const list=Array.isArray(fields)?fields:ACTIVITY_RECONCILIATION_DEFAULT_FIELDS;
const out=[];
list.forEach((field)=>{
const name=_cleanString(field);
if(name&&out.indexOf(name)===-1) out.push(name);
});
return out;
}
function _activityReconciliationDataValue(row, keys){
const item=(row&&typeof row==='object')?row:{};
for(let i=0;i<keys.length;i+=1){
const key=keys[i];
const value=_own(item,key);
if(value!==undefined&&value!==null&&value!=='') return value;
}
const dataset=item.dataset&&typeof item.dataset==='object'?item.dataset:null;
if(dataset){
for(let i=0;i<keys.length;i+=1){
const key=keys[i];
const camel=key.replace(/_([a-z])/g,(_,ch)=>ch.toUpperCase());
const value=_own(dataset,camel)||_own(dataset,key);
if(value!==undefined&&value!==null&&value!=='') return value;
}
}
if(typeof item.getAttribute==='function'){
for(let i=0;i<keys.length;i+=1){
const key=keys[i];
const dataKey=`data-${String(key).replace(/_/g,'-')}`;
const value=item.getAttribute(dataKey)||item.getAttribute(key);
if(value!==undefined&&value!==null&&value!=='') return value;
}
}
return undefined;
}
function _activityReconciliationBool(value){
if(typeof value==='boolean') return value;
if(value===0||value==='0') return false;
if(value===1||value==='1') return true;
const raw=_cleanString(value).toLowerCase();
if(!raw) return null;
if(raw==='true'||raw==='yes'||raw==='done'||raw==='completed') return true;
if(raw==='false'||raw==='no'||raw==='running'||raw==='pending') return false;
return null;
}
function _activityReconciliationStatus(value){
const raw=_cleanString(value).toLowerCase().replace(/[\s-]+/g,'_');
if(!raw) return null;
const terminal=normalizeAssistantTurnAnchorTerminalState(raw);
if(terminal) return terminal;
if(raw==='complete'||raw==='done') return 'completed';
if(raw==='failed'||raw==='failure') return 'error';
return raw;
}
function _activityReconciliationText(row){
const item=(row&&typeof row==='object')?row:{};
const value=_firstTextValue(
_activityReconciliationDataValue(item,['text']),
_activityReconciliationDataValue(item,['preview']),
_activityReconciliationDataValue(item,['summary']),
_activityReconciliationDataValue(item,['content'])
);
if(value) return value;
return typeof item.textContent==='string'?item.textContent:'';
}
function _activityReconciliationRowSummary(row, index){
const item=(row&&typeof row==='object')?row:{};
const tool=item.tool&&typeof item.tool==='object'?item.tool:{};
const rawKind=_cleanString(_activityReconciliationDataValue(item,['kind','event_type','type']));
const rawRole=_cleanString(_activityReconciliationDataValue(item,['role']));
const toolName=_cleanString(
_activityReconciliationDataValue(item,['tool_name','name'])||
_activityReconciliationDataValue(tool,['name','tool_name'])
)||null;
const toolCallId=_cleanString(
_activityReconciliationDataValue(item,['tool_call_id','toolCallId','tid','tool_use_id','call_id'])||
_activityReconciliationDataValue(tool,['id','tool_call_id','toolCallId','tid','tool_use_id','call_id'])
)||null;
const toolDone=_activityReconciliationBool(
_activityReconciliationDataValue(item,['tool_done','toolDone','done']) ??
_activityReconciliationDataValue(tool,['done'])
);
const toolError=_activityReconciliationBool(
_activityReconciliationDataValue(item,['tool_is_error','toolError','is_error','isError']) ??
_activityReconciliationDataValue(tool,['is_error','isError'])
);
const rowId=_cleanString(_activityReconciliationDataValue(item,['row_id','rowId','event_id','eventId','id']));
const status=_activityReconciliationStatus(_activityReconciliationDataValue(item,['status','state']));
return Object.freeze({
row_id:rowId||null,
order_index:index,
declared_order_index:_activityReconciliationDataValue(item,['order_index','orderIndex'])??null,
kind:rawKind||null,
role:rawRole||null,
source_event_type:_cleanString(_activityReconciliationDataValue(item,['source_event_type','sourceEventType','event_type','eventType']))||null,
display_hint:_cleanString(_activityReconciliationDataValue(item,['display_hint','displayHint']))||null,
status,
text:_activityReconciliationText(item),
tool_call_id:toolCallId,
tool_name:toolName,
tool_done:toolDone,
tool_is_error:toolError,
});
}
function _activityReconciliationRowsById(rows){
const map=new Map();
rows.forEach((row)=>{
if(row&&row.row_id&&!map.has(row.row_id)) map.set(row.row_id,row);
});
return map;
}
function _activityReconciliationDuplicateRowIds(rows){
const seen=new Set();
const dupes=[];
rows.forEach((row,index)=>{
const rowId=row&&row.row_id;
if(!rowId) return;
if(seen.has(rowId)) dupes.push(Object.freeze({row_id:rowId,index,row}));
else seen.add(rowId);
});
return dupes;
}
function _activityReconciliationMismatch(kind, detail){
return Object.freeze({
kind,
..._copyObject(detail),
});
}
function reconcileAssistantTurnAnchorActivityScene(input, options){
const item=(input&&typeof input==='object')?input:{};
const opts=(options&&typeof options==='object')?options:{};
const scene=_activityReconciliationInputScene(item,opts);
const expectedRows=(Array.isArray(scene.activity_rows)?scene.activity_rows:[])
.map((row,index)=>_activityReconciliationRowSummary(row,index));
const actualRows=_activityReconciliationRendererRows(item,opts)
.map((row,index)=>_activityReconciliationRowSummary(row,index));
const fields=_activityReconciliationFields(item,opts);
const mismatches=[];
if(expectedRows.length!==actualRows.length){
mismatches.push(_activityReconciliationMismatch('row_count',{
expected_count:expectedRows.length,
actual_count:actualRows.length,
}));
}
const expectedById=_activityReconciliationRowsById(expectedRows);
const actualById=_activityReconciliationRowsById(actualRows);
const useRowIds=expectedById.size===expectedRows.length&&actualById.size>0;
const matchedActualIds=new Set();
_activityReconciliationDuplicateRowIds(actualRows).forEach((duplicate)=>{
mismatches.push(_activityReconciliationMismatch('duplicate_actual_row',duplicate));
});
expectedRows.forEach((expected,index)=>{
const actual=useRowIds&&expected.row_id?actualById.get(expected.row_id):actualRows[index];
if(!actual){
mismatches.push(_activityReconciliationMismatch('missing_actual_row',{
row_id:expected.row_id,
expected_index:index,
expected,
}));
return;
}
if(useRowIds&&actual.row_id) matchedActualIds.add(actual.row_id);
if(actual.order_index!==expected.order_index){
mismatches.push(_activityReconciliationMismatch('order_mismatch',{
row_id:expected.row_id,
expected_index:expected.order_index,
actual_index:actual.order_index,
}));
}
fields.forEach((field)=>{
if(!_hasOwn(expected,field)&&!_hasOwn(actual,field)) return;
const expectedValue=expected[field]===undefined?null:expected[field];
const actualValue=actual[field]===undefined?null:actual[field];
if(JSON.stringify(expectedValue)!==JSON.stringify(actualValue)){
mismatches.push(_activityReconciliationMismatch('field_mismatch',{
row_id:expected.row_id,
field,
expected:expectedValue,
actual:actualValue,
}));
}
});
});
if(useRowIds){
actualRows.forEach((actual,index)=>{
if(actual.row_id&&expectedById.has(actual.row_id)) return;
if(actual.row_id&&matchedActualIds.has(actual.row_id)) return;
mismatches.push(_activityReconciliationMismatch('unexpected_actual_row',{
row_id:actual.row_id,
actual_index:index,
actual,
}));
});
}else if(actualRows.length>expectedRows.length){
actualRows.slice(expectedRows.length).forEach((actual,offset)=>{
mismatches.push(_activityReconciliationMismatch('unexpected_actual_row',{
row_id:actual.row_id,
actual_index:expectedRows.length+offset,
actual,
}));
});
}
return Object.freeze({
version:'activity_scene_reconciliation_v1',
mode:scene.mode||(_cleanString(_own(item,'mode'))||_cleanString(_own(opts,'mode'))||'compact_worklog'),
scene_version:scene.version||null,
matched:mismatches.length===0,
summary:Object.freeze({
expected_count:expectedRows.length,
actual_count:actualRows.length,
mismatch_count:mismatches.length,
}),
identity:scene.identity||Object.freeze({source_message_refs:Object.freeze([])}),
terminal_state:scene.terminal_state||null,
fields:Object.freeze(fields),
expected_rows:Object.freeze(expectedRows),
actual_rows:Object.freeze(actualRows),
mismatches:Object.freeze(mismatches),
});
}
function createAssistantTurnAnchorSeed(input){
const opts=(input&&typeof input==='object')?input:{};
const sessionId=_cleanString(opts.session_id);
@@ -1101,7 +1374,7 @@
}
ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'slice5-activity-scene',
version:'slice7-dual-run-reconciler',
activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
@@ -1120,6 +1393,7 @@
createAssistantTurnAnchorShadowSnapshot,
projectAssistantTurnAnchorSettledMessageFinalAnswer,
projectAssistantTurnAnchorActivityScene,
reconcileAssistantTurnAnchorActivityScene,
isAssistantTurnAnchorActivityKind,
});
})();
@@ -182,7 +182,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity():
live = data["liveToken"]
replay = data["replayToken"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert live["classification"] == "activity"
assert live["dedupe_key"] == 'event_id:"run-1:7"'
assert replay["dedupe_key"] == live["dedupe_key"]
@@ -229,6 +229,9 @@ def test_phase0_inventory_doc_matches_scaffold_contract():
"`INFLIGHT`",
"Stream closure state",
"Live DOM",
"Slice 7 Dual-Run Reconciler",
"`HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorActivityScene()`",
"`activity_scene_reconciliation_v1`",
"Dedupe Invariant",
"`event_id`",
"`run_id + seq`",
@@ -216,6 +216,83 @@ console.log(JSON.stringify({{
return json.loads(result.stdout)
def _activity_scene_reconciliation_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-reconcile',
turn_id:'turn-reconcile',
}});
api.applyAssistantTurnAnchorSourceEvents(registry, [
{{event:'reasoning', payload:{{text:'thinking'}}, event_id:'run-reconcile:1', seq:1}},
{{event:'tool', payload:{{tool_call_id:'tool-1', name:'terminal', args:{{command:'rg anchor'}}}}, event_id:'run-reconcile:2', seq:2}},
{{event:'tool_complete', payload:{{tool_call_id:'tool-1', name:'terminal', result:'ok', is_error:false}}, event_id:'run-reconcile:3', seq:3}},
{{event:'done', payload:{{status:'done'}}, event_id:'run-reconcile:4', seq:4}},
], {{run_id:'run-reconcile', stream_id:'stream-reconcile'}});
const scene = api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'transparent_stream'}});
const rendererRows = scene.activity_rows.map((row) => ({{
row_id:row.row_id,
kind:row.kind,
role:row.role,
source_event_type:row.source_event_type,
status:row.status,
tool_call_id:row.tool_call_id,
tool_name:row.tool && row.tool.name,
tool_done:row.tool && row.tool.done,
tool_is_error:row.tool && row.tool.is_error,
}}));
const matched = api.reconcileAssistantTurnAnchorActivityScene({{
registry,
mode:'transparent_stream',
renderer_rows:rendererRows,
}});
const mutatedRows = rendererRows
.filter((row) => row.row_id !== 'run-reconcile:1')
.map((row) => row.row_id === 'run-reconcile:3'
? {{...row, status:'running', tool_done:false}}
: row);
mutatedRows.push({{
row_id:'renderer-extra',
kind:'tool_started',
role:'tool',
source_event_type:'tool',
status:'running',
tool_call_id:'tool-extra',
tool_name:'terminal',
tool_done:false,
tool_is_error:false,
}});
const mismatched = api.reconcileAssistantTurnAnchorActivityScene({{
scene,
renderer_rows:mutatedRows,
}});
const duplicatedRows = rendererRows.map((row) => row.row_id === 'run-reconcile:3'
? {{...rendererRows[1]}}
: row);
const duplicated = api.reconcileAssistantTurnAnchorActivityScene({{
scene,
renderer_rows:duplicatedRows,
}});
console.log(JSON.stringify({{
version:api.version,
scene,
matched,
mismatched,
duplicated,
}}));
"""
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 _final_projection_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
@@ -446,7 +523,7 @@ def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events():
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
assert registry["event_index"]["dedupe_keys"][:2] == [
'event_id:"run-1:1"',
@@ -546,7 +623,7 @@ def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle():
registry = data["toolRegistry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert data["toolResults"] == [
{"applied": True, "reason": None},
{"applied": True, "reason": None},
@@ -596,7 +673,7 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner()
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert data["results"]["live"] == [{"applied": True, "reason": None}]
assert data["results"]["replay"] == [
{"applied": False, "reason": "duplicate"},
@@ -624,7 +701,7 @@ def test_activity_scene_projects_current_activity_events_for_both_render_modes()
compact = data["compact"]
transparent = data["transparent"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert compact["version"] == "activity_scene_v1"
assert transparent["version"] == "activity_scene_v1"
assert compact["mode"] == "compact_worklog"
@@ -745,13 +822,112 @@ def test_activity_scene_is_renderer_neutral_and_empty_safe():
}
def test_activity_scene_reconciler_matches_renderer_snapshot_rows():
data = _activity_scene_reconciliation_snapshot()
matched = data["matched"]
assert data["version"] == "slice7-dual-run-reconciler"
assert matched["version"] == "activity_scene_reconciliation_v1"
assert matched["scene_version"] == "activity_scene_v1"
assert matched["mode"] == "transparent_stream"
assert matched["matched"] is True
assert matched["summary"] == {
"expected_count": 4,
"actual_count": 4,
"mismatch_count": 0,
}
assert matched["terminal_state"] == "completed"
assert matched["fields"] == [
"kind",
"role",
"source_event_type",
"status",
"tool_call_id",
"tool_name",
"tool_done",
"tool_is_error",
]
assert [row["row_id"] for row in matched["expected_rows"]] == [
"run-reconcile:1",
"run-reconcile:2",
"run-reconcile:3",
"run-reconcile:4",
]
assert matched["actual_rows"][2]["tool_name"] == "terminal"
assert matched["actual_rows"][2]["tool_done"] is True
def test_activity_scene_reconciler_reports_missing_changed_and_extra_rows():
data = _activity_scene_reconciliation_snapshot()
mismatched = data["mismatched"]
kinds = [item["kind"] for item in mismatched["mismatches"]]
assert mismatched["matched"] is False
assert mismatched["summary"]["expected_count"] == 4
assert mismatched["summary"]["actual_count"] == 4
assert mismatched["summary"]["mismatch_count"] == len(mismatched["mismatches"])
assert "missing_actual_row" in kinds
assert "unexpected_actual_row" in kinds
assert {
"kind": "field_mismatch",
"row_id": "run-reconcile:3",
"field": "status",
"expected": "completed",
"actual": "running",
} in mismatched["mismatches"]
assert {
"kind": "field_mismatch",
"row_id": "run-reconcile:3",
"field": "tool_done",
"expected": True,
"actual": False,
} in mismatched["mismatches"]
missing = [
item for item in mismatched["mismatches"]
if item["kind"] == "missing_actual_row"
]
assert missing[0]["row_id"] == "run-reconcile:1"
extra = [
item for item in mismatched["mismatches"]
if item["kind"] == "unexpected_actual_row"
]
assert extra[0]["row_id"] == "renderer-extra"
def test_activity_scene_reconciler_reports_duplicate_renderer_row_ids():
data = _activity_scene_reconciliation_snapshot()
duplicated = data["duplicated"]
kinds = [item["kind"] for item in duplicated["mismatches"]]
assert duplicated["matched"] is False
assert "duplicate_actual_row" in kinds
assert "missing_actual_row" in kinds
duplicates = [
item for item in duplicated["mismatches"]
if item["kind"] == "duplicate_actual_row"
]
assert duplicates == [
{
"kind": "duplicate_actual_row",
"row_id": "run-reconcile:2",
"index": 2,
"row": duplicated["actual_rows"][2],
}
]
missing = [
item for item in duplicated["mismatches"]
if item["kind"] == "missing_actual_row"
]
assert missing[0]["row_id"] == "run-reconcile:3"
def test_final_projection_routes_settled_assistant_message_through_anchor_owner():
data = _final_projection_snapshot()
projected = data["projected"]
registry = projected["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice5-activity-scene"
assert data["version"] == "slice7-dual-run-reconciler"
assert projected["applied"] is True
assert projected["reason"] is None
assert projected["final_message_ref"] == "message-final"
@@ -825,6 +1001,7 @@ def test_slice5_scene_projection_does_not_wire_activity_scene_into_rendering_hot
"applyAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorShadowSnapshot",
scene_helper,
"reconcileAssistantTurnAnchorActivityScene",
]:
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)