feat(anchor): project settled final answer through anchor

This commit is contained in:
Frank Song committed 2026-06-13 13:23:15 +08:00
1 parent c2ecb47554
commit b990bd09dd
6 files changed
+275 -14

No files matched your search

@@ -21,9 +21,12 @@ streaming or rendering yet.
anchor seed excludes renderer presentation state, terminal states are exposed anchor seed excludes renderer presentation state, terminal states are exposed
as constants with alias normalization, and replay + settlement ordering is as constants with alias normalization, and replay + settlement ordering is
pinned by tests before visible wiring begins. pinned by tests before visible wiring begins.
- The next independently reviewable boundary is Phase 3 settlement through the - Slice 4 starts RFC Phase 3 by routing settled assistant final prose through the
anchor owner. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes remain anchor owner before `renderMessages()` renders the final assistant body.
projection/cache layers until that wiring lands. - 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 ## 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 until the matching field is explicitly moved. The fallback order is journal
replay first, settled transcript second, `INFLIGHT` only for gaps. 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 ## Source Event Classification
Phase 0 classifies current sources before changing render behavior: Phase 0 classifies current sources before changing render behavior:
+96 -5
View File
@@ -1,9 +1,9 @@
// Stable Assistant Turn Anchors scaffold (#3926). // Stable Assistant Turn Anchors scaffold (#3926).
// //
// This file is intentionally inert: it defines the current ownership inventory, // This file defines the current ownership inventory, event classifications, and
// event classifications, and small owner helpers, but it does not register // small owner helpers. It does not register anchors globally. The only renderer
// anchors globally or change any renderer. Later phases can wire these helpers into // wiring in this slice is settled assistant final-answer projection; live
// send(), attachLiveStream(), replay hydration, and renderMessages(). // streaming, replay hydration, tools, and DOM ownership remain unwired.
(function(){ (function(){
const ROOT=(typeof window!=='undefined')?window:globalThis; 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){ 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);
@@ -755,7 +845,7 @@
} }
ROOT.HermesAssistantTurnAnchors=Object.freeze({ ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'slice3-registry-shadow', version:'slice4-final-projection',
activityEventKinds:ACTIVITY_EVENT_KINDS, activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS, stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
@@ -772,6 +862,7 @@
applyAssistantTurnAnchorSourceEvent, applyAssistantTurnAnchorSourceEvent,
applyAssistantTurnAnchorSourceEvents, applyAssistantTurnAnchorSourceEvents,
createAssistantTurnAnchorShadowSnapshot, createAssistantTurnAnchorShadowSnapshot,
projectAssistantTurnAnchorSettledMessageFinalAnswer,
isAssistantTurnAnchorActivityKind, isAssistantTurnAnchorActivityKind,
}); });
})(); })();
+27
View File
@@ -8747,6 +8747,26 @@ function _renderMessagesWithScrollSnapshot(options){
renderMessages({...(options||{}),preserveScroll:true}); renderMessages({...(options||{}),preserveScroll:true});
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot); _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){ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
// Terminal stream renders can happen after S.activeStreamId is cleared. // Terminal stream renders can happen after S.activeStreamId is cleared.
// In that case, preserveScroll asks the normal pin-state helper to decide: // In that case, preserveScroll asks the normal pin-state helper to decide:
@@ -9077,6 +9097,13 @@ function renderMessages(options){
if(Array.isArray(content)){ if(Array.isArray(content)){
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); 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 content==='string'){
if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){ if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){
const split=window._extractInlineThinkingFromContentForRender(content, thinkingText); const split=window._extractInlineThinkingFromContentForRender(content, thinkingText);
@@ -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"] == "slice3-registry-shadow" assert data["version"] == "slice4-final-projection"
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"]
@@ -88,8 +88,11 @@ def test_phase0_scaffold_is_loaded_before_current_rendering_modules():
messages_pos = html.index('static/messages.js?v=__WEBUI_VERSION__') messages_pos = html.index('static/messages.js?v=__WEBUI_VERSION__')
assert anchor_pos < ui_pos < sessions_pos < messages_pos 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 "'./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(SESSIONS_JS)
assert "HermesAssistantTurnAnchors" not in _read(MESSAGES_JS) assert "HermesAssistantTurnAnchors" not in _read(MESSAGES_JS)
@@ -112,6 +112,59 @@ console.log(JSON.stringify({{
return json.loads(result.stdout) 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: def _hardening_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests" assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f""" script = f"""
@@ -289,7 +342,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"] == "slice3-registry-shadow" assert data["version"] == "slice4-final-projection"
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"',
@@ -389,7 +442,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"] == "slice3-registry-shadow" assert data["version"] == "slice4-final-projection"
assert data["toolResults"] == [ assert data["toolResults"] == [
{"applied": True, "reason": None}, {"applied": True, "reason": None},
{"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"] registry = data["registry"]
anchor = registry["anchor"] 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"]["live"] == [{"applied": True, "reason": None}]
assert data["results"]["replay"] == [ assert data["results"]["replay"] == [
{"applied": False, "reason": "duplicate"}, {"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" 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(): def test_registry_instances_do_not_share_owner_state():
data = _registry_snapshot() data = _registry_snapshot()
isolated = data["isolated"] isolated = data["isolated"]
@@ -473,7 +590,7 @@ def test_registry_instances_do_not_share_owner_state():
assert isolated["anchor"]["activity_events"] == [] 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 = [ helper_names = [
"createAssistantTurnAnchorRegistry", "createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorNormalizedEvent", "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(UI_JS)
assert helper not in _read(SESSIONS_JS) assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS) assert helper not in _read(MESSAGES_JS)
assert "projectAssistantTurnAnchorSettledMessageFinalAnswer" in _read(UI_JS)