diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index f5b84f7f..f75d81e4 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -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: diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 1aa73f34..21d6af6d 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -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, }); })(); diff --git a/static/ui.js b/static/ui.js index 0661fce8..3717f447 100644 --- a/static/ui.js +++ b/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); diff --git a/tests/test_stable_assistant_turn_anchor_normalizer.py b/tests/test_stable_assistant_turn_anchor_normalizer.py index 9d63fe9f..1ab6234c 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"] == "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"] diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py index 5a7f6583..4e995a86 100644 --- a/tests/test_stable_assistant_turn_anchor_phase0.py +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -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) diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 4f947cbd..f5ae67f8 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -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)