From d516f9dd00a98232a6fc5a1411a6d01c5543f9cb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 13 Jun 2026 00:44:26 +0800 Subject: [PATCH] fix(anchor): harden registry contract before wiring --- .../stable-assistant-turn-anchor-phase0.md | 36 +++++- docs/rfcs/stable-assistant-turn-anchors.md | 55 +++++++-- static/assistant_turn_anchors.js | 70 ++++++++++-- ...est_stable_assistant_turn_anchor_phase0.py | 37 +++++- ...t_stable_assistant_turn_anchor_registry.py | 107 ++++++++++++++++++ 5 files changed, 283 insertions(+), 22 deletions(-) diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 15a6cf702..f5b84f7f9 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -17,6 +17,10 @@ streaming or rendering yet. - This slice advances RFC Phase 1 and Phase 2 together: it adds a local registry owner plus a shadow source-feed harness that can combine live, replay, settled, and in-flight observations into one anchor snapshot. +- It also covers the RFC Phase 2.5 contract-hardening boundary: the semantic + 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. @@ -72,9 +76,10 @@ the same event-envelope dedupe rule, and route events into one owner: The registry may fill missing `run_id` / `stream_id` identity from the first matching normalized event, update lifecycle on terminal status, and copy the -settled assistant message into `content.final_answer`. It rejects mismatched -session or turn identity and skips duplicate live + replay observations by the -same dedupe key. +settled assistant message into `content.final_answer` as a derived render +snapshot while keeping `content.final_message_ref` as the settled transcript +reference. It rejects mismatched session or turn identity and skips duplicate +live + replay observations by the same dedupe key. This slice deliberately keeps the ownership boundary inert: `send()`, `attachLiveStream()`, replay hydration, `renderMessages()`, `S.messages`, @@ -89,6 +94,26 @@ feeds them through one local registry, and returns the resulting snapshot plus per-source apply results. This gives later slices an invariant target without making the current UI consume the owner yet. +Renderer-only UI state such as Compact Worklog expansion, Transparent Stream +expansion, copy-button visibility, and scroll-follow preference is intentionally +not stored in the anchor seed. Those choices belong in renderer state or a +separate per-session UI preference store so replay and settlement do not carry +historic display preferences as semantic facts. + +`HermesAssistantTurnAnchors.terminalStates` exposes the RFC terminal-state enum: +`completed`, `cancelled`, `interrupted`, `no_response`, +`tool_limit_reached`, `compression_exhausted`, `connection_lost`, `degraded`, +and `error`. `normalizeAssistantTurnAnchorTerminalState()` maps current source +aliases such as `done`, `cancel`, `apperror`, `interrupted-by-user`, +`max_iterations`, and `lost_worker_bookkeeping` into that enum. + +During the later `INFLIGHT` migration, the registry is the semantic owner for +event identity, lifecycle, final answer reference, and activity events. +`INFLIGHT.lastRunJournalSeq`, `activityBurstAnchors`, `currentLiveSegmentSeq`, +`streamId`, and cached live text/tool state remain recovery or renderer caches +until the matching field is explicitly moved. The fallback order is journal +replay first, settled transcript second, `INFLIGHT` only for gaps. + ## Source Event Classification Phase 0 classifies current sources before changing render behavior: @@ -118,3 +143,8 @@ The Phase 0 helper uses this order: This mirrors the RuntimeAdapter Event Envelope and keeps the browser aligned with run-journal replay while the anchor registry is still unwired. + +The registry tests also pin the reconnect/settlement race shape: if one run is +observed live, replayed, and settled in either order, duplicate event envelopes +are skipped and the resulting anchor has the same activity list, terminal state, +final message reference, final-answer snapshot, and usage metadata. diff --git a/docs/rfcs/stable-assistant-turn-anchors.md b/docs/rfcs/stable-assistant-turn-anchors.md index 5c2172240..7fea9e301 100644 --- a/docs/rfcs/stable-assistant-turn-anchors.md +++ b/docs/rfcs/stable-assistant-turn-anchors.md @@ -301,10 +301,6 @@ AssistantTurnAnchor artifacts[] side_effects[] usage - presentation_state - compact_worklog expansion state - transparent_stream expansion state - scroll/follow hints ``` The anchor is not a backend schema requirement. It is the frontend model that @@ -335,6 +331,12 @@ renderers consume the anchor In the target model, stream `done` should not need to "make a different turn." It should complete the existing turn. +Renderer-only preferences are deliberately outside the semantic anchor. Compact +Worklog expansion, Transparent Stream expansion, copy-button visibility, and +scroll-follow preferences may live in renderer state or a per-session UI +preference store, but replay and settlement must not persist those choices as +assistant-turn facts. + ## Anchor Creation And Identity The normal creation path is: @@ -525,8 +527,8 @@ existing anchor: 1. verify that `session_id`, `run_id`, or `stream_id` still belongs to the anchor, -2. write the final assistant answer into `anchor.final_answer` or bind - `anchor.final_message_ref`, +2. bind `anchor.content.final_message_ref` to the settled assistant message and + write `anchor.content.final_answer` as a derived render snapshot, 3. attach terminal state and usage metadata, 4. merge settled reasoning/tool/artifact metadata into existing activity events, 5. mark live-only events as settled or drop them if they were purely transient, @@ -542,6 +544,13 @@ Terminal state is separate from final answer text. A turn can have partial process prose, tool cards, reasoning, artifacts, or usage metadata and still fail to produce a normal final answer. +`content.final_message_ref` is the durable transcript reference when settled +message identity exists. `content.final_answer` is not an independent semantic +owner; it is a projection cache copied from the settled assistant message so +early renderers can avoid chasing `S.messages` during every paint. If settlement +later rewrites the transcript message, the anchor must be refreshed from that +message instead of allowing the two copies to drift silently. + ## Replay, Reload, And Reconstruction Replay/reload should reconstruct the same Assistant Turn Anchor from durable @@ -569,6 +578,17 @@ anchor after `/api/chat/start` succeeds and returns `stream_id`. Reconstruction is for reload, reconnect, session switch, SSE replay, and recovery cases where the local anchor is missing or incomplete. +During migration, `INFLIGHT` remains a recovery cache until an anchor-backed +field takes over. The handoff order should be: + +| Current `INFLIGHT` field family | Anchor destination | Fallback rule | +| --- | --- | --- | +| `lastRunJournalSeq` / replay cursor | anchor event dedupe index plus run-journal cursor metadata | Prefer run journal replay; read `INFLIGHT` only to resume a missing browser cursor. | +| `activityBurstAnchors` / live row anchors | `activity_events[]` identity and grouping metadata | Preserve DOM hints as renderer cache; do not let them outrank normalized events. | +| `currentLiveSegmentSeq` / local live order | anchor-local source order when no Event Envelope exists | Use only as browser fallback identity; never dedupe by visible text. | +| `streamId` / active transport key | `identity.stream_id` fallback below `run_id` | Do not hard-bind ownership to `stream_id` once `run_id` is known. | +| cached assistant text / reasoning / tool state | `activity_events[]` and derived render rows | Use for reconstruction gaps only after journal and settled transcript evidence. | + `stream_end` deserves special care. It is a transport close signal and may trigger recovery. It must not be treated as proof that the turn completed. @@ -651,6 +671,11 @@ replacement when no final answer exists. Transparent Stream should show the same terminal truth as the final chronological event. The two display modes must not disagree about the outcome. +Implementation code should expose the terminal states as constants rather than +spread string literals across settlement, replay, and renderer code. Source +aliases such as `done`, `cancel`, `apperror`, and runtime-specific error labels +should normalize into this enum before renderer consumption. + ## Artifacts And Side Effects Agent turns produce more than prose and tool rows. They can create files, mutate @@ -765,6 +790,18 @@ changes should remain independently reviewable. - Keep Compact Worklog as the only visible renderer. - Add coverage proving all current event names have an explicit classification. +### Phase 2.5: Anchor contract hardening + +- Keep renderer presentation state out of the semantic anchor seed. +- Export terminal-state constants and normalize source aliases before Phase 3 + writes settlement outcomes. +- Document which `INFLIGHT` fields become anchor-owned, which remain fallback + cache, and which are renderer-only hints. +- Define `final_message_ref` as the settled transcript authority and + `final_answer` as a derived render snapshot. +- Add order-invariance coverage for live + replay + settlement observations of + the same run. + ### Phase 3: Settlement through anchor - Reconcile `done` into the existing anchor. @@ -832,7 +869,11 @@ An implementation should eventually satisfy: transport, or explicitly excluded, - replay/reconnect of one run is idempotent at the existing `(session_id, event_id)` dedupe ring: no activity event is dropped or - duplicated when the same run is observed live and then replayed. + duplicated when the same run is observed live and then replayed, +- replay/reconnect and settlement are order-invariant for one run: whether + replay events arrive before or after the settled assistant message, the anchor + reaches the same final answer reference, terminal state, usage metadata, and + activity event list. ## Review Checklist For Implementation PRs diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index f9f787ba4..1aa73f347 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -113,6 +113,40 @@ 'excluded', ]); + const TERMINAL_STATES=Object.freeze({ + completed:'completed', + cancelled:'cancelled', + interrupted:'interrupted', + no_response:'no_response', + tool_limit_reached:'tool_limit_reached', + compression_exhausted:'compression_exhausted', + connection_lost:'connection_lost', + degraded:'degraded', + error:'error', + }); + + const TERMINAL_STATE_ALIASES=Object.freeze({ + completed:TERMINAL_STATES.completed, + complete:TERMINAL_STATES.completed, + done:TERMINAL_STATES.completed, + cancelled:TERMINAL_STATES.cancelled, + canceled:TERMINAL_STATES.cancelled, + cancel:TERMINAL_STATES.cancelled, + interrupted:TERMINAL_STATES.interrupted, + interrupted_by_user:TERMINAL_STATES.interrupted, + no_response:TERMINAL_STATES.no_response, + no_response_generated:TERMINAL_STATES.no_response, + tool_limit_reached:TERMINAL_STATES.tool_limit_reached, + max_iterations:TERMINAL_STATES.tool_limit_reached, + compression_exhausted:TERMINAL_STATES.compression_exhausted, + connection_lost:TERMINAL_STATES.connection_lost, + lost_worker_bookkeeping:TERMINAL_STATES.connection_lost, + degraded:TERMINAL_STATES.degraded, + error:TERMINAL_STATES.error, + failed:TERMINAL_STATES.error, + apperror:TERMINAL_STATES.error, + }); + const UNSAFE_OBJECT_KEYS=Object.freeze([ '__proto__', 'constructor', @@ -144,6 +178,20 @@ return typeof value==='string'?value.trim():''; } + function _terminalStateKey(value){ + return _cleanString(value).toLowerCase().replace(/[\s-]+/g,'_'); + } + + function normalizeAssistantTurnAnchorTerminalState(value, fallback){ + const key=_terminalStateKey(value); + if(key&&_hasOwn(TERMINAL_STATE_ALIASES,key)) return TERMINAL_STATE_ALIASES[key]; + const fallbackKey=_terminalStateKey(fallback); + if(fallbackKey&&_hasOwn(TERMINAL_STATE_ALIASES,fallbackKey)){ + return TERMINAL_STATE_ALIASES[fallbackKey]; + } + return null; + } + function _coercePayload(value){ if(value==null) return {}; if(typeof value==='string'){ @@ -243,13 +291,15 @@ function _statusForSourceEvent(sourceType, kind, payload){ const explicit=_cleanString(_firstOwn(payload,['status','state','phase'])); - if(explicit) return explicit; + if(explicit){ + return kind==='terminal_status' + ?normalizeAssistantTurnAnchorTerminalState(explicit,sourceType)||explicit + :explicit; + } if(kind==='tool_started') return 'running'; if(kind==='tool_completed') return _own(payload,'is_error')?'error':'completed'; if(kind==='terminal_status'){ - if(sourceType==='done') return 'completed'; - if(sourceType==='cancel') return 'cancelled'; - return 'error'; + return normalizeAssistantTurnAnchorTerminalState(sourceType)||TERMINAL_STATES.error; } if(kind==='lifecycle_status') return 'running'; if(kind==='control_boundary') return 'pending'; @@ -503,7 +553,10 @@ lifecycle.status='running'; } if(kind==='terminal_status'){ - const terminal=status||'completed'; + const terminal=normalizeAssistantTurnAnchorTerminalState( + status, + _own(event,'source_event_type') + )||TERMINAL_STATES.completed; lifecycle.status=terminal; lifecycle.terminal_state=terminal; lifecycle.completed_at=createdAt||lifecycle.completed_at||null; @@ -679,11 +732,6 @@ metadata_events:[], transport_events:[], usage:null, - presentation_state:{ - compact_worklog:{expanded:false}, - transparent_stream:{expanded:false}, - scroll:{follow:true}, - }, }; } @@ -712,7 +760,9 @@ stateLayers:STATE_LAYERS, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, classificationOrder:CLASSIFICATION_ORDER, + terminalStates:TERMINAL_STATES, createAssistantTurnAnchorSeed, + normalizeAssistantTurnAnchorTerminalState, assistantTurnAnchorEventDedupeKey, classifyAssistantTurnAnchorSourceEvent, normalizeAssistantTurnAnchorSourceEvent, diff --git a/tests/test_stable_assistant_turn_anchor_phase0.py b/tests/test_stable_assistant_turn_anchor_phase0.py index ff32495f3..5a7f65839 100644 --- a/tests/test_stable_assistant_turn_anchor_phase0.py +++ b/tests/test_stable_assistant_turn_anchor_phase0.py @@ -48,6 +48,16 @@ const out = {{ layers: api.stateLayers, classifications: api.sourceEventClassification, classificationOrder: api.classificationOrder, + terminalStates: api.terminalStates, + terminalAliases: {{ + done: api.normalizeAssistantTurnAnchorTerminalState('done'), + cancel: api.normalizeAssistantTurnAnchorTerminalState('cancel'), + apperror: api.normalizeAssistantTurnAnchorTerminalState('apperror'), + interruptedByUser: api.normalizeAssistantTurnAnchorTerminalState('interrupted-by-user'), + lostBookkeeping: api.normalizeAssistantTurnAnchorTerminalState('lost_worker_bookkeeping'), + maxIterations: api.normalizeAssistantTurnAnchorTerminalState('max_iterations'), + unknown: api.normalizeAssistantTurnAnchorTerminalState('unknown'), + }}, tokenKind: api.classifyAssistantTurnAnchorSourceEvent('token').kind, streamEndClass: api.classifyAssistantTurnAnchorSourceEvent('stream_end').classification, unknownClass: api.classifyAssistantTurnAnchorSourceEvent('unknown_future').classification, @@ -165,6 +175,30 @@ def test_phase0_dedupe_prefers_event_envelope_not_visible_text_or_timestamps(): assert "created_at" not in helper_src +def test_phase0_exports_terminal_state_contract_and_aliases(): + data = _anchor_api_snapshot() + assert data["terminalStates"] == { + "completed": "completed", + "cancelled": "cancelled", + "interrupted": "interrupted", + "no_response": "no_response", + "tool_limit_reached": "tool_limit_reached", + "compression_exhausted": "compression_exhausted", + "connection_lost": "connection_lost", + "degraded": "degraded", + "error": "error", + } + assert data["terminalAliases"] == { + "done": "completed", + "cancel": "cancelled", + "apperror": "error", + "interruptedByUser": "interrupted", + "lostBookkeeping": "connection_lost", + "maxIterations": "tool_limit_reached", + "unknown": None, + } + + def test_phase0_anchor_seed_matches_rfc_shape_without_registering_state(): data = _anchor_api_snapshot() anchor = data["anchor"] @@ -176,8 +210,7 @@ def test_phase0_anchor_seed_matches_rfc_shape_without_registering_state(): 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 + assert "presentation_state" not in anchor def test_phase0_inventory_doc_matches_scaffold_contract(): diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 1b36273fe..4f947cbd8 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -219,6 +219,71 @@ console.log(JSON.stringify({{ return json.loads(result.stdout) +def _race_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; + +function build(order) {{ + const registry = api.createAssistantTurnAnchorRegistry({{ + session_id:'sid-race', + turn_id:'turn-race', + run_id:'run-race', + stream_id:'stream-race', + }}); + const live = [ + {{event:'token', payload:{{text:'live token'}}, event_id:'run-race:1', run_id:'run-race', seq:1}}, + ]; + const replay = [ + {{event:'token', payload:{{text:'replayed duplicate'}}, event_id:'run-race:1', run_id:'run-race', seq:1}}, + {{event:'tool_complete', payload:{{tool_call_id:'tool-1', result:'ok'}}, event_id:'run-race:2', run_id:'run-race', seq:2}}, + {{event:'done', payload:{{status:'done'}}, event_id:'run-race:3', run_id:'run-race', seq:3, created_at:'2026-06-11T00:00:03Z'}}, + ]; + const settled = [ + {{source_type:'settled_message', payload:{{role:'assistant', id:'message-race', content:'race final', _turnUsage:{{input_tokens:5, output_tokens:8}}}}}}, + ]; + api.applyAssistantTurnAnchorSourceEvents(registry, live); + if (order === 'replay-first') {{ + api.applyAssistantTurnAnchorSourceEvents(registry, replay); + api.applyAssistantTurnAnchorSourceEvents(registry, settled); + }} else {{ + api.applyAssistantTurnAnchorSourceEvents(registry, settled); + api.applyAssistantTurnAnchorSourceEvents(registry, replay); + }} + const anchor = registry.anchor; + return {{ + stats: registry.stats, + dedupe_keys: registry.event_index.dedupe_keys, + activity: anchor.activity_events.map((event) => ({{ + event_id: event.event_id, + kind: event.kind, + status: event.status, + text: event.payload && event.payload.text || null, + tool_call_id: event.payload && event.payload.tool_call_id || null, + }})), + terminal_state: anchor.lifecycle.terminal_state, + final_answer: anchor.content.final_answer, + final_message_ref: anchor.content.final_message_ref, + usage: anchor.usage, + }}; +}} + +console.log(JSON.stringify({{ + replayFirst: build('replay-first'), + settledFirst: build('settled-first'), +}})); +""" + 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_registry_owns_one_anchor_and_dedupes_live_plus_replay_events(): data = _registry_snapshot() registry = data["registry"] @@ -277,6 +342,48 @@ def test_registry_updates_lifecycle_and_settled_final_projection(): assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13} +def test_registry_replay_and_settlement_order_converge_on_same_anchor_state(): + data = _race_snapshot() + replay_first = data["replayFirst"] + settled_first = data["settledFirst"] + + assert replay_first == settled_first + assert replay_first["stats"]["applied"] == 4 + assert replay_first["stats"]["skipped_duplicate"] == 1 + assert replay_first["dedupe_keys"] == [ + 'event_id:"run-race:1"', + 'event_id:"run-race:2"', + 'event_id:"run-race:3"', + ] + assert replay_first["activity"] == [ + { + "event_id": "run-race:1", + "kind": "process_prose", + "status": None, + "text": "live token", + "tool_call_id": None, + }, + { + "event_id": "run-race:2", + "kind": "tool_completed", + "status": "completed", + "text": None, + "tool_call_id": "tool-1", + }, + { + "event_id": "run-race:3", + "kind": "terminal_status", + "status": "completed", + "text": None, + "tool_call_id": None, + }, + ] + assert replay_first["terminal_state"] == "completed" + assert replay_first["final_answer"] == "race final" + assert replay_first["final_message_ref"] == "message-race" + assert replay_first["usage"] == {"input_tokens": 5, "output_tokens": 8} + + def test_registry_does_not_destructively_dedupe_seqless_local_tool_lifecycle(): data = _hardening_snapshot() registry = data["toolRegistry"]