Add assistant turn anchor renderer snapshot adapter
This commit is contained in:
1 parent
bba5631a69
commit
409fb59760
5 files changed
+537
-13
No files matched your search
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
## [v0.51.389] — 2026-06-13 — Release NB (surface dirty-install state in update check, #4085)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ streaming or rendering yet.
|
|||||||
`S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
|
`S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
|
||||||
projection/cache layers outside the settled final-prose path and the live
|
projection/cache layers outside the settled final-prose path and the live
|
||||||
shadow registry.
|
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
|
## 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
|
DOM continuity continue to render exactly as before until a later replacement
|
||||||
slice deliberately switches a renderer to anchor-owned rows.
|
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
|
## Source Event Classification
|
||||||
|
|
||||||
Phase 0 classifies current sources before changing render behavior:
|
Phase 0 classifies current sources before changing render behavior:
|
||||||
|
|||||||
@@ -1217,8 +1217,8 @@
|
|||||||
|
|
||||||
function _activityReconciliationMismatch(kind, detail){
|
function _activityReconciliationMismatch(kind, detail){
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
kind,
|
|
||||||
..._copyObject(detail),
|
..._copyObject(detail),
|
||||||
|
kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,7 +1240,7 @@
|
|||||||
}
|
}
|
||||||
const expectedById=_activityReconciliationRowsById(expectedRows);
|
const expectedById=_activityReconciliationRowsById(expectedRows);
|
||||||
const actualById=_activityReconciliationRowsById(actualRows);
|
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();
|
const matchedActualIds=new Set();
|
||||||
_activityReconciliationDuplicateRowIds(actualRows).forEach((duplicate)=>{
|
_activityReconciliationDuplicateRowIds(actualRows).forEach((duplicate)=>{
|
||||||
mismatches.push(_activityReconciliationMismatch('duplicate_actual_row',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){
|
function createAssistantTurnAnchorSeed(input){
|
||||||
const opts=(input&&typeof input==='object')?input:{};
|
const opts=(input&&typeof input==='object')?input:{};
|
||||||
const sessionId=_cleanString(opts.session_id);
|
const sessionId=_cleanString(opts.session_id);
|
||||||
@@ -1374,7 +1623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ROOT.HermesAssistantTurnAnchors=Object.freeze({
|
ROOT.HermesAssistantTurnAnchors=Object.freeze({
|
||||||
version:'slice7-dual-run-reconciler',
|
version:'slice8-renderer-snapshot-adapter',
|
||||||
activityEventKinds:ACTIVITY_EVENT_KINDS,
|
activityEventKinds:ACTIVITY_EVENT_KINDS,
|
||||||
stateLayers:STATE_LAYERS,
|
stateLayers:STATE_LAYERS,
|
||||||
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
|
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
|
||||||
@@ -1394,6 +1643,8 @@
|
|||||||
projectAssistantTurnAnchorSettledMessageFinalAnswer,
|
projectAssistantTurnAnchorSettledMessageFinalAnswer,
|
||||||
projectAssistantTurnAnchorActivityScene,
|
projectAssistantTurnAnchorActivityScene,
|
||||||
reconcileAssistantTurnAnchorActivityScene,
|
reconcileAssistantTurnAnchorActivityScene,
|
||||||
|
createAssistantTurnAnchorRendererSnapshot,
|
||||||
|
reconcileAssistantTurnAnchorRendererSnapshot,
|
||||||
isAssistantTurnAnchorActivityKind,
|
isAssistantTurnAnchorActivityKind,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -182,7 +182,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity():
|
|||||||
live = data["liveToken"]
|
live = data["liveToken"]
|
||||||
replay = data["replayToken"]
|
replay = data["replayToken"]
|
||||||
|
|
||||||
assert data["version"] == "slice7-dual-run-reconciler"
|
assert data["version"] == "slice8-renderer-snapshot-adapter"
|
||||||
assert live["classification"] == "activity"
|
assert live["classification"] == "activity"
|
||||||
assert live["dedupe_key"] == 'event_id:"run-1:7"'
|
assert live["dedupe_key"] == 'event_id:"run-1:7"'
|
||||||
assert replay["dedupe_key"] == live["dedupe_key"]
|
assert replay["dedupe_key"] == live["dedupe_key"]
|
||||||
|
|||||||
@@ -280,12 +280,156 @@ const duplicated = api.reconcileAssistantTurnAnchorActivityScene({{
|
|||||||
scene,
|
scene,
|
||||||
renderer_rows:duplicatedRows,
|
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({{
|
console.log(JSON.stringify({{
|
||||||
version:api.version,
|
version:api.version,
|
||||||
scene,
|
scene,
|
||||||
matched,
|
matched,
|
||||||
mismatched,
|
mismatched,
|
||||||
duplicated,
|
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)
|
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"]
|
registry = data["registry"]
|
||||||
anchor = registry["anchor"]
|
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 [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
|
||||||
assert registry["event_index"]["dedupe_keys"][:2] == [
|
assert registry["event_index"]["dedupe_keys"][:2] == [
|
||||||
'event_id:"run-1:1"',
|
'event_id:"run-1:1"',
|
||||||
@@ -623,7 +767,7 @@ def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle():
|
|||||||
registry = data["toolRegistry"]
|
registry = data["toolRegistry"]
|
||||||
anchor = registry["anchor"]
|
anchor = registry["anchor"]
|
||||||
|
|
||||||
assert data["version"] == "slice7-dual-run-reconciler"
|
assert data["version"] == "slice8-renderer-snapshot-adapter"
|
||||||
assert data["toolResults"] == [
|
assert data["toolResults"] == [
|
||||||
{"applied": True, "reason": None},
|
{"applied": True, "reason": None},
|
||||||
{"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"]
|
registry = data["registry"]
|
||||||
anchor = registry["anchor"]
|
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"]["live"] == [{"applied": True, "reason": None}]
|
||||||
assert data["results"]["replay"] == [
|
assert data["results"]["replay"] == [
|
||||||
{"applied": False, "reason": "duplicate"},
|
{"applied": False, "reason": "duplicate"},
|
||||||
@@ -701,7 +845,7 @@ def test_activity_scene_projects_current_activity_events_for_both_render_modes()
|
|||||||
compact = data["compact"]
|
compact = data["compact"]
|
||||||
transparent = data["transparent"]
|
transparent = data["transparent"]
|
||||||
|
|
||||||
assert data["version"] == "slice7-dual-run-reconciler"
|
assert data["version"] == "slice8-renderer-snapshot-adapter"
|
||||||
assert compact["version"] == "activity_scene_v1"
|
assert compact["version"] == "activity_scene_v1"
|
||||||
assert transparent["version"] == "activity_scene_v1"
|
assert transparent["version"] == "activity_scene_v1"
|
||||||
assert compact["mode"] == "compact_worklog"
|
assert compact["mode"] == "compact_worklog"
|
||||||
@@ -826,7 +970,7 @@ def test_activity_scene_reconciler_matches_renderer_snapshot_rows():
|
|||||||
data = _activity_scene_reconciliation_snapshot()
|
data = _activity_scene_reconciliation_snapshot()
|
||||||
matched = data["matched"]
|
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["version"] == "activity_scene_reconciliation_v1"
|
||||||
assert matched["scene_version"] == "activity_scene_v1"
|
assert matched["scene_version"] == "activity_scene_v1"
|
||||||
assert matched["mode"] == "transparent_stream"
|
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 duplicated["matched"] is False
|
||||||
assert "duplicate_actual_row" in kinds
|
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 = [
|
duplicates = [
|
||||||
item for item in duplicated["mismatches"]
|
item for item in duplicated["mismatches"]
|
||||||
if item["kind"] == "duplicate_actual_row"
|
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],
|
"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 = [
|
missing = [
|
||||||
item for item in duplicated["mismatches"]
|
item for item in transparent["reconciliation"]["mismatches"]
|
||||||
if item["kind"] == "missing_actual_row"
|
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():
|
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"]
|
registry = projected["registry"]
|
||||||
anchor = registry["anchor"]
|
anchor = registry["anchor"]
|
||||||
|
|
||||||
assert data["version"] == "slice7-dual-run-reconciler"
|
assert data["version"] == "slice8-renderer-snapshot-adapter"
|
||||||
assert projected["applied"] is True
|
assert projected["applied"] is True
|
||||||
assert projected["reason"] is None
|
assert projected["reason"] is None
|
||||||
assert projected["final_message_ref"] == "message-final"
|
assert projected["final_message_ref"] == "message-final"
|
||||||
|
|||||||
Reference in new issue
Block a user