fix(anchor): harden registry contract before wiring

This commit is contained in:
Frank Song
2026-06-13 00:44:26 +08:00
parent 53051ad4a3
commit d516f9dd00
5 changed files with 283 additions and 22 deletions
@@ -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.
+48 -7
View File
@@ -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
+60 -10
View File
@@ -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,
@@ -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():
@@ -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"]