The loop recorded every model token delta as a durable `assistant/chunk` session event AND emitted an identical live `agent/stream-chunk` Cordis event one line later. Same StreamChunk, same turn/step; the emit added only the live Agent handle, which the sole consumer discarded. This is the boundary-mirror duplication the event-domain work removed for turn/step boundaries, applied to the token stream — a follow-up the boundary RFC explicitly deferred. The premise is settled: chunk persistence is authoritative (the proposal to stop persisting chunks was rejected — replay/snapshots depend on it), so `assistant/chunk` on `session/event` is the load-bearing token stream and `agent/stream-chunk` is pure redundancy. - Remove the `agent/stream-chunk` declaration + emit; drop the now-unused StreamChunk import from dsh-agent's types. - Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads assistant/chunk off session/event) to render assistant/chunk in its existing session/event listener. Consolidating to one listener also makes the inReasoning dim-SGR flag deterministic across chunk/boundary events (they no longer race across two listeners). - Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the session/event assistant/chunk feed. - New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror); amend the boundary RFC's retained-list entry to cross-link; update architecture, cookbook, event-domain-semantics, the ACP proposal, and the regenerated cordis catalog. Snapshot goldens unchanged (ACP never used the mirror), confirming no editor-facing transcript change.
5.8 KiB
RFC: Event-domain semantics — session is the fact log, agent is the live surface
Status: implemented (accepted 2026-06-30)
Context
The harness extends the agent loop through a Cordis event taxonomy (see the microkernel event-taxonomy RFC). As that taxonomy grew, the line between the three event domains blurred:
session/*carries the durable, event-sourced log (SessionEventMap).agent/*carries live runtime signals that hand a plugin theAgenthandle.tools/*carries the tool registry + execution seam.
Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable SessionEvent (turn/start, turn/end, step/start, step/end) AND as a mirrored agent/* emit (agent/turn-start, agent/turn-end, agent/step-start, agent/step-end). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why.
This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the hook/* durable log, the bridges) build on.
Decision
Three domains, one job each, with a single boundary rule.
session/*— the durable, replayable FACT log. OwnsSessionEventMap; every entry is JSON-only (no live objects). Onesession/eventemit per append, plus thesession/flushparallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering andsession/loadreplay share one path.agent/*— the LIVE runtime surface. Always carries the liveAgent. Two shapes: INTERCEPTION waterfalls (agent/request,agent/step-result,agent/turn-continuation) that mutate or veto, and TRANSIENT emits (agent/status,agent/error,agent/created/agent/disposed,agent/queued,agent/steering) that notify with theAgentin hand. Turn and step BOUNDARIES are NOT here — they are durable session events read offsession/event, and so is the token stream (assistant/chunk).tools/*— the tool registry + execution seam.
The boundary rule: a durable, replayable fact is a SessionEvent; a live interception or a transient/live-object signal is an agent/tools Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the session/event feed — it is NOT mirrored as an agent/* emit.
Applying the rule to the boundary twins: all four boundary mirrors — agent/turn-start, agent/turn-end, agent/step-start, agent/step-end — are REMOVED. No production consumer needs the live Agent at a boundary: the ACP bridge settles from session/event turn/end plus agent/status, and the only turn-mirror consumer (dsh-ui-stdio, a disposable test REPL) was migrated to render boundaries from session/event, recovering the short agent label from an agent/created→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see the remove-boundary-mirror-events RFC, which owns that decision. Removing the emits also simplifies the loop's closeStep/closeTurn (one append each, no paired emit).
Consequences
- The loop no longer emits any boundary mirror;
closeStepappendsstep/endonly andcloseTurnappendsturn/endonly. A throwingstep/end/turn/endsession-event listener is the surviving boundary-listener failure path (contained insidecloseStep/closeTurn—Session.appendpushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless). - Tests that observed boundaries via the removed emits now observe the durable
turn/start/turn/end/step/start/step/endsession events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a throwing turn-boundary emit listener were deleted, because that code path no longer exists (there is no emit to throw from). Per AGENTS.md "tests document behavior, not golden truth", the behavior and its test moved (or died) together. - The loop marks the step open (
stepOpen = true) BEFORE appendingstep/start, becauseSession.appendpushes the event to the log before notifyingsession/eventlisteners (validation throws happen earlier, before the push — see the session append contract). So a throwingstep/startsession-event listener runs with the step already open and the event already in the log: the loop's outer catch then callscloseStep(), which appends the balancingstep/end, and the turn closes balanced with an error (turn/start → step/start → step/end → turn/end— verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. - The full realization of this is the simplification RFC "Stop mirroring durable boundaries as agent events": all four boundary mirrors are removed and every consumer reads boundaries off
session/event.agent/steering(a live control signal, not a boundary mirror) is retained; see that RFC's scope section. - The cordis catalog (
docs/cordis-catalog/events-and-services.md) is regenerated to drop the mirror events.