diff --git a/CHANGELOG.md b/CHANGELOG.md index 257732da..08d86fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- **Stable Assistant Turn Anchors renderer snapshot adapter (#3926).** Adds `createAssistantTurnAnchorRendererSnapshot()` and `reconcileAssistantTurnAnchorRendererSnapshot()` so current Compact Worklog / Transparent Stream row hooks can be summarized as `renderer_snapshot_v1` and compared with the anchor-owned `activity_scene_v1` through the dual-run reconciler. This is still an inert migration harness: no current `renderMessages()`, live SSE, `S.messages`, `INFLIGHT`, Compact Worklog, Transparent Stream, or DOM path invokes it automatically. + ## [v0.51.389] — 2026-06-13 — Release NB (surface dirty-install state in update check, #4085) ### Fixed diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 5d516a4f..a2a8092c 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -36,6 +36,11 @@ streaming or rendering yet. `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 8 adds the renderer snapshot adapter that can extract + `renderer_snapshot_v1` summaries from current Compact Worklog / + Transparent Stream row hooks and feed them through the reconciler to produce a + concrete matched / mismatched answer. The adapter remains opt-in and is not + invoked by `renderMessages()` or the live SSE hot path. ## State Layers @@ -223,6 +228,28 @@ 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. +## Slice 8 Renderer Snapshot Adapter + +`HermesAssistantTurnAnchors.createAssistantTurnAnchorRendererSnapshot()` turns +current renderer rows into `renderer_snapshot_v1` summaries. The helper accepts +plain row-like objects or a DOM/root object with the existing renderer hooks: +Transparent Stream rows (`.transparent-event-row` / +`data-transparent-event-row`), Compact Worklog reasoning rows (`.wl-reason`, +`.agent-activity-thinking`, `.thinking-card-row`), and Compact Worklog tool rows +(`.tool-card-row`). + +`HermesAssistantTurnAnchors.reconcileAssistantTurnAnchorRendererSnapshot()` is +the first one-call yes/no harness: it builds or accepts a renderer snapshot, +passes its rows into `reconcileAssistantTurnAnchorActivityScene()`, and returns +`renderer_snapshot_reconciliation_v1` with `matched: true` or `matched: false` +plus the underlying mismatch diagnostics. + +This slice still does not change visible rendering. It gives later work a +bounded way to ask whether the current renderer output is equivalent to the +anchor-owned activity scene. A `matched: false` result is expected while current +renderers intentionally collapse or omit events, such as representing a tool +start + tool completion as one visible row or omitting terminal status 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 36628b69..69298201 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -1217,8 +1217,8 @@ function _activityReconciliationMismatch(kind, detail){ return Object.freeze({ - kind, ..._copyObject(detail), + kind, }); } @@ -1240,7 +1240,7 @@ } const expectedById=_activityReconciliationRowsById(expectedRows); const actualById=_activityReconciliationRowsById(actualRows); - const useRowIds=expectedById.size===expectedRows.length&&actualById.size>0; + const useRowIds=expectedById.size===expectedRows.length&&actualById.size===actualRows.length; const matchedActualIds=new Set(); _activityReconciliationDuplicateRowIds(actualRows).forEach((duplicate)=>{ mismatches.push(_activityReconciliationMismatch('duplicate_actual_row',duplicate)); @@ -1315,6 +1315,255 @@ }); } + function _rendererSnapshotMode(input, options){ + const item=(input&&typeof input==='object')?input:{}; + const opts=(options&&typeof options==='object')?options:{}; + const requested=_cleanString(_own(item,'mode'))||_cleanString(_own(opts,'mode')); + return requested==='transparent_stream'?'transparent_stream':'compact_worklog'; + } + + function _rendererSnapshotRowsInput(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 null; + } + + function _rendererSnapshotRoot(input, options){ + const item=(input&&typeof input==='object')?input:{}; + const opts=(options&&typeof options==='object')?options:{}; + return _own(item,'root')||_own(item,'turn')||_own(item,'element')|| + _own(opts,'root')||_own(opts,'turn')||_own(opts,'element')||null; + } + + function _rendererSnapshotDomRows(root, mode){ + if(!root||typeof root.querySelectorAll!=='function') return []; + const selector=mode==='transparent_stream' + ? '.transparent-event-row,[data-transparent-event-row="1"]' + : [ + '.wl-reason[data-worklog-reason-source="reasoning"]', + '.wl-reason[data-worklog-anchor-reason="1"]', + '.agent-activity-thinking', + '.thinking-card-row', + '.tool-card-row', + ].join(','); + return Array.from(root.querySelectorAll(selector)||[]); + } + + function _rendererSnapshotAttr(row, keys){ + return _activityReconciliationDataValue(row,keys); + } + + function _rendererSnapshotQueryText(row, selector){ + if(!row||typeof row.querySelector!=='function') return ''; + const el=row.querySelector(selector); + return el&&typeof el.textContent==='string'?el.textContent:''; + } + + function _rendererSnapshotText(row){ + const explicit=_firstTextValue( + _rendererSnapshotAttr(row,['text']), + _rendererSnapshotAttr(row,['preview']), + _rendererSnapshotAttr(row,['summary']), + _rendererSnapshotAttr(row,['content']) + ); + if(explicit) return explicit; + return _firstTextValue( + _rendererSnapshotQueryText(row,'.transparent-event-preview'), + _rendererSnapshotQueryText(row,'.transparent-event-thinking-preview'), + _rendererSnapshotQueryText(row,'.thinking-card-body pre'), + _rendererSnapshotQueryText(row,'.tool-card-preview'), + _rendererSnapshotQueryText(row,'.tool-card-result pre'), + typeof row.textContent==='string'?row.textContent:'' + ); + } + + function _rendererSnapshotHasClass(row, className){ + return !!(row&&row.classList&&typeof row.classList.contains==='function'&&row.classList.contains(className)); + } + + function _rendererSnapshotHasAttribute(row, attrName){ + return !!(row&&typeof row.hasAttribute==='function'&&row.hasAttribute(attrName)); + } + + function _rendererSnapshotStatus(row){ + const raw=_rendererSnapshotAttr(row,['status','state','event_status','eventStatus']); + if(raw!==undefined&&raw!==null&&raw!=='') return _activityReconciliationStatus(raw); + if(_rendererSnapshotHasClass(row,'tool-card-running')) return 'running'; + return null; + } + + function _rendererSnapshotToolDone(row, status){ + const explicit=_rendererSnapshotAttr(row,['tool_done','toolDone','done']); + const parsed=_activityReconciliationBool(explicit); + if(parsed!==null) return parsed; + if(status==='completed'||status==='error'||status==='failed') return true; + if(status==='running'||status==='pending'||status==='interrupted') return false; + return null; + } + + function _rendererSnapshotToolError(row, status){ + const explicit=_rendererSnapshotAttr(row,['tool_is_error','toolError','is_error','isError']); + const parsed=_activityReconciliationBool(explicit); + if(parsed!==null) return parsed; + if(status==='error'||status==='failed') return true; + return false; + } + + function _rendererSnapshotToolName(row){ + return _firstTextValue( + _rendererSnapshotAttr(row,['tool_name','toolName','name']), + _rendererSnapshotQueryText(row,'.tool-card-name') + )||null; + } + + function _rendererSnapshotToolCallId(row){ + return _firstTextValue( + _rendererSnapshotAttr(row,['tool_call_id','toolCallId','tool_use_id','toolUseId','call_id','callId','tid','live_tid','liveTid']) + )||null; + } + + function _rendererSnapshotRowId(row){ + return _firstTextValue( + _rendererSnapshotAttr(row,[ + 'row_id', + 'rowId', + 'activity_row_id', + 'activityRowId', + 'event_id', + 'eventId', + 'anchor_event_id', + 'anchorEventId', + 'id', + ]) + )||null; + } + + function _rendererSnapshotKindFromType(type, status, toolDone){ + if(type==='thinking'||type==='reasoning') return 'reasoning'; + if(type==='tool'||type==='tool_call'||type==='tool-card'){ + if(toolDone===true||status==='completed'||status==='error'||status==='failed') return 'tool_completed'; + return 'tool_started'; + } + if(type==='compressing'||type==='compressed') return 'lifecycle_status'; + if(type==='done'||type==='cancel'||type==='error'||type==='apperror') return 'terminal_status'; + return type||null; + } + + function _rendererSnapshotKind(row, mode, status, toolDone){ + const explicit=_cleanString(_rendererSnapshotAttr(row,['kind'])); + if(explicit) return explicit; + const type=_cleanString(_rendererSnapshotAttr(row,['event_type','eventType','type'])); + if(type) return _rendererSnapshotKindFromType(type,status,toolDone); + if(mode==='transparent_stream'){ + if(_rendererSnapshotAttr(row,['tool_name','toolName','name'])||_rendererSnapshotQueryText(row,'.tool-card-name')){ + return _rendererSnapshotKindFromType('tool',status,toolDone); + } + return 'reasoning'; + } + if(_rendererSnapshotHasClass(row,'wl-reason')||_rendererSnapshotHasClass(row,'agent-activity-thinking')||_rendererSnapshotHasClass(row,'thinking-card-row')){ + return 'reasoning'; + } + if(_rendererSnapshotHasClass(row,'tool-card-row')){ + const compressionCardValue=_rendererSnapshotAttr(row,['compression_card','compressionCard']); + const hasCompressionCard=compressionCardValue!==undefined|| + _rendererSnapshotHasAttribute(row,'data-compression-card')|| + _rendererSnapshotHasAttribute(row,'compression-card')|| + _rendererSnapshotHasAttribute(row,'compression_card'); + if(hasCompressionCard&&_activityReconciliationBool(compressionCardValue)!==false) return 'lifecycle_status'; + return _rendererSnapshotKindFromType('tool',status,toolDone); + } + return null; + } + + function _rendererSnapshotRole(kind){ + return _activityRowRole(kind||'activity'); + } + + function _rendererSnapshotSourceEventType(row, kind){ + const explicit=_cleanString(_rendererSnapshotAttr(row,['source_event_type','sourceEventType'])); + if(explicit) return explicit; + const type=_cleanString(_rendererSnapshotAttr(row,['event_type','eventType','type'])); + if(type==='thinking') return 'reasoning'; + if(type==='tool') return kind==='tool_completed'?'tool_complete':'tool'; + if(kind==='reasoning') return 'reasoning'; + if(kind==='tool_started') return 'tool'; + if(kind==='tool_completed') return 'tool_complete'; + if(kind==='lifecycle_status') return type||'compressed'; + if(kind==='terminal_status') return type||'done'; + return type||null; + } + + function _rendererSnapshotRow(row, index, mode){ + const status=_rendererSnapshotStatus(row); + const provisionalDone=_rendererSnapshotToolDone(row,status); + const kind=_rendererSnapshotKind(row,mode,status,provisionalDone); + const toolDone=_isToolActivityKind(kind)?_rendererSnapshotToolDone(row,status):null; + const toolError=_isToolActivityKind(kind)?_rendererSnapshotToolError(row,status):null; + const sourceEventType=_rendererSnapshotSourceEventType(row,kind); + return Object.freeze({ + row_id:_rendererSnapshotRowId(row), + order_index:index, + kind, + role:_rendererSnapshotRole(kind), + source_event_type:sourceEventType, + status, + text:_rendererSnapshotText(row), + tool_call_id:_isToolActivityKind(kind)?_rendererSnapshotToolCallId(row):null, + tool_name:_isToolActivityKind(kind)?_rendererSnapshotToolName(row):null, + tool_done:toolDone, + tool_is_error:toolError, + }); + } + + function createAssistantTurnAnchorRendererSnapshot(input, options){ + const item=(input&&typeof input==='object')?input:{}; + const opts=(options&&typeof options==='object')?options:{}; + const mode=_rendererSnapshotMode(item,opts); + const renderer=_cleanString(_own(item,'renderer'))||_cleanString(_own(opts,'renderer'))||mode; + const explicitRows=_rendererSnapshotRowsInput(item,opts); + const rows=(explicitRows||_rendererSnapshotDomRows(_rendererSnapshotRoot(item,opts),mode)) + .map((row,index)=>_rendererSnapshotRow(row,index,mode)); + return Object.freeze({ + version:'renderer_snapshot_v1', + mode, + renderer, + row_count:rows.length, + rows:Object.freeze(rows), + }); + } + + function reconcileAssistantTurnAnchorRendererSnapshot(input, options){ + const item=(input&&typeof input==='object')?input:{}; + const opts=(options&&typeof options==='object')?options:{}; + const snapshotInput=_own(item,'renderer_snapshot')||_own(item,'snapshot')||_own(opts,'renderer_snapshot')||_own(opts,'snapshot'); + const snapshot=(snapshotInput&&typeof snapshotInput==='object'&&_own(snapshotInput,'version')==='renderer_snapshot_v1') + ? snapshotInput + : createAssistantTurnAnchorRendererSnapshot(item,opts); + const reconciliation=reconcileAssistantTurnAnchorActivityScene({ + ...item, + mode:snapshot.mode, + renderer_rows:snapshot.rows, + },opts); + return Object.freeze({ + version:'renderer_snapshot_reconciliation_v1', + mode:snapshot.mode, + renderer:snapshot.renderer, + matched:reconciliation.matched, + snapshot, + reconciliation, + }); + } + function createAssistantTurnAnchorSeed(input){ const opts=(input&&typeof input==='object')?input:{}; const sessionId=_cleanString(opts.session_id); @@ -1374,7 +1623,7 @@ } ROOT.HermesAssistantTurnAnchors=Object.freeze({ - version:'slice7-dual-run-reconciler', + version:'slice8-renderer-snapshot-adapter', activityEventKinds:ACTIVITY_EVENT_KINDS, stateLayers:STATE_LAYERS, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, @@ -1394,6 +1643,8 @@ projectAssistantTurnAnchorSettledMessageFinalAnswer, projectAssistantTurnAnchorActivityScene, reconcileAssistantTurnAnchorActivityScene, + createAssistantTurnAnchorRendererSnapshot, + reconcileAssistantTurnAnchorRendererSnapshot, isAssistantTurnAnchorActivityKind, }); })(); diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index a07504f7..80c73263 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"] == "slice7-dual-run-reconciler" + assert data["version"] == "slice8-renderer-snapshot-adapter" 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 2fc24c31..b7707e27 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -280,12 +280,156 @@ const duplicated = api.reconcileAssistantTurnAnchorActivityScene({{ scene, renderer_rows:duplicatedRows, }}); +const mixedIdRows = rendererRows.map((row) => ({{...row}})); +delete mixedIdRows[1].row_id; +const mixedIds = api.reconcileAssistantTurnAnchorActivityScene({{ + scene, + renderer_rows:mixedIdRows, +}}); console.log(JSON.stringify({{ version:api.version, scene, matched, mismatched, duplicated, + mixedIds, +}})); +""" + 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 _renderer_snapshot_adapter_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; + +function node(attrs, text, children, classes) {{ + const attrMap = attrs || {{}}; + const classSet = new Set(classes || []); + return {{ + textContent: text || '', + dataset: {{}}, + classList: {{ + contains(name) {{ return classSet.has(name); }}, + }}, + getAttribute(name) {{ + return Object.prototype.hasOwnProperty.call(attrMap, name) ? attrMap[name] : null; + }}, + hasAttribute(name) {{ + return Object.prototype.hasOwnProperty.call(attrMap, name); + }}, + querySelector(selector) {{ + return children && children[selector] || null; + }}, + }}; +}} + +const registry = api.createAssistantTurnAnchorRegistry({{ + session_id:'sid-renderer', + turn_id:'turn-renderer', +}}); +api.applyAssistantTurnAnchorSourceEvents(registry, [ + {{event:'reasoning', payload:{{text:'thinking'}}, event_id:'run-renderer:1', seq:1}}, + {{event:'tool', payload:{{tool_call_id:'tool-1', name:'terminal', args:{{command:'rg anchor'}}}}, event_id:'run-renderer:2', seq:2}}, + {{event:'tool_complete', payload:{{tool_call_id:'tool-1', name:'terminal', result:'ok', is_error:false}}, event_id:'run-renderer:3', seq:3}}, + {{event:'done', payload:{{status:'done'}}, event_id:'run-renderer:4', seq:4}}, +], {{run_id:'run-renderer', stream_id:'stream-renderer'}}); +const scene = api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'transparent_stream'}}); +const transparentRows = [ + node({{ + 'data-transparent-event-row':'1', + 'data-event-type':'thinking', + 'data-event-id':'run-renderer:1', + }}, '', {{ + '.thinking-card-body pre': {{textContent:'thinking'}}, + '.transparent-event-preview': {{textContent:'thinking'}}, + }}, ['transparent-event-row']), + node({{ + 'data-transparent-event-row':'1', + 'data-event-type':'tool', + 'data-event-status':'Completed', + 'data-event-id':'run-renderer:3', + 'data-tool-name':'terminal', + 'data-live-tid':'tool-1', + }}, '', {{ + '.tool-card-name': {{textContent:'terminal'}}, + '.tool-card-preview': {{textContent:'Completed'}}, + }}, ['transparent-event-row','tool-card-row']), +]; +const transparentRoot = {{ + querySelectorAll(selector) {{ + if (selector.includes('transparent-event-row')) return transparentRows; + return []; + }}, +}}; +const transparentSnapshot = api.createAssistantTurnAnchorRendererSnapshot({{ + root:transparentRoot, + mode:'transparent_stream', + renderer:'transparent_stream', +}}); +const transparentReconciliation = api.reconcileAssistantTurnAnchorRendererSnapshot({{ + scene, + renderer_snapshot:transparentSnapshot, +}}); + +const matchingSnapshot = api.createAssistantTurnAnchorRendererSnapshot({{ + mode:'transparent_stream', + rows: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 matchingReconciliation = api.reconcileAssistantTurnAnchorRendererSnapshot({{ + scene, + renderer_snapshot:matchingSnapshot, +}}); + +const compactRows = [ + node({{'data-worklog-reason-source':'reasoning'}}, '', {{ + '.thinking-card-body pre': {{textContent:'compact thinking'}}, + }}, ['wl-reason']), + node({{'data-tool-name':'terminal','data-tool-done':'false','data-tool-error':'false','data-live-tid':'tool-live'}}, '', {{ + '.tool-card-name': {{textContent:'terminal'}}, + '.tool-card-preview': {{textContent:'Running'}}, + }}, ['tool-card-row']), +]; +const compactSnapshot = api.createAssistantTurnAnchorRendererSnapshot({{ + mode:'compact_worklog', + rows:compactRows, +}}); +const compressionSnapshot = api.createAssistantTurnAnchorRendererSnapshot({{ + mode:'compact_worklog', + rows:[ + node({{'data-compression-card':''}}, 'Compression updated', {{}}, ['tool-card-row']), + node({{'data-compression-card':'false','data-tool-name':'terminal'}}, '', {{ + '.tool-card-name': {{textContent:'terminal'}}, + }}, ['tool-card-row']), + ], +}}); + +console.log(JSON.stringify({{ + version:api.version, + transparentSnapshot, + transparentReconciliation, + matchingSnapshot, + matchingReconciliation, + compactSnapshot, + compressionSnapshot, }})); """ result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False) @@ -523,7 +667,7 @@ def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events(): registry = data["registry"] anchor = registry["anchor"] - assert data["version"] == "slice7-dual-run-reconciler" + assert data["version"] == "slice8-renderer-snapshot-adapter" assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"] assert registry["event_index"]["dedupe_keys"][:2] == [ 'event_id:"run-1:1"', @@ -623,7 +767,7 @@ def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle(): registry = data["toolRegistry"] anchor = registry["anchor"] - assert data["version"] == "slice7-dual-run-reconciler" + assert data["version"] == "slice8-renderer-snapshot-adapter" assert data["toolResults"] == [ {"applied": True, "reason": None}, {"applied": True, "reason": None}, @@ -673,7 +817,7 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner() registry = data["registry"] anchor = registry["anchor"] - assert data["version"] == "slice7-dual-run-reconciler" + assert data["version"] == "slice8-renderer-snapshot-adapter" assert data["results"]["live"] == [{"applied": True, "reason": None}] assert data["results"]["replay"] == [ {"applied": False, "reason": "duplicate"}, @@ -701,7 +845,7 @@ def test_activity_scene_projects_current_activity_events_for_both_render_modes() compact = data["compact"] transparent = data["transparent"] - assert data["version"] == "slice7-dual-run-reconciler" + assert data["version"] == "slice8-renderer-snapshot-adapter" assert compact["version"] == "activity_scene_v1" assert transparent["version"] == "activity_scene_v1" assert compact["mode"] == "compact_worklog" @@ -826,7 +970,7 @@ 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 data["version"] == "slice8-renderer-snapshot-adapter" assert matched["version"] == "activity_scene_reconciliation_v1" assert matched["scene_version"] == "activity_scene_v1" assert matched["mode"] == "transparent_stream" @@ -901,7 +1045,8 @@ def test_activity_scene_reconciler_reports_duplicate_renderer_row_ids(): assert duplicated["matched"] is False assert "duplicate_actual_row" in kinds - assert "missing_actual_row" in kinds + assert "missing_actual_row" not in kinds + assert "field_mismatch" in kinds duplicates = [ item for item in duplicated["mismatches"] if item["kind"] == "duplicate_actual_row" @@ -914,11 +1059,108 @@ def test_activity_scene_reconciler_reports_duplicate_renderer_row_ids(): "row": duplicated["actual_rows"][2], } ] + mismatched_fields = [ + item["field"] for item in duplicated["mismatches"] + if item["kind"] == "field_mismatch" + ] + assert "kind" in mismatched_fields + assert "tool_done" in mismatched_fields + + +def test_activity_scene_reconciler_uses_index_matching_for_partial_actual_row_ids(): + data = _activity_scene_reconciliation_snapshot() + mixed = data["mixedIds"] + + assert mixed["matched"] is True + assert mixed["summary"] == { + "expected_count": 4, + "actual_count": 4, + "mismatch_count": 0, + } + assert mixed["actual_rows"][1]["row_id"] is None + + +def test_renderer_snapshot_adapter_extracts_current_renderer_rows(): + data = _renderer_snapshot_adapter_snapshot() + transparent = data["transparentSnapshot"] + compact = data["compactSnapshot"] + compression = data["compressionSnapshot"] + + assert data["version"] == "slice8-renderer-snapshot-adapter" + assert transparent["version"] == "renderer_snapshot_v1" + assert transparent["mode"] == "transparent_stream" + assert transparent["renderer"] == "transparent_stream" + assert transparent["row_count"] == 2 + assert transparent["rows"][0] == { + "row_id": "run-renderer:1", + "order_index": 0, + "kind": "reasoning", + "role": "thinking", + "source_event_type": "reasoning", + "status": None, + "text": "thinking", + "tool_call_id": None, + "tool_name": None, + "tool_done": None, + "tool_is_error": None, + } + assert transparent["rows"][1] == { + "row_id": "run-renderer:3", + "order_index": 1, + "kind": "tool_completed", + "role": "tool", + "source_event_type": "tool_complete", + "status": "completed", + "text": "Completed", + "tool_call_id": "tool-1", + "tool_name": "terminal", + "tool_done": True, + "tool_is_error": False, + } + + assert compact["mode"] == "compact_worklog" + assert compact["rows"][0]["kind"] == "reasoning" + assert compact["rows"][0]["text"] == "compact thinking" + assert compact["rows"][1]["kind"] == "tool_started" + assert compact["rows"][1]["status"] is None + assert compact["rows"][1]["tool_done"] is False + assert compact["rows"][1]["tool_call_id"] == "tool-live" + + assert compression["rows"][0]["kind"] == "lifecycle_status" + assert compression["rows"][0]["source_event_type"] == "compressed" + assert compression["rows"][1]["kind"] == "tool_started" + assert compression["rows"][1]["tool_name"] == "terminal" + + +def test_renderer_snapshot_reconciliation_produces_yes_or_no_answer(): + data = _renderer_snapshot_adapter_snapshot() + matching = data["matchingReconciliation"] + transparent = data["transparentReconciliation"] + + assert matching["version"] == "renderer_snapshot_reconciliation_v1" + assert matching["matched"] is True + assert matching["reconciliation"]["summary"] == { + "expected_count": 4, + "actual_count": 4, + "mismatch_count": 0, + } + + assert transparent["version"] == "renderer_snapshot_reconciliation_v1" + assert transparent["matched"] is False + assert transparent["snapshot"]["row_count"] == 2 + assert transparent["reconciliation"]["summary"]["expected_count"] == 4 + assert transparent["reconciliation"]["summary"]["actual_count"] == 2 + kinds = [item["kind"] for item in transparent["reconciliation"]["mismatches"]] + assert "row_count" in kinds + assert "missing_actual_row" in kinds missing = [ - item for item in duplicated["mismatches"] + item for item in transparent["reconciliation"]["mismatches"] if item["kind"] == "missing_actual_row" ] - assert missing[0]["row_id"] == "run-reconcile:3" + assert [item["row_id"] for item in missing] == [ + "run-renderer:2", + "run-renderer:4", + ] def test_final_projection_routes_settled_assistant_message_through_anchor_owner(): @@ -927,7 +1169,7 @@ def test_final_projection_routes_settled_assistant_message_through_anchor_owner( registry = projected["registry"] anchor = registry["anchor"] - assert data["version"] == "slice7-dual-run-reconciler" + assert data["version"] == "slice8-renderer-snapshot-adapter" assert projected["applied"] is True assert projected["reason"] is None assert projected["final_message_ref"] == "message-final"