refactor: simplify session log representation

This commit is contained in:
Tianyi Cui
2026-07-13 23:56:10 +08:00
parent d060c0dd4f
commit 1123e946c0
64 changed files with 522 additions and 1032 deletions

View File

@@ -22,10 +22,9 @@ export * from './types.ts'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isToolPairingBalanced } from './tool-pairing.ts'
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
declare module 'cordis' {
interface Context {
@@ -197,6 +196,9 @@ function assertSurfaceMetadataShape(
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
if (event['type'] === 'request/header-delta') {
throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
}
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
if (Object.keys(event).some(key => !allowed.has(key))
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
@@ -262,7 +264,7 @@ export class Session {
private log: SessionEvent[] = []
/**
* Derived surface — a cached linked list of message-producing events.
* Derived surface — a cached order of message-producing event sequences.
* Lazily rebuilt from `surfaceOp` markers in the log; processes only new
* events (delta) on each access — the log is append-only, so prior events
* never change.
@@ -270,7 +272,7 @@ export class Session {
*/
private _surface: SurfaceManager | undefined
/** The surface linked list over this session's event log. */
/** The ordered surface over this session's event log. */
get surface(): SurfaceManager {
if (!this._surface) this._surface = new SurfaceManager(this.log)
return this._surface
@@ -354,7 +356,7 @@ export class Session {
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
* the surface linked list; `sourceEventSeqs` records provenance (the seq
* the ordered surface; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
@@ -463,8 +465,8 @@ export class Session {
private derivedGeneration = 0
/**
* Derive the LLM message history by walking the session surface — the linked
* list of message-producing events maintained by `surfaceOp` markers. The
* Derive the LLM message history by walking the ordered sequences of
* message-producing events maintained by `surfaceOp` markers. The
* surface is the single source of derived history: every message-producing
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
* turn boundary) is correctly absent, and a compaction `replace` deletes the
@@ -488,11 +490,11 @@ export class Session {
this.derivedNodes = 0
this.derivedGeneration = generation
}
for (const node of nodes.slice(this.derivedNodes)) {
// Surface nodes are built from this.log — node.seq is always a valid
for (const seq of nodes.slice(this.derivedNodes)) {
// Surface sequences are built from this.log — seq is always a valid
// index by construction. The non-null assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const msg = this.deriveEventMessage(this.log[node.seq]!)
const msg = this.deriveEventMessage(this.log[seq]!)
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
// usage) derives to null and must not enter the transcript.

View File

@@ -1,35 +1,20 @@
/**
* Request-header reconstruction utilities: the pure fold/diff/apply trio over
* the `request/header` / `request/header-delta` session events. Anyone
* holding a session log reconstructs the {@link EpochHeader} any request was
* built under by folding these events in log order; the loop uses the same
* functions to decide whether a step's header changed and to encode the
* change. Deltas are an encoding optimization with a safety valve — the
* writer round-trip-verifies every delta before appending and falls back to
* a full snapshot when the encoding cannot express the change — so folding
* never needs error recovery on a well-formed log.
* Request-header reconstruction utilities over full `request/header` session
* events. Anyone holding a session log reconstructs the {@link EpochHeader}
* any request was built under by taking the latest canonical snapshot; the
* loop uses the same equality helper to avoid logging unchanged headers.
*
* @module dsh-session/request-header
*/
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
/** The `request/header-delta` payload shape: each present field amends the folded header. */
type HeaderDelta = {
system?: SystemDelta
tools?: ToolsDelta
config?: LlmCallConfig
messagePrefix?: Message[]
}
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, SessionEvent } from './types.ts'
/**
* Normalize a header to canonical form: an empty system prompt, an empty
* tool list, and an empty session prefix become ABSENT fields, matching how
* requests are built (the request-build spreads skip empty values). Diff,
* fold, and comparison all operate on canonical headers, so "no system
* prompt" (and "no session prefix") has exactly one representation.
* Normalize a header to canonical form: an empty system prompt, an empty tool
* list, and an empty session prefix become absent fields, matching how requests
* are built. Logging, folding, and comparison use this one representation.
* @param header - the header to normalize (not mutated).
* @returns the canonical header.
*/
@@ -42,88 +27,22 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
}
}
/** Split a canonical (possibly absent) system prompt into lines; absence is zero lines. */
function systemLines(system: string | undefined): string[] {
return system === undefined ? [] : system.split('\n')
}
/** Join lines back into a canonical system value; zero lines is absence. */
function joinSystem(lines: string[]): string | undefined {
return lines.length === 0 ? undefined : lines.join('\n')
}
/**
* Compute the line-level {@link SystemDelta} between two canonical system
* prompts: trim the common prefix and (non-overlapping) common suffix, and
* carry the replacement lines between them. Deterministic and library-free;
* with nothing shared it degenerates to a full replacement.
*/
function diffSystem(prev: string | undefined, next: string | undefined): SystemDelta {
const a = systemLines(prev)
const b = systemLines(next)
let keepStart = 0
while (keepStart < a.length && keepStart < b.length && a[keepStart] === b[keepStart]) keepStart += 1
let keepEnd = 0
while (
keepEnd < a.length - keepStart &&
keepEnd < b.length - keepStart &&
a[a.length - 1 - keepEnd] === b[b.length - 1 - keepEnd]
) keepEnd += 1
return { keepStart, keepEnd, insert: b.slice(keepStart, b.length - keepEnd) }
}
/** Apply a {@link SystemDelta} to a canonical system prompt. */
function applySystem(prev: string | undefined, delta: SystemDelta): string | undefined {
const a = systemLines(prev)
return joinSystem([...a.slice(0, delta.keepStart), ...delta.insert, ...a.slice(a.length - delta.keepEnd)])
}
/** Canonical JSON equality for tool schemas — sound because schemas are
* JSON-serializable by construction and both sides come from the same
* assembly path, so key insertion order matches when the values do. */
/** Canonical JSON equality for tool schemas assembled through the same path. */
function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
/**
* Compute the name-keyed {@link ToolsDelta} between two canonical tool lists.
* A pure reordering produces an empty delta — the writer's round-trip guard
* catches that case and records a snapshot instead.
*/
function diffTools(prev: readonly ToolSchema[], next: readonly ToolSchema[]): ToolsDelta {
const prevByName = new Map(prev.map(tool => [tool.name, tool]))
const nextNames = new Set(next.map(tool => tool.name))
return {
added: next.filter(tool => !prevByName.has(tool.name)),
removed: prev.filter(tool => !nextNames.has(tool.name)).map(tool => tool.name),
changed: next.filter((tool) => {
const before = prevByName.get(tool.name)
return before !== undefined && !sameSchema(before, tool)
}),
}
}
/** Apply a {@link ToolsDelta} to a canonical tool list: drop removed, replace changed in place, append added. */
function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[] {
const removed = new Set(delta.removed)
const changedByName = new Map(delta.changed.map(tool => [tool.name, tool]))
const kept = prev
.filter(tool => !removed.has(tool.name))
.map(tool => changedByName.get(tool.name) ?? tool)
return [...kept, ...delta.added]
/** Canonical JSON equality over session-prefix arrays; absence equals empty. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
/**
* Field-wise equality over canonical headers — the cheap comparison the
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
* the intended header) and the loop runs to skip logging an unchanged header.
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
* correctly unequal; the session prefix compares as canonical JSON (both
* sides come from the same build path, so key order matches when the values
* do).
* Field-wise equality over canonical headers. Tool schemas compare in order;
* the session prefix compares as canonical JSON.
* @param a - one canonical header.
* @param b - the other.
* @returns whether config, system, tools (in order), and the session prefix all match.
* @returns whether config, system, tools, and session prefix all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
@@ -133,77 +52,19 @@ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
}
/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
/**
* Compute the `request/header-delta` payload between two canonical headers,
* or undefined when they are equal. The caller MUST round-trip the result
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
* the encoding cannot express every change (a pure tool reordering) — and
* fall back to a full `request/header` snapshot when the check fails.
* The session prefix is replaced whole (small advisory content, not worth
* diffing); an empty replacement array encodes the transition to "none".
* @param prev - the folded header the log currently implies.
* @param next - the header the next request will actually use.
* @returns the delta payload, or undefined when nothing changed.
*/
export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined {
const delta: HeaderDelta = {}
if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system)
const prevTools = prev.tools ?? []
const nextTools = next.tools ?? []
if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
return Object.keys(delta).length > 0 ? delta : undefined
}
/**
* Apply a `request/header-delta` payload to a canonical header, producing the
* canonical header it encodes. Total for well-formed logs (the writer only
* appends round-trip-verified deltas).
* @param prev - the folded header before the delta.
* @param delta - the logged delta payload.
* @returns the canonical header after the delta.
*/
export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader {
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
return canonicalHeader({
config: delta.config ?? prev.config,
...system !== undefined ? { system } : {},
...tools !== undefined ? { tools } : {},
...messagePrefix !== undefined ? { messagePrefix } : {},
})
}
/**
* Fold the header events of a log (or any prefix of one) into the
* {@link EpochHeader} in force after the last of them: each
* `request/header` snapshot replaces the state, each `request/header-delta`
* amends it. The pure, offline form of reconstruction — external tooling and
* the dev invariant both use it; the live session tracks the same fold
* incrementally.
* @param events - session events in log order (non-header events are skipped).
* @param from - a previously folded state to continue from (the live session's
* incremental cursor); omit to fold from nothing.
* @returns the folded header, or undefined when no header event exists yet.
* Fold the header events of a log (or any prefix) into the
* {@link EpochHeader} in force after the last snapshot. Non-header events are
* skipped. This is the pure offline reconstruction path; the live session
* tracks the same fold incrementally.
* @param events - session events in log order.
* @param from - a previously folded state to continue from.
* @returns the latest canonical header, or undefined when none exists yet.
*/
export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
let state: EpochHeader | undefined = from
let state = from
for (const event of events) {
if (event.type === 'request/header') {
state = canonicalHeader(event.data.header)
} else if (event.type === 'request/header-delta') {
if (state === undefined) {
throw new Error(`request/header-delta at seq ${event.seq} before any request/header snapshot: corrupt log`)
}
state = applyHeaderDelta(state, event.data)
}
if (event.type === 'request/header') state = canonicalHeader(event.data.header)
}
return state
}

View File

@@ -1,6 +1,6 @@
/**
* Surface layer on top of the session event log: a derived, cached linked list
* of events that produce LLM messages. Rebuilt deterministically from
* Surface layer on top of the session event log: a derived, cached sequence
* list of events that produce LLM messages. Folded deterministically from
* `surfaceOp` markers in the log — the log is the source of truth; the surface
* is a view.
*
@@ -10,7 +10,7 @@
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
/**
* The set of event type strings that are eligible for the surface linked list.
* The set of event type strings that are eligible for the surface sequence.
* Mirrors the {@link SurfaceEventType} union; kept as a runtime set so the
* type guard can check membership without a chain of string comparisons.
*/
@@ -51,28 +51,16 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
return true
}
/** One node in the surface linked list. */
export interface SurfaceNode {
/** The event seq of this surface node. */
seq: number
/** The previous surface node's seq, or null if this is the head. */
prev: number | null
/** The next surface node's seq, or null if this is the tail. */
next: number | null
}
/**
* Maintains a cached linked list of surface nodes, rebuilt lazily from
* Maintains a cached ordered list of surface event sequences, folded lazily from
* `surfaceOp` markers in the event log. Because the log is append-only, it
* processes only the delta since the last rebuild — new events are folded
* into the existing surface in O(new events) rather than rescanning the
* whole log.
*/
export class SurfaceManager {
/** Surface nodes in linked-list order (head to tail). Empty until first access. */
private _nodes: SurfaceNode[] = []
/** Map from event seq → node. */
private _nodeBySeq = new Map<number, SurfaceNode>()
/** Surface event sequences in head-to-tail order. Empty until first access. */
private _nodes: number[] = []
/** The last processed seq. -1 folds the seeded log on first access. */
private _lastProcessedSeq = -1
@@ -95,15 +83,15 @@ export class SurfaceManager {
return this._replaceGeneration
}
/** The surface nodes in linked-list order (head to tail). */
get nodes(): readonly SurfaceNode[] {
/** Surface event sequences in head-to-tail order. */
get nodes(): readonly number[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
return this._nodes
}
/**
* Process events from `_lastProcessedSeq + 1` through the end of the log,
* folding new surface markers into the existing linked list.
* folding new surface markers into the existing sequence list.
*/
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
@@ -116,11 +104,7 @@ export class SurfaceManager {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp === 'append') {
const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
if (tail) tail.next = event.seq
this._nodes.push(node)
this._nodeBySeq.set(event.seq, node)
this._nodes.push(event.seq)
} else {
this._replace(event.seq, event.surfaceOp)
}
@@ -133,38 +117,21 @@ export class SurfaceManager {
newSeq: number,
op: Extract<SurfaceOp, { op: 'replace' }>,
): void {
const startNode = this._nodeBySeq.get(op.start)
if (!startNode) {
const startIdx = this._nodes.indexOf(op.start)
if (startIdx === -1) {
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
}
const endNode = this._nodeBySeq.get(op.end)
if (!endNode) {
const endIdx = this._nodes.indexOf(op.end)
if (endIdx === -1) {
throw new Error(`surface replace: end seq ${op.end} not found in surface`)
}
const startIdx = this._nodes.indexOf(startNode)
const endIdx = this._nodes.indexOf(endNode)
if (startIdx > endIdx) {
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
}
// Remove shadowed nodes from `[startIdx, endIdx]` inclusive.
const count = endIdx - startIdx + 1
const removed = this._nodes.splice(startIdx, count)
for (const r of removed) this._nodeBySeq.delete(r.seq)
// Insert the new node where the removed range was.
const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined
const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined
const newNode: SurfaceNode = {
seq: newSeq,
prev: prevNode?.seq ?? null,
next: nextNode?.seq ?? null,
}
if (prevNode) prevNode.next = newSeq
if (nextNode) nextNode.prev = newSeq
this._nodes.splice(startIdx, 0, newNode)
this._nodeBySeq.set(newSeq, newNode)
this._nodes.splice(startIdx, count, newSeq)
this._replaceGeneration += 1
}
}

View File

@@ -35,7 +35,6 @@
*/
import type { SessionEvent } from './types.ts'
import type { SurfaceNode } from './surface.ts'
/**
* The tool-pairing delta of a surface node: how it shifts the count of
@@ -62,20 +61,20 @@ function nodeDelta(event: SessionEvent): number {
* cut has its answering `tool/result` before the cut too, so the cut is a safe
* edge for a collapsed region (it cannot split an assistant↔result pair).
*
* `nodes` is the surface linked list in head→tail order (e.g.
* `nodes` is the surface sequence list in head→tail order (e.g.
* `session.surface.nodes`); `events` is the session log, used to look each
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
* event up by sequence. `beforeSeq` names the cut by the surface event it
* sits immediately before; the after-tail cut (the whole surface) is `null`,
* as is any `beforeSeq` not present on the surface.
*
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* surface successor (`nodes[index + 1]`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @param nodes - the surface linked list in head→tail order.
* @param events - the session log each node's `seq` indexes into.
* @param nodes - surface event sequences in head→tail order.
* @param events - the session log each sequence indexes into.
* @param beforeSeq - names the cut (the node it sits immediately before);
* `null` — or any seq not on the surface — means the after-tail cut.
* @returns true when every `tool-call` before the cut is answered before it
@@ -86,18 +85,18 @@ function nodeDelta(event: SessionEvent): number {
* rather than silently mis-classifying a boundary.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
nodes: readonly number[],
events: readonly SessionEvent[],
beforeSeq: number | null,
): boolean {
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// node.seq is a surface-node seq, always a valid log index by construction.
for (const seq of nodes) {
if (seq === beforeSeq) return depth === 0
// seq is a surface event sequence, always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
depth += nodeDelta(events[seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
}
}
// Reached the after-tail cut (beforeSeq === null, or a seq not on the

View File

@@ -195,10 +195,9 @@ export interface TodoItem {
* The request header: everything about an LLM request besides its derived
* message history — the call configuration plus the rendered system prompt,
* tool schemas, and the session prefix. Logged session state (the
* reconstructability RFC): a
* {@link SessionEventMap} `request/header` snapshot installs one, a
* `request/header-delta` amends it, and folding those events over the log
* (`foldRequestHeader`) reconstructs the header any request was built under.
* reconstructability RFC): each changed header is logged as a full
* {@link SessionEventMap} `request/header` snapshot, and taking the latest
* snapshot (`foldRequestHeader`) reconstructs the header any request used.
* Canonical form: an empty system prompt, an empty tool list, and an empty
* prefix are ABSENT fields, matching how requests are built.
*/
@@ -223,43 +222,9 @@ export interface EpochHeader {
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
* header (a new conversation); `'resume'` — a loop instance's first request
* over a log that already has header events (process restart, fork seed);
* `'fallback'` — a mid-run change the delta encoding could not round-trip
* (e.g. a pure tool reordering), recorded whole instead.
* `'change'` — a later request used a different header.
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'fallback'
/**
* Line-level edit of the system prompt: keep the first `keepStart` and last
* `keepEnd` lines of the previous text, with `insert` replacing everything
* between. Computed as a common-prefix/common-suffix trim — deterministic,
* library-free, degenerating to a full replacement when nothing is shared.
* Absence is encoded as zero lines (the canonical form has no empty-string
* system), so a transition to or from "no system prompt" round-trips.
*/
export interface SystemDelta {
/** Lines kept from the start of the previous system prompt. */
keepStart: number
/** Lines kept from the end of the previous system prompt. */
keepEnd: number
/** Lines replacing everything between the kept edges. */
insert: string[]
}
/**
* Tool-set edit keyed by tool name (names are unique — the registry rejects
* duplicates): `removed` names drop, `changed` schemas replace their
* predecessor in place, `added` schemas append at the end. A change this
* encoding cannot express (a pure reordering) fails the writer's round-trip
* guard and is recorded as a `'fallback'` snapshot instead.
*/
export interface ToolsDelta {
/** Schemas appended to the end of the tool list. */
added: ToolSchema[]
/** Names of schemas dropped from the tool list. */
removed: string[]
/** Schemas replacing the same-named predecessor in place. */
changed: ToolSchema[]
}
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/**
* The session event vocabulary — the append-only source of truth for an
@@ -363,32 +328,14 @@ export interface SessionEventMap {
* Full snapshot of the {@link EpochHeader} the NEXT request is built under,
* with the {@link RequestHeaderReason} it was recorded whole. Appended by
* the loop inside the step, before dispatch, on a loop instance's first
* request-building step (`'initial'`/`'resume'`) or when a delta failed its
* round-trip guard (`'fallback'`); always records what the request actually
* used, post-`agent/request`. Anchors the header fold: reconstruction reads
* the latest snapshot and applies the deltas after it. NOT a
* request-building step (`'initial'`/`'resume'`) or when a later request's
* header changes (`'change'`); always records what the request actually used,
* post-`agent/request`. Reconstruction reads the latest snapshot. NOT a
* {@link SurfaceEventType}: it produces no LLM message — it is the request
* envelope, logged so every request is a pure function of the session log
* (the reconstructability RFC).
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Amendment to the folded {@link EpochHeader}: at least one of a
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
* replacement session prefix (`messagePrefix` — small advisory content,
* replaced whole; an EMPTY array encodes the transition to "none",
* mirroring the canonical form's absent field — the loop never produces
* one in practice: the prefix is composed once per instance and anchored
* by that instance's snapshot, so this arm exists for codec totality).
* Appended by the
* loop inside the step, before dispatch, when the header for this request
* differs from the fold of the log so far; the writer verifies
* `applyHeaderDelta(previous, delta)` reproduces the new header exactly and
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
*/
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
@@ -396,7 +343,7 @@ export type SessionEventType = keyof SessionEventMap
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the surface linked list. Only these
* messages and are eligible to appear on the ordered surface. Only these
* event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
*/
export type SurfaceEventType =
@@ -407,7 +354,7 @@ export type SurfaceEventType =
| 'steering/message'
/**
* A {@link SessionEvent} that is **on** the surface linked list — its
* A {@link SessionEvent} that is **on** the ordered surface — its
* `surfaceOp` is guaranteed present (mandatory), narrowed from a
* surface-eligible {@link SessionEvent} by checking both `type` and
* `surfaceOp` at runtime.
@@ -418,7 +365,7 @@ export type SurfaceEventType =
export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: SurfaceOp }
/**
* How a session event entered the surface linked list. Only valid on
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
@@ -435,7 +382,7 @@ export type SurfaceOp =
/**
* Surface metadata passed to {@link Session.append}.
* `surfaceOp` controls how the event enters the surface linked list;
* `surfaceOp` controls how the event enters the ordered surface;
* `sourceEventSeqs` records the seq numbers of events that are provenance
* sources of this one (e.g. the `assistant/chunk` seqs behind an
* `assistant/message`, or the shadowed nodes behind a compaction replacement).