Release LW stage — v0.51.359 (#3962 assistant turn anchor phase 0 scaffold) (#3977)
Some checks failed
Release & Docker / release (push) Has been cancelled

* feat(anchor): add stable assistant turn phase 0 scaffold

* chore: stamp v0.51.359 — Release LW (assistant turn anchor phase 0 scaffold, #3962)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@local>
This commit is contained in:
nesquena-hermes
2026-06-10 23:14:18 -07:00
committed by GitHub
parent 1126e54132
commit 48860418c9
7 changed files with 456 additions and 0 deletions

View File

@@ -3,8 +3,11 @@
## [Unreleased]
## [v0.51.359] — 2026-06-11 — Release LW (assistant turn anchor phase 0 scaffold)
### Added
- **Internal Stable Assistant Turn Anchors Phase 0 scaffold.** The browser now ships an inert `HermesAssistantTurnAnchors` helper surface plus a documented state-layer inventory for #3926, pinning current live/replay/settled source classifications and event dedupe precedence without changing visible chat rendering.
- **New RFC: Stable Assistant Turn Anchors for Live-to-Final rendering.** Defines a frontend presentation/reconciliation model for anchoring one assistant turn across live streaming, settlement, replay/reload/recovery, Compact Worklog, Transparent Stream, terminal states, artifacts, and side effects. (#3926)
## [v0.51.358] — 2026-06-11 — Release LV (first-run password bootstrap hardening)

View File

@@ -31,6 +31,10 @@ does not change runtime behavior, maintainer policy, bot behavior, or CI gates.
proposed product model for long-running assistant replies, live process text,
tool activity, recovery, terminal outcomes, and final-answer boundaries. Start
here for UI/UX changes to running-session assistant reply rendering.
- [`docs/architecture/stable-assistant-turn-anchor-phase0.md`](architecture/stable-assistant-turn-anchor-phase0.md):
current Phase 0 inventory for the Stable Assistant Turn Anchors work under
#3926. Use this before wiring anchor helpers into live SSE, replay,
settlement, `INFLIGHT`, or `renderMessages()` paths.
- [`docs/rfcs/canonical-session-resolution.md`](rfcs/canonical-session-resolution.md):
proposed contract for resolving URL routes, query parameters, localStorage,
sidebar rows, and compression-lineage IDs to one canonical visible session

View File

@@ -0,0 +1,52 @@
# Stable Assistant Turn Anchors Phase 0 Inventory
This inventory implements the first non-visual slice of
[`stable-assistant-turn-anchors.md`](../rfcs/stable-assistant-turn-anchors.md).
It documents the current per-turn state layers and the event-shape contract that
future anchor phases must consume. It does not claim that anchors are wired into
streaming or rendering yet.
## State Layers
| Layer | Current surface | Phase 0 anchor policy |
| --- | --- | --- |
| RuntimeAdapter / run-journal Event Envelope | `event_id`, `run_id`, `seq`, `Last-Event-ID` / `after_seq` | Preferred identity and replay dedupe source. |
| Run journal replay events | `read_run_events()`, `_replay_run_journal`, `runtime_journal_snapshot` | Durable replay hydration source before browser caches. |
| Server settled transcript | `/api/session` messages and metadata | Settlement updates final answer and terminal state on an existing turn. |
| `S.messages` | Browser transcript projection consumed by `renderMessages()` | Projection/cache, not a second semantic owner. |
| `INFLIGHT` | Browser recovery cache and persisted localStorage state | Recovery fallback only; does not outrank journal or settled transcript. |
| Stream closure state | `attachLiveStream()` local assistant text, reasoning text, parser target, tool state | Hot-path write buffer; future phases normalize this into anchor events. |
| Live DOM | `#liveAssistantTurn`, Worklog rows, tool cards, Thinking cards | Renderer output only; DOM survival is not semantic truth. |
The same inventory is encoded in `static/assistant_turn_anchors.js` as
`HermesAssistantTurnAnchors.stateLayers` so tests can pin the current authority
order.
## Source Event Classification
Phase 0 classifies current sources before changing render behavior:
- activity: `token`, `interim_assistant`, `reasoning`, `tool`,
`tool_complete`, `tool_update`, `compressing`, `compressed`, `approval`,
`clarify`, `pending_steer_leftover`, `goal_continue`, `done`, `cancel`,
`error`, `apperror`
- artifact: `artifact_reference`
- side effect: `state_saved`
- metadata: `usage`, `title`, `settled_message`, `runtime_journal_snapshot`,
`inflight_snapshot`
- transport: `stream_end`
Future phases may add sources, but every source must choose one of these classes
or explicitly mark itself `excluded`.
## Dedupe Invariant
Anchor event dedupe is intentionally independent of visible text and timestamps.
The Phase 0 helper uses this order:
1. `event_id`
2. `run_id + seq`
3. `session_id + local_id` as a browser fallback
This mirrors the RuntimeAdapter Event Envelope and keeps the browser aligned
with run-journal replay while the anchor registry is still unwired.

View File

@@ -0,0 +1,199 @@
// Stable Assistant Turn Anchors Phase 0 scaffold (#3926).
//
// This file is intentionally inert: it defines the current ownership inventory,
// event classifications, and small pure helpers, but it does not register
// anchors or change any renderer. Later phases can wire these helpers into
// send(), attachLiveStream(), replay hydration, and renderMessages().
(function(){
const ROOT=(typeof window!=='undefined')?window:globalThis;
const ACTIVITY_EVENT_KINDS=Object.freeze([
'process_prose',
'reasoning',
'tool_started',
'tool_updated',
'tool_completed',
'lifecycle_status',
'control_boundary',
'terminal_status',
]);
const STATE_LAYERS=Object.freeze([
Object.freeze({
id:'event_envelope',
label:'RuntimeAdapter / run-journal Event Envelope',
currentSurface:'event_id, run_id, seq, Last-Event-ID / after_seq',
role:'durable_identity',
authorityRank:1,
anchorPolicy:'Anchor identity and replay dedupe must consume this first.',
}),
Object.freeze({
id:'run_journal',
label:'Run journal replay events',
currentSurface:'read_run_events(), _replay_run_journal, runtime_journal_snapshot',
role:'durable_replay',
authorityRank:2,
anchorPolicy:'Replay hydration should rebuild activity events from this before caches.',
}),
Object.freeze({
id:'settled_transcript',
label:'Server settled transcript messages',
currentSurface:'/api/session messages and message metadata',
role:'durable_settlement',
authorityRank:3,
anchorPolicy:'Settlement updates the existing anchor final answer and terminal state.',
}),
Object.freeze({
id:'S.messages',
label:'Browser transcript projection',
currentSurface:'S.messages consumed by renderMessages()',
role:'projection_cache',
authorityRank:4,
anchorPolicy:'Projection input/output, not a second owner for one assistant turn.',
}),
Object.freeze({
id:'INFLIGHT',
label:'Browser in-flight recovery cache',
currentSurface:'INFLIGHT[session_id], localStorage persisted in-flight state',
role:'recovery_cache',
authorityRank:5,
anchorPolicy:'Recovery fallback only; must not outrank journal or settled transcript.',
}),
Object.freeze({
id:'stream_closure',
label:'attachLiveStream closure-local state',
currentSurface:'assistantText, reasoningText, parser targets, live tool state',
role:'hot_path_cache',
authorityRank:6,
anchorPolicy:'Hot-path write buffer; normalize into anchor events as the stream advances.',
}),
Object.freeze({
id:'live_dom',
label:'Live DOM / Worklog nodes',
currentSurface:'#liveAssistantTurn, tool-card rows, Thinking cards',
role:'renderer_output',
authorityRank:7,
anchorPolicy:'DOM continuity is useful, but DOM is never semantic truth.',
}),
]);
const SOURCE_EVENT_CLASSIFICATION=Object.freeze({
token:Object.freeze({classification:'activity',kind:'process_prose',source:'sse'}),
interim_assistant:Object.freeze({classification:'activity',kind:'process_prose',source:'sse'}),
reasoning:Object.freeze({classification:'activity',kind:'reasoning',source:'sse'}),
tool:Object.freeze({classification:'activity',kind:'tool_started',source:'sse'}),
tool_complete:Object.freeze({classification:'activity',kind:'tool_completed',source:'sse'}),
tool_update:Object.freeze({classification:'activity',kind:'tool_updated',source:'future_sse'}),
compressing:Object.freeze({classification:'activity',kind:'lifecycle_status',source:'sse'}),
compressed:Object.freeze({classification:'activity',kind:'lifecycle_status',source:'sse'}),
approval:Object.freeze({classification:'activity',kind:'control_boundary',source:'sse'}),
clarify:Object.freeze({classification:'activity',kind:'control_boundary',source:'sse'}),
pending_steer_leftover:Object.freeze({classification:'activity',kind:'control_boundary',source:'sse'}),
goal_continue:Object.freeze({classification:'activity',kind:'control_boundary',source:'sse'}),
artifact_reference:Object.freeze({classification:'artifact',kind:'artifact_reference',source:'derived'}),
state_saved:Object.freeze({classification:'side_effect',kind:null,source:'sse'}),
usage:Object.freeze({classification:'metadata',kind:null,source:'settlement'}),
title:Object.freeze({classification:'metadata',kind:null,source:'settlement'}),
done:Object.freeze({classification:'activity',kind:'terminal_status',source:'sse'}),
cancel:Object.freeze({classification:'activity',kind:'terminal_status',source:'sse'}),
error:Object.freeze({classification:'activity',kind:'terminal_status',source:'sse'}),
apperror:Object.freeze({classification:'activity',kind:'terminal_status',source:'sse'}),
stream_end:Object.freeze({classification:'transport',kind:null,source:'sse'}),
runtime_journal_snapshot:Object.freeze({classification:'metadata',kind:null,source:'session_payload'}),
inflight_snapshot:Object.freeze({classification:'metadata',kind:null,source:'browser_storage'}),
settled_message:Object.freeze({classification:'metadata',kind:null,source:'session_payload'}),
});
const CLASSIFICATION_ORDER=Object.freeze([
'activity',
'artifact',
'side_effect',
'metadata',
'transport',
'excluded',
]);
function _cleanString(value){
return typeof value==='string'?value.trim():'';
}
function assistantTurnAnchorEventDedupeKey(event){
if(!event||typeof event!=='object') return '';
const eventId=_cleanString(event.event_id);
if(eventId) return 'event_id:'+eventId;
const runId=_cleanString(event.run_id);
const seq=(event.seq!=null&&event.seq!=='')?String(event.seq):'';
if(runId&&seq) return 'run_seq:'+runId+':'+seq;
const sid=_cleanString(event.session_id);
const localId=_cleanString(event.local_id);
if(sid&&localId) return 'local:'+sid+':'+localId;
return '';
}
function classifyAssistantTurnAnchorSourceEvent(sourceType){
const key=_cleanString(sourceType);
return SOURCE_EVENT_CLASSIFICATION[key]||Object.freeze({
classification:'excluded',
kind:null,
source:key||'unknown',
});
}
function isAssistantTurnAnchorActivityKind(kind){
return ACTIVITY_EVENT_KINDS.indexOf(kind)!==-1;
}
function createAssistantTurnAnchorSeed(input){
const opts=(input&&typeof input==='object')?input:{};
const sessionId=_cleanString(opts.session_id);
if(!sessionId) throw new Error('assistant turn anchor requires session_id');
const streamId=_cleanString(opts.stream_id);
const runId=_cleanString(opts.run_id);
const turnId=_cleanString(opts.turn_id)||[
'local',
sessionId,
runId||streamId||'pending',
_cleanString(opts.local_id)||'assistant',
].join(':');
return {
identity:{
session_id:sessionId,
turn_id:turnId,
run_id:runId||null,
stream_id:streamId||null,
source_message_refs:Array.isArray(opts.source_message_refs)?opts.source_message_refs.slice():[],
},
lifecycle:{
status:_cleanString(opts.status)||'created',
terminal_state:null,
started_at:opts.started_at||null,
completed_at:null,
},
content:{
final_answer:'',
final_message_ref:null,
},
activity_events:[],
artifacts:[],
side_effects:[],
usage:null,
presentation_state:{
compact_worklog:{expanded:false},
transparent_stream:{expanded:false},
scroll:{follow:true},
},
};
}
ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'phase0',
activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
classificationOrder:CLASSIFICATION_ORDER,
createAssistantTurnAnchorSeed,
assistantTurnAnchorEventDedupeKey,
classifyAssistantTurnAnchorSourceEvent,
isAssistantTurnAnchorActivityKind,
});
})();

View File

@@ -1498,6 +1498,7 @@
<div class="toast" id="toast"></div>
<script src="static/i18n.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/icons.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/assistant_turn_anchors.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/ui.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/workspace.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/terminal.js?v=__WEBUI_VERSION__" defer></script>

View File

@@ -25,6 +25,7 @@ const SHELL_ASSETS = [
'./static/style.css' + VQ,
'./static/pwa-startup.js' + VQ,
'./static/boot.js' + VQ,
'./static/assistant_turn_anchors.js' + VQ,
'./static/ui.js' + VQ,
'./static/messages.js' + VQ,
'./static/sessions.js' + VQ,

View File

@@ -0,0 +1,196 @@
"""Phase 0 contract tests for Stable Assistant Turn Anchors (#3926).
The first implementation slice is intentionally non-visual. It adds the
inventory and pure helper surface that later phases can wire into send(),
attachLiveStream(), replay hydration, and renderMessages().
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ANCHORS_JS = REPO / "static" / "assistant_turn_anchors.js"
INDEX_HTML = REPO / "static" / "index.html"
MESSAGES_JS = REPO / "static" / "messages.js"
UI_JS = REPO / "static" / "ui.js"
SESSIONS_JS = REPO / "static" / "sessions.js"
SW_JS = REPO / "static" / "sw.js"
PHASE0_DOC = REPO / "docs" / "architecture" / "stable-assistant-turn-anchor-phase0.md"
NODE = shutil.which("node")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _anchor_api_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js helper 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 anchor = api.createAssistantTurnAnchorSeed({{
session_id:'sid-1',
stream_id:'stream-1',
run_id:'run-1',
source_message_refs:['m1'],
}});
const out = {{
version: api.version,
kinds: api.activityEventKinds,
layers: api.stateLayers,
classifications: api.sourceEventClassification,
classificationOrder: api.classificationOrder,
tokenKind: api.classifyAssistantTurnAnchorSourceEvent('token').kind,
streamEndClass: api.classifyAssistantTurnAnchorSourceEvent('stream_end').classification,
unknownClass: api.classifyAssistantTurnAnchorSourceEvent('unknown_future').classification,
eventIdKey: api.assistantTurnAnchorEventDedupeKey({{event_id:'run-1:2', text:'same'}}),
runSeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:2, timestamp:123}}),
localKey: api.assistantTurnAnchorEventDedupeKey({{session_id:'sid-1', local_id:'local-1', content:'ignored'}}),
zeroSeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:0, session_id:'sid-1', local_id:'local-1'}}),
nanSeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:NaN, session_id:'sid-1', local_id:'local-1'}}),
emptySeqKey: api.assistantTurnAnchorEventDedupeKey({{run_id:'run-1', seq:'', session_id:'sid-1', local_id:'local-1'}}),
emptyKey: api.assistantTurnAnchorEventDedupeKey({{content:'visible text only', timestamp:123}}),
artifactIsActivityKind: api.isAssistantTurnAnchorActivityKind('artifact_reference'),
terminalIsActivityKind: api.isAssistantTurnAnchorActivityKind('terminal_status'),
anchor,
}};
console.log(JSON.stringify(out));
"""
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 test_phase0_scaffold_is_loaded_before_current_rendering_modules():
html = _read(INDEX_HTML)
anchor_pos = html.index('static/assistant_turn_anchors.js?v=__WEBUI_VERSION__')
ui_pos = html.index('static/ui.js?v=__WEBUI_VERSION__')
sessions_pos = html.index('static/sessions.js?v=__WEBUI_VERSION__')
messages_pos = html.index('static/messages.js?v=__WEBUI_VERSION__')
assert anchor_pos < ui_pos < sessions_pos < messages_pos
assert "'./static/assistant_turn_anchors.js' + VQ" in _read(SW_JS)
assert "HermesAssistantTurnAnchors" not in _read(UI_JS)
assert "HermesAssistantTurnAnchors" not in _read(SESSIONS_JS)
assert "HermesAssistantTurnAnchors" not in _read(MESSAGES_JS)
def test_phase0_inventory_names_current_state_layers_in_authority_order():
data = _anchor_api_snapshot()
layer_ids = [layer["id"] for layer in data["layers"]]
assert layer_ids == [
"event_envelope",
"run_journal",
"settled_transcript",
"S.messages",
"INFLIGHT",
"stream_closure",
"live_dom",
]
ranks = [layer["authorityRank"] for layer in data["layers"]]
assert ranks == sorted(ranks)
assert data["layers"][0]["role"] == "durable_identity"
assert data["layers"][-1]["role"] == "renderer_output"
def test_phase0_classifies_all_current_live_to_final_sources():
data = _anchor_api_snapshot()
classifications = data["classifications"]
required = {
"token": ("activity", "process_prose"),
"interim_assistant": ("activity", "process_prose"),
"reasoning": ("activity", "reasoning"),
"tool": ("activity", "tool_started"),
"tool_complete": ("activity", "tool_completed"),
"tool_update": ("activity", "tool_updated"),
"compressing": ("activity", "lifecycle_status"),
"compressed": ("activity", "lifecycle_status"),
"approval": ("activity", "control_boundary"),
"clarify": ("activity", "control_boundary"),
"pending_steer_leftover": ("activity", "control_boundary"),
"goal_continue": ("activity", "control_boundary"),
"done": ("activity", "terminal_status"),
"cancel": ("activity", "terminal_status"),
"error": ("activity", "terminal_status"),
"apperror": ("activity", "terminal_status"),
"stream_end": ("transport", None),
"runtime_journal_snapshot": ("metadata", None),
"inflight_snapshot": ("metadata", None),
"settled_message": ("metadata", None),
}
for source, expected in required.items():
item = classifications[source]
assert (item["classification"], item["kind"]) == expected
assert data["tokenKind"] == "process_prose"
assert data["streamEndClass"] == "transport"
assert data["unknownClass"] == "excluded"
assert data["artifactIsActivityKind"] is False
assert data["terminalIsActivityKind"] is True
for item in classifications.values():
if item["classification"] == "activity":
assert item["kind"] in data["kinds"]
elif item["kind"] is not None:
assert item["kind"] not in data["kinds"]
def test_phase0_dedupe_prefers_event_envelope_not_visible_text_or_timestamps():
data = _anchor_api_snapshot()
assert data["eventIdKey"] == "event_id:run-1:2"
assert data["runSeqKey"] == "run_seq:run-1:2"
assert data["localKey"] == "local:sid-1:local-1"
assert data["zeroSeqKey"] == "run_seq:run-1:0"
assert data["nanSeqKey"] == "run_seq:run-1:NaN"
assert data["emptySeqKey"] == "local:sid-1:local-1"
assert data["emptyKey"] == ""
helper_src = _read(ANCHORS_JS).split("function assistantTurnAnchorEventDedupeKey", 1)[1]
helper_src = helper_src.split("function classifyAssistantTurnAnchorSourceEvent", 1)[0]
assert "event_id" in helper_src
assert "run_id" in helper_src
assert "seq" in helper_src
assert "text" not in helper_src
assert "content" not in helper_src
assert "timestamp" not in helper_src
assert "created_at" not in helper_src
def test_phase0_anchor_seed_matches_rfc_shape_without_registering_state():
data = _anchor_api_snapshot()
anchor = data["anchor"]
assert anchor["identity"]["session_id"] == "sid-1"
assert anchor["identity"]["run_id"] == "run-1"
assert anchor["identity"]["stream_id"] == "stream-1"
assert anchor["lifecycle"]["status"] == "created"
assert anchor["content"]["final_answer"] == ""
assert anchor["activity_events"] == []
assert anchor["artifacts"] == []
assert anchor["side_effects"] == []
assert anchor["presentation_state"]["compact_worklog"]["expanded"] is False
assert anchor["presentation_state"]["transparent_stream"]["expanded"] is False
def test_phase0_inventory_doc_matches_scaffold_contract():
doc = _read(PHASE0_DOC)
for marker in [
"RuntimeAdapter / run-journal Event Envelope",
"Run journal replay events",
"Server settled transcript",
"`S.messages`",
"`INFLIGHT`",
"Stream closure state",
"Live DOM",
"Dedupe Invariant",
"`event_id`",
"`run_id + seq`",
"`session_id + local_id`",
]:
assert marker in doc