Merge #4092 (assistant turn anchor settled final projection) onto master

This commit is contained in:
nesquena-hermes
2026-06-13 05:36:00 +00:00
6 changed files with 275 additions and 14 deletions

View File

@@ -21,9 +21,12 @@ streaming or rendering yet.
anchor seed excludes renderer presentation state, terminal states are exposed
as constants with alias normalization, and replay + settlement ordering is
pinned by tests before visible wiring begins.
- The next independently reviewable boundary is Phase 3 settlement through the
anchor owner. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain
projection/cache layers until that wiring lands.
- Slice 4 starts RFC Phase 3 by routing settled assistant final prose through the
anchor owner before `renderMessages()` renders the final assistant body.
- The next independently reviewable boundary is activity/render-scene projection
for reasoning, tool rows, and transparent-stream/worklog metadata. `S.messages`,
`INFLIGHT`, stream-local state, and DOM nodes remain projection/cache layers
outside the settled final-prose path.
## State Layers
@@ -114,6 +117,25 @@ event identity, lifecycle, final answer reference, and activity events.
until the matching field is explicitly moved. The fallback order is journal
replay first, settled transcript second, `INFLIGHT` only for gaps.
## Slice 4 Settled Final Projection
`HermesAssistantTurnAnchors.projectAssistantTurnAnchorSettledMessageFinalAnswer()`
projects one settled assistant transcript message through a local anchor
registry. The settled transcript message reference remains the semantic
authority (`content.final_message_ref`); `content.final_answer` is a derived
render snapshot for the existing markdown pipeline.
`renderMessages()` uses that projection only for settled assistant messages
(`!isUser && !m._live`) and only after preserving the current content-array
flattening behavior. It then continues through the existing inline-thinking and
markdown rendering pipeline. If the anchor helper is unavailable or cannot
produce a final answer, `renderMessages()` falls back to the existing message
content path.
This is intentionally narrower than render-scene ownership: live stream tokens,
replay hydration, worklog rows, transparent-stream rows, tool cards, `INFLIGHT`,
and DOM continuity are still not consumed by the anchor registry in this slice.
## Source Event Classification
Phase 0 classifies current sources before changing render behavior:

View File

@@ -1,9 +1,9 @@
// Stable Assistant Turn Anchors scaffold (#3926).
//
// This file is intentionally inert: it defines the current ownership inventory,
// event classifications, and small owner helpers, but it does not register
// anchors globally or change any renderer. Later phases can wire these helpers into
// send(), attachLiveStream(), replay hydration, and renderMessages().
// This file defines the current ownership inventory, event classifications, and
// small owner helpers. It does not register anchors globally. The only renderer
// wiring in this slice is settled assistant final-answer projection; live
// streaming, replay hydration, tools, and DOM ownership remain unwired.
(function(){
const ROOT=(typeof window!=='undefined')?window:globalThis;
@@ -696,6 +696,96 @@
});
}
function _contextValue(context, keys){
return _firstOwn(context&&typeof context==='object'?context:{},keys);
}
function _messageValue(message, keys){
return _firstOwn(message&&typeof message==='object'?message:{},keys);
}
function _rawIndexMessageRef(context){
const rawIdx=_contextValue(context,['raw_idx','rawIdx']);
if(rawIdx===undefined||rawIdx===null||rawIdx==='') return '';
return 'raw_idx:'+String(rawIdx);
}
function projectAssistantTurnAnchorSettledMessageFinalAnswer(input, context){
const message=(input&&typeof input==='object')?input:{};
const ctx=(context&&typeof context==='object')?context:{};
const sessionId=_cleanString(_contextValue(ctx,['session_id','sessionId']))
||_cleanString(_messageValue(message,['session_id','sessionId']));
if(!sessionId){
return Object.freeze({applied:false,reason:'missing_session',final_answer:'',final_message_ref:null,registry:null});
}
const role=_cleanString(_messageValue(message,['role']))||'assistant';
if(role&&role!=='assistant'){
return Object.freeze({applied:false,reason:'non_assistant',final_answer:'',final_message_ref:null,registry:null});
}
const runId=_cleanString(_contextValue(ctx,['run_id','runId']))
||_cleanString(_messageValue(message,['run_id','runId','_run_id','runtime_run_id']));
const streamId=_cleanString(_contextValue(ctx,['stream_id','streamId']))
||_cleanString(_messageValue(message,['stream_id','streamId','_stream_id']));
const messageRef=_firstTextValue(
_messageValue(message,['message_id','id','local_id']),
_contextValue(ctx,['message_id','messageId','local_id','localId'])
)||_rawIndexMessageRef(ctx);
const turnId=_cleanString(_contextValue(ctx,['turn_id','turnId']))
||_cleanString(_messageValue(message,['turn_id','turnId']))
||[
'settled',
sessionId,
runId||streamId||messageRef||'assistant',
].join(':');
const registry=createAssistantTurnAnchorRegistry({
session_id:sessionId,
turn_id:turnId,
run_id:runId||null,
stream_id:streamId||null,
local_id:messageRef||null,
source_message_refs:messageRef?[messageRef]:[],
});
const payload={
role:'assistant',
id:messageRef||null,
content:_hasOwn(ctx,'content')?_own(ctx,'content'):_own(message,'content'),
};
const usage=_own(message,'usage');
const turnUsage=_own(message,'_turnUsage');
if(usage&&typeof usage==='object') payload.usage=usage;
if(turnUsage&&typeof turnUsage==='object') payload._turnUsage=turnUsage;
const result=applyAssistantTurnAnchorSourceEvent(registry,{
source_type:'settled_message',
payload,
local_id:messageRef||null,
},{
session_id:sessionId,
turn_id:turnId,
run_id:runId||null,
stream_id:streamId||null,
});
if(!result.applied){
return Object.freeze({
applied:false,
reason:result.reason||null,
final_answer:'',
final_message_ref:null,
registry,
});
}
const rawFinalAnswer=registry.anchor&&registry.anchor.content&&registry.anchor.content.final_answer;
const rawFinalMessageRef=registry.anchor&&registry.anchor.content&&registry.anchor.content.final_message_ref;
const finalAnswer=typeof rawFinalAnswer==='string'?rawFinalAnswer:'';
const finalMessageRef=typeof rawFinalMessageRef==='string'?rawFinalMessageRef:null;
return Object.freeze({
applied:!!result.applied,
reason:result.reason||null,
final_answer:finalAnswer,
final_message_ref:finalMessageRef,
registry,
});
}
function createAssistantTurnAnchorSeed(input){
const opts=(input&&typeof input==='object')?input:{};
const sessionId=_cleanString(opts.session_id);
@@ -755,7 +845,7 @@
}
ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'slice3-registry-shadow',
version:'slice4-final-projection',
activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
@@ -772,6 +862,7 @@
applyAssistantTurnAnchorSourceEvent,
applyAssistantTurnAnchorSourceEvents,
createAssistantTurnAnchorShadowSnapshot,
projectAssistantTurnAnchorSettledMessageFinalAnswer,
isAssistantTurnAnchorActivityKind,
});
})();

