From c52c832d0524dbe8a64b3c766f14c5e9037b6ff5 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 14 Jun 2026 01:03:36 +0800 Subject: [PATCH] Add assistant turn anchor dual-run reconciler --- CHANGELOG.md | 4 + .../stable-assistant-turn-anchor-phase0.md | 37 ++- static/assistant_turn_anchors.js | 276 +++++++++++++++++- ...stable_assistant_turn_anchor_normalizer.py | 2 +- ...est_stable_assistant_turn_anchor_phase0.py | 3 + ...t_stable_assistant_turn_anchor_registry.py | 187 +++++++++++- 6 files changed, 497 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8877293e..c0ff1214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 559368c1..5d516a4f 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -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: diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 4edf3422..36628b69 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -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;ich.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{ + 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, }); })(); diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index b3f9dc27..a07504f7 100644 --- a/tests/test_stable_assistant_turn_anchor_normalizer.py +++ b/tests/test_stable_assistant_turn_anchor_normalizer.py @@ -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"] diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py index 03a13ef6..c943860a 100644 --- a/tests/test_stable_assistant_turn_anchor_phase0.py +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -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`", diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index e27583c5..2fc24c31 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -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)