Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test.
5.2 KiB
RFC: Fold trace-only session facts into load-bearing events
Status: implemented (proposed and accepted 2026-06-20)
Problem
The session event vocabulary includes first-class events that are not part of replayable conversation history and have little or no production consumption. usage is already present as a model stream chunk before the loop also appends a separate usage event. error duplicates the turn/end { kind: 'error', message, code } reason for loop failures; ACP settlement reads the turn-end reason, ACP rendering ignores the error event, and deriveMessages() skips it.
These events make the canonical transcript look more useful as telemetry than it currently is. They add event variants, invariants, tests, snapshots, and persistence cases, but they are not load-bearing as separate records. The facts they carry can still be useful: token usage should remain available for accounting, and an error's step number should not silently disappear. The simplification is to fold those facts into nearby events consumers already must understand, not to record less information.
Proposal
Remove standalone trace-only events only where their information can be preserved without a parallel record:
- Fold successful-step usage into the matching
assistant/message, e.g.assistant/message { turn, step, content, usage? }, so the assembled model output and its accounting travel together. - For a failed or aborted step that has usage but no
assistant/message, carry the usage on the terminal turn reason or another load-bearing failure record in the same turn. The implementing design must prove no usage chunk that is currently persisted becomes unrepresented. - Fold the step number from the standalone
errorevent intoturn/end.reasonforkind: 'error', e.g.{ kind: 'error', step, message, code? }.turn/endis the durable turn outcome ACP and resume already consume. - Keep
agent/errorand logging for live diagnostics; do not add a second session-log error record afterturn/end.
If analytics become real, add a projection helper or a dedicated telemetry store with its own retention policy. The user conversation log should contain what is needed to render, resume, audit, and account for the interaction without requiring consumers to reconcile duplicate trace rows.
Acceptance criteria
SessionEventMapdrops standaloneusageanderroronly after their fields are represented on load-bearing session events.- The loop no longer appends a separate
usageevent for a usage chunk. - The loop records durable failures through
turn/end { kind: 'error', step, message, code? }or an equivalent no-information-loss shape and reports live diagnostics throughagent/error. - ACP snapshots and persistence tests stop asserting trace-only lines.
- Documentation explains exactly where token usage and operational errors are observed.
- Recorded fixtures are refreshed for the new event shape; the session format version stays pinned at
0(unstable/pre-release) and backends reject any non-0stored log per the pre-release format policy.
What we give up
A consumer can no longer filter the canonical log for standalone usage or step-level error rows. It must read those facts from the assistant/failure events that carry them. That is a reasonable simplification only if the implementing PR proves the same facts remain present; otherwise the standalone events should stay.
Implementation note
Shipped as proposed, with one scope refinement (per AGENTS.md "RFCs are proposals, not golden truth"):
- Empty-content
assistant/messagehosts usage with no data loss. The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standaloneusage. It now records an empty-contentassistant/message { content: [], usage }. To keep that from injecting a spurious content-less assistant turn into the provider transcript,deriveMessages()skips empty-contentassistant/messageevents. A regression test asserts usage stays represented AND derived history is uncorrupted.
Format version. The persisted SessionEventMap shape changed (usage folded onto assistant/message, standalone usage/error removed, step on turn/end.reason.error), so per the AGENTS.md "bump the version and reject — don't migrate" policy a backend must reject any non-current log. The version literal is centralized in an exported SESSION_FORMAT_VERSION constant (read by both write sites and the coordinator's load-time check). While the harness is unreleased the on-disk format is pre-release/unstable, so the constant stays 0: a breaking format change is absorbed at v0 (no monotonic bump until the first tagged release, when a specific format boundary becomes worth distinguishing) and old logs at any other version are rejected on load — there is no v0→vN migration (no persisted user data exists). turn/end.reason.error.step is required for newly-written logs.
Usage is now observed on assistant/message.usage; an operational error's step on turn/end.reason for kind: 'error'. agent/error + logging are unchanged for live diagnostics.