simplify(session): fold trace-only usage/error events into load-bearing events

The session event vocabulary carried two standalone trace-only events that
were not load-bearing as separate records. Fold their facts into nearby
load-bearing events and delete the standalone variants.

- Token usage now rides on `assistant/message` as an optional `usage` field —
  the assembled model output and its accounting travel together. The loop folds
  `assembler.usage` onto the append instead of emitting a separate `usage`
  event.
- The max-tokens path is the no-data-loss host: a step cut off with usage but
  EMPTY content (e.g. only a dropped tool call) previously emitted a standalone
  `usage`; it now records an empty-content `assistant/message { content: [],
  usage }`. `deriveMessages()` skips empty-content assistant messages, so the
  usage host never injects a spurious content-less assistant turn into the
  provider transcript. A step with neither content nor usage appends nothing.
- An operational error's step number now rides on `turn/end.reason` for
  `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable
  turn outcome ACP and resume already consume. `failTurn` sets the reason
  directly (no separate session `error` event). `agent/error` + logging are
  unchanged for live diagnostics.
- No format-version bump: pre-release, no persisted data, so per the format
  policy there is nothing to migrate or reject (the RFC's "refresh the format
  version" criterion over-reached). `version` stays 1.
- ACP fixtures + goldens re-recorded (keyless replay): dropped standalone
  usage/error lines, usage folded onto assistant/message, error step on
  turn/end.reason.

RFC moved proposed -> implemented with an implementation note recording the two
scope refinements.
This commit is contained in:
Tianyi Cui
2026-06-21 10:00:06 +08:00
parent c6ed980d6f
commit 2be60b9a22
30 changed files with 480 additions and 470 deletions

View File

@@ -192,8 +192,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
// turn as its commit/replay boundary (the JSONL backend treats anything
// after the last turn/end as a crash tail), so a bare event between turns is
// silently dropped on reload. The loop records queued user messages after
// turn/start, an idle agent.inject() wraps its context/message in a one-shot
// turn, and usage/error are only appended inside an open turn. A `default`
// turn/start, and an idle agent.inject() wraps its context/message in a
// one-shot turn. A `default`
// (not an enumerated list) is deliberate: SessionEventMap is
// merge-extensible, so a PLUGIN-added event type appended while idle must
// also fail here rather than fall through and be dropped on resume.

View File

@@ -95,14 +95,12 @@ describe('session-log invariants', () => {
.toThrow(/outside any open turn/)
})
it('rejects usage/error and plugin-added events appended outside any open turn', async () => {
it('rejects steering and plugin-added events appended outside any open turn', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
// usage and error are turn-scoped: outside a turn they would land past the
// steering/message is turn-scoped: outside a turn it would land past the
// commit boundary and be dropped on resume (the turn-enclosure RFC).
expect(() => session.append('usage', { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } }))
.toThrow(/outside any open turn/)
expect(() => session.append('error', { turn: 1, step: 1, message: 'boom' }))
expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
.toThrow(/outside any open turn/)
// A PLUGIN-added (merge-extensible) event type is caught by the default too.
expect(() => session.append('compaction/marker' as never, { foo: 'bar' } as never))
@@ -156,7 +154,7 @@ describe('session-log invariants', () => {
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'error', message: 'boom' } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
}).not.toThrow()
})

View File

@@ -130,11 +130,11 @@ describe('deriveReplayScript', () => {
})
it('throws on a group that lacks a terminal finish chunk (a thrown stream)', () => {
// A thrown stream(): prefix chunks logged, then error/turn/end, NO finish.
// A thrown stream(): prefix chunks logged, then turn/end (error reason), NO finish.
const events: SessionEvent[] = [
chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }),
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', message: 'x' } } },
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'x' } } },
]
expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s)
})