Add assistant turn anchor dual-run reconciler
This commit is contained in:
1 parent
e3d8a3dc30
commit
c52c832d05
6 files changed
+497
-12
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user