View File

@@ -8747,6 +8747,26 @@ function _renderMessagesWithScrollSnapshot(options){
renderMessages({...(options||{}),preserveScroll:true});
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
}
let _assistantTurnAnchorSettledFinalAnswerWarned=false;
function _assistantTurnAnchorSettledFinalAnswer(message, content, context){
try{
const api=(typeof window!=='undefined')?window.HermesAssistantTurnAnchors:null;
if(!api||typeof api.projectAssistantTurnAnchorSettledMessageFinalAnswer!=='function') return null;
const result=api.projectAssistantTurnAnchorSettledMessageFinalAnswer(message,{
session_id:context&&context.session_id,
raw_idx:context&&context.raw_idx,
content,
});
const finalAnswer=result&&typeof result.final_answer==='string'?result.final_answer:'';
return finalAnswer?finalAnswer:null;
}catch(err){
if(!_assistantTurnAnchorSettledFinalAnswerWarned&&typeof console!=='undefined'&&console.warn){
_assistantTurnAnchorSettledFinalAnswerWarned=true;
console.warn('assistant turn anchor settled-final projection failed',err);
}
return null;
}
}
function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
// Terminal stream renders can happen after S.activeStreamId is cleared.
// In that case, preserveScroll asks the normal pin-state helper to decide:
@@ -9077,6 +9097,13 @@ function renderMessages(options){
if(Array.isArray(content)){
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
if(m.role==='assistant'&&!m._live&&typeof content==='string'){
const anchorFinal=_assistantTurnAnchorSettledFinalAnswer(m, content, {
session_id:sid,
raw_idx:rawIdx,
});
if(anchorFinal!==null) content=anchorFinal;
}
if(typeof content==='string'){
if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){
const split=window._extractInlineThinkingFromContentForRender(content, thinkingText);

View File

@@ -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"] == "slice3-registry-shadow"
assert data["version"] == "slice4-final-projection"
assert live["classification"] == "activity"
assert live["dedupe_key"] == 'event_id:"run-1:7"'
assert replay["dedupe_key"] == live["dedupe_key"]

View File

@@ -88,8 +88,11 @@ def test_phase0_scaffold_is_loaded_before_current_rendering_modules():
messages_pos = html.index('static/messages.js?v=__WEBUI_VERSION__')
assert anchor_pos < ui_pos < sessions_pos < messages_pos
ui_src = _read(UI_JS)
assert "'./static/assistant_turn_anchors.js' + VQ" in _read(SW_JS)
assert "HermesAssistantTurnAnchors" not in _read(UI_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in ui_src
assert "createAssistantTurnAnchorRegistry" not in ui_src
assert "applyAssistantTurnAnchorSourceEvent" not in ui_src
assert "HermesAssistantTurnAnchors" not in _read(SESSIONS_JS)
assert "HermesAssistantTurnAnchors" not in _read(MESSAGES_JS)

View File

@@ -112,6 +112,59 @@ console.log(JSON.stringify({{
return json.loads(result.stdout)
def _final_projection_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 projected = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'assistant',
id:'message-final',
content:'raw content should be replaced by render-preserved content',
_turnUsage:{{input_tokens:8, output_tokens:13}},
}}, {{
session_id:'sid-project',
raw_idx:7,
content:'line one\\nline two',
}});
const projectedByRawIdx = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'assistant',
content:'message without id',
}}, {{
session_id:'sid-project',
raw_idx:11,
content:'raw index final',
}});
const missingSession = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'assistant',
id:'message-missing-session',
content:'final',
}}, {{}});
const nonAssistant = api.projectAssistantTurnAnchorSettledMessageFinalAnswer({{
role:'user',
id:'message-user',
content:'user text',
}}, {{
session_id:'sid-project',
}});
console.log(JSON.stringify({{
version:api.version,
projected,
projectedByRawIdx,
missingSession,
nonAssistant,
}}));
"""
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 _hardening_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
@@ -289,7 +342,7 @@ def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events():
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry-shadow"
assert data["version"] == "slice4-final-projection"
assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
assert registry["event_index"]["dedupe_keys"][:2] == [
'event_id:"run-1:1"',
@@ -389,7 +442,7 @@ def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle():
registry = data["toolRegistry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry-shadow"
assert data["version"] == "slice4-final-projection"
assert data["toolResults"] == [
{"applied": True, "reason": None},
{"applied": True, "reason": None},
@@ -439,7 +492,7 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner()
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry-shadow"
assert data["version"] == "slice4-final-projection"
assert data["results"]["live"] == [{"applied": True, "reason": None}]
assert data["results"]["replay"] == [
{"applied": False, "reason": "duplicate"},
@@ -463,6 +516,70 @@ def test_shadow_snapshot_feeds_current_source_families_into_one_registry_owner()
assert anchor["content"]["final_answer"] == "shadow final"
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"] == "slice4-final-projection"
assert projected["applied"] is True
assert projected["reason"] is None
assert projected["final_message_ref"] == "message-final"
assert projected["final_answer"] == "line one\nline two"
assert registry["stats"]["applied"] == 1
assert anchor["identity"]["session_id"] == "sid-project"
assert anchor["content"]["final_answer"] == "line one\nline two"
assert anchor["content"]["final_message_ref"] == "message-final"
assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13}
assert [event["source_event_type"] for event in anchor["metadata_events"]] == [
"settled_message",
]
assert data["projectedByRawIdx"]["final_message_ref"] == "raw_idx:11"
assert data["projectedByRawIdx"]["registry"]["anchor"]["identity"][
"source_message_refs"
] == ["raw_idx:11"]
anchor_src = _read(ANCHORS_JS)
assert "if(!result.applied)" in anchor_src
def test_final_projection_is_scoped_to_settled_assistant_messages():
data = _final_projection_snapshot()
assert data["missingSession"] == {
"applied": False,
"reason": "missing_session",
"final_answer": "",
"final_message_ref": None,
"registry": None,
}
assert data["nonAssistant"] == {
"applied": False,
"reason": "non_assistant",
"final_answer": "",
"final_message_ref": None,
"registry": None,
}
def test_render_messages_uses_anchor_projection_only_for_settled_final_prose():
src = _read(UI_JS)
start = src.index("function renderMessages")
end = src.index("function _toolDisplayName", start)
render_body = src[start:end]
flatten_idx = render_body.index("content=content.filter(p=>p&&p.type==='text')")
projection_idx = render_body.index("_assistantTurnAnchorSettledFinalAnswer(m, content")
thinking_idx = render_body.index("_extractInlineThinkingFromContentForRender(content")
assert flatten_idx < projection_idx < thinking_idx
assert "if(m.role==='assistant'&&!m._live&&typeof content==='string'){" in render_body
assert "createAssistantTurnAnchorRegistry" not in render_body
assert "applyAssistantTurnAnchorSourceEvent" not in render_body
assert "_assistantTurnAnchorSettledFinalAnswerWarned" in src
assert "console.warn('assistant turn anchor settled-final projection failed',err)" in src
def test_registry_instances_do_not_share_owner_state():
data = _registry_snapshot()
isolated = data["isolated"]
@@ -473,7 +590,7 @@ def test_registry_instances_do_not_share_owner_state():
assert isolated["anchor"]["activity_events"] == []
def test_slice3_registry_is_still_unwired_from_rendering_hot_paths():
def test_slice4_projection_does_not_wire_registry_into_rendering_hot_paths():
helper_names = [
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorNormalizedEvent",
@@ -485,3 +602,4 @@ def test_slice3_registry_is_still_unwired_from_rendering_hot_paths():
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS)