Merge #4092 (assistant turn anchor settled final projection) onto master
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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&®istry.anchor.content&®istry.anchor.content.final_answer;
|
||||
const rawFinalMessageRef=registry.anchor&®istry.anchor.content&®istry.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,
|
||||
});
|
||||
})();
|
||||
|
||||
27
static/ui.js
27
static/ui.js
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user