diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee6d415..dc44d97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 6720387e..3eb8b5d0 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -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 diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md new file mode 100644 index 00000000..0ccbc355 --- /dev/null +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -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. diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js new file mode 100644 index 00000000..3ee67174 --- /dev/null +++ b/static/assistant_turn_anchors.js @@ -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, + }); +})(); diff --git a/static/index.html b/static/index.html index ec5af0d0..373ea342 100644 --- a/static/index.html +++ b/static/index.html @@ -1498,6 +1498,7 @@
+ diff --git a/static/sw.js b/static/sw.js index ef60c497..a0be6f47 100644 --- a/static/sw.js +++ b/static/sw.js @@ -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, diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py new file mode 100644 index 00000000..8de94a6d --- /dev/null +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -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