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
@@ -213,9 +213,9 @@ describe('toError normalization', () => {
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('naked string error')
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
// session error event carries a routable code instead of degrading.
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
// turn-end error reason carries a routable code instead of degrading.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
@@ -240,8 +240,8 @@ describe('toError normalization', () => {
expect(errors).toHaveLength(1)
// String() of { code: 500 } is '[object Object]'
expect(errors[0]!.message).toBe('[object Object]')
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
})
})
@@ -268,11 +268,11 @@ describe('coded error data emission', () => {
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('server overloaded')
// session error event includes the code
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent).toBeDefined()
if (errorEvent!.type === 'error') {
expect(errorEvent!.data.code).toBe('RATE_LIMIT')
// turn-end error reason includes the code
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect(turnEnd.data.reason.code).toBe('RATE_LIMIT')
}
})
})