mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
fix(anchor): harden registry contract before wiring
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user