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

@@ -170,8 +170,8 @@ export interface LoopHandle {
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
* session('step/start') same sync frame, strictly before step/start
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
* log (initial/resume anchor, delta, fallback)
* session('request/header') ⟵ the header event this request owes the
* log (initial/resume anchor or changed snapshot)
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
* session('assistant/chunk')
@@ -774,7 +774,7 @@ async function runStep(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
// The request header (the log's request/header* vocabulary): canonical form,
// The request header (the log's request/header snapshots): canonical form,
// recorded before dispatch so the log always explains the request —
// including the session prefix, which no other event carries.
const header = canonicalHeader({

View File

@@ -5,12 +5,12 @@
* otherwise transmission-stateless — the comparison baseline is the log's own
* folded header (`Session.requestHeader()`), so resume and fork need no
* special path: a fresh loop instance simply logs a `'resume'` snapshot on
* its first request and deltas from there.
* its first request and full changed-header snapshots from there.
*
* @module dsh-agent-loop/request-log
*/
import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session'
import { headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
@@ -38,7 +38,7 @@ export function createTransmissionLog(): TransmissionLog {
/**
* Append whatever header event this request owes the log, so folding the log
* reproduces the header the request was built under. Exactly one of four
* reproduces the header the request was built under. Exactly one of three
* things happens:
*
* 1. This loop instance has not logged a header yet → a full `request/header`
@@ -48,11 +48,7 @@ export function createTransmissionLog(): TransmissionLog {
* snapshot is appended even when nothing changed).
* 2. The header equals the folded baseline → nothing; the log already
* explains this request.
* 3. It differs and the delta round-trips (`applyHeaderDelta` on the baseline
* reproduces the header exactly) → a `request/header-delta`.
* 4. It differs and the delta encoding cannot express the change (a pure tool
* reordering) → a full snapshot with reason `'fallback'`; deltas are an
* encoding optimization, never a correctness dependency.
* 3. It differs → a full snapshot with reason `'change'`.
*
* @param session - the session whose log explains the request.
* @param state - this loop instance's bookkeeping (mutated on first log).
@@ -69,12 +65,5 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const baseline = session.requestHeader()!
if (headerEquals(baseline, header)) return
const delta = diffHeader(baseline, header)
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */
if (delta === undefined) return
if (headerEquals(applyHeaderDelta(baseline, delta), header)) {
session.append('request/header-delta', delta)
} else {
session.append('request/header', { header, reason: 'fallback' })
}
session.append('request/header', { header, reason: 'change' })
}

View File

@@ -374,8 +374,8 @@ describe('agent/session-prefix', () => {
expect(request.messages[0]).toEqual(reminder)
}
// The anchoring snapshot is the prefix's durable record — and the ONLY
// header event: reuse means no request/header-delta ever.
const headerEvents = events(agent).filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
// header event: reuse means no changed snapshot ever.
const headerEvents = events(agent).filter(e => e.type === 'request/header')
expect(headerEvents).toHaveLength(1)
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder])
// Never session history: the derivation starts at the real user prompt.
@@ -491,7 +491,7 @@ describe('agent/session-prefix', () => {
// cached prefix is a deep-frozen clone, so step 2's request is unchanged.
held.content = [{ type: 'text', text: 'v2' }]
expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] })
expect(events(agent).filter(e => e.type === 'request/header-delta')).toHaveLength(0)
expect(events(agent).filter(e => e.type === 'request/header')).toHaveLength(1)
})
})

View File

@@ -1,9 +1,8 @@
/**
* recordRequestHeader unit tests: exactly one of four things per request —
* recordRequestHeader unit tests: exactly one of three things per request —
* an 'initial' snapshot (log has no header yet), a 'resume' snapshot (fresh
* loop instance over a log that has one), nothing (header unchanged), a
* round-tripping delta, or a 'fallback' snapshot when the delta encoding
* cannot express the change (pure tool reordering).
* loop instance over a log that has one), nothing (header unchanged), or a
* full 'change' snapshot.
*/
import { describe, expect, it } from 'vitest'
@@ -23,7 +22,7 @@ function openSession(id: string): Session {
}
function headerEvents(session: Session): SessionEvent[] {
return session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
return session.events.filter(e => e.type === 'request/header')
}
describe('recordRequestHeader', () => {
@@ -53,8 +52,8 @@ describe('recordRequestHeader', () => {
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('resume')
})
it('logs a round-tripping delta for a mid-run change, and the fold reproduces the header', () => {
const session = openSession('rl-delta')
it("logs a full 'change' snapshot for a mid-run change, and the fold reproduces the header", () => {
const session = openSession('rl-change')
const state = createTransmissionLog()
const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] })
recordRequestHeader(session, state, first)
@@ -63,12 +62,12 @@ describe('recordRequestHeader', () => {
recordRequestHeader(session, state, second)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type).toBe('request/header-delta')
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change')
expect(session.requestHeader()).toEqual(second)
})
it("records a change the delta cannot express (pure reordering) as a 'fallback' snapshot", () => {
const session = openSession('rl-fallback')
it("records a pure tool reordering as a 'change' snapshot", () => {
const session = openSession('rl-reorder')
const state = createTransmissionLog()
const first = canonicalHeader({ config: { model: 'm' }, tools: [tool('a'), tool('b')] })
recordRequestHeader(session, state, first)
@@ -77,9 +76,7 @@ describe('recordRequestHeader', () => {
recordRequestHeader(session, state, reordered)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('fallback')
// The fold still lands on the exact header — deltas are an encoding
// optimization, never a correctness dependency.
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change')
expect(session.requestHeader()).toEqual(reordered)
})
})

View File

@@ -1,7 +1,7 @@
/**
* Loop-level reconstructability: every request the loop sends is a pure
* function of the session log — messages are the derivation at the step/start
* boundary, the header is the fold of request/header* events — and every
* boundary, the header is the latest request/header snapshot — and every
* request is an append-extension of its predecessor unless a logged event
* (compaction replace, header change) explains the difference. The requests
* recorded by the mock adapter are the observable; the offline-rebuild test
@@ -87,7 +87,7 @@ describe('request stability across the loop', () => {
expect(Object.isFrozen(request.messages)).toBe(true)
}
// One anchoring header snapshot; no further header events (nothing changed).
const headerEvents = agent.session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
const headerEvents = agent.session.events.filter(e => e.type === 'request/header')
expect(headerEvents).toHaveLength(1)
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial')
})
@@ -124,8 +124,8 @@ describe('request stability across the loop', () => {
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}, {
surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq },
sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq],
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
sourceEventSeqs: [nodes[0]!, nodes[1]!],
})
})
@@ -139,7 +139,7 @@ describe('request stability across the loop', () => {
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
})
it('a real system-prompt change is a logged header delta; a stable prompt logs nothing', async () => {
it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -149,14 +149,15 @@ describe('request stability across the loop', () => {
send(agent, 'second')
await waitForIdle(ctx, agent)
// Identical assembly re-rendered per step is NOT a change.
expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0)
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' })
send(agent, 'third')
await waitForIdle(ctx, agent)
const deltas = agent.session.events.filter(e => e.type === 'request/header-delta')
expect(deltas).toHaveLength(1)
const snapshots = agent.session.events.filter(e => e.type === 'request/header')
expect(snapshots).toHaveLength(2)
expect(snapshots[1]?.data.reason).toBe('change')
expect(adapter.requests[2]!.system).toContain('new guidance')
// History is preserved across the change — only the header moved.
expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length)
@@ -262,9 +263,9 @@ describe('request stability across the loop', () => {
send(agent, 'second')
await waitForIdle(ctx, agent)
// No delta was logged (nothing really changed), and the session's own
// No changed snapshot was logged (nothing really changed), and the session's own
// fold is immutable state.
expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0)
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
expect(Object.isFrozen(agent.session.requestHeader())).toBe(true)
expect(adapter.requests[1]!.temperature).toBeUndefined()
})
@@ -298,7 +299,7 @@ describe('request stability across the loop', () => {
const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
// Header: the fold of request/header* events up to this step's dispatch
// Header: the latest request/header snapshot up to this step's dispatch
// (its header event sits between step/start and the first chunk).
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!

View File

@@ -464,7 +464,7 @@ declare module 'cordis' {
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
* the header-logged session prefix via {@link agent/session-prefix}
* — never through request mutation, and the loop records whatever config
* the request actually uses as a `request/header*` event before dispatch.
* the request actually uses as a `request/header` event before dispatch.
* The step's messages are already snapshotted when this fires (the
* `step/start` boundary): an `inject()` from a listener here lands in the
* log but joins the NEXT request. For surface mutation that must precede

View File

@@ -1,6 +1,6 @@
# dsh-session
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (a linked list of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered sequence of message-producing event seqs) is maintained on top of the raw log for efficient derivation and compaction.
## Service: `SessionStore` (ctx key: `sessions`)
@@ -33,7 +33,7 @@ The store pairs announced creation with disposal, publishes each append, and pro
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface entry is projected exactly once, when first seen (O(new entries) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.surface: SurfaceManager` — the derived surface, lazily folded from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and never reset, so an incremental consumer comparing generations cannot be fooled.
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
@@ -46,14 +46,13 @@ Durable values need one accepted representation, not a check followed by a secon
### Surface types
- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
### Request-header reconstruction (`request-header.ts`)
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it.
The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` format are rejected rather than partially replayed.
### Session event vocabulary (`types.ts`)

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).

View File

@@ -44,7 +44,7 @@ describe('derived-message cache', () => {
const nodes = session.surface.nodes
session.append('context/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(session.deriveMessages()).toHaveLength(1)
expect(session.deriveMessages()).toEqual(scratch(session))

View File

@@ -1,14 +1,7 @@
/**
* Request-header utility tests: canonical form, the system line-diff
* (prefix/suffix trim), the name-keyed tools delta, config replacement, the
* round-trip contract (including the reorder case the encoding cannot
* express), and the log fold. These pin the reconstruction algebra: for every
* logged delta, apply(prev, delta) === next, and folding a log prefix yields
* the header its next request was built under.
*/
/** Request-header canonicalization, equality, snapshot folding, and format rejection. */
import { describe, expect, it } from 'vitest'
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -22,165 +15,57 @@ function msg(text: string): Message {
return { role: 'user', content: [{ type: 'text', text }] }
}
/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
const delta = diffHeader(prev, next)
if (delta !== undefined) {
expect(applyHeaderDelta(prev, delta)).toEqual(canonicalHeader(next))
}
return delta
}
describe('canonicalHeader', () => {
it('normalizes empty system and empty tools to absent fields', () => {
expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
expect(full.system).toBe('s')
expect(full.tools).toHaveLength(1)
it('normalizes empty optional fields to absence and preserves populated fields', () => {
expect(canonicalHeader({ config: CONFIG, system: '', tools: [], messagePrefix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
})
})
describe('diffHeader / applyHeaderDelta', () => {
it('returns undefined for equal headers', () => {
const header = canonicalHeader({ config: CONFIG, system: 'a\nb', tools: [tool('t')] })
expect(diffHeader(header, header)).toBeUndefined()
describe('headerEquals', () => {
const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
it('compares every canonical field and preserves tool order', () => {
expect(headerEquals(base, structuredClone(base))).toBe(true)
expect(headerEquals(base, { ...base, config: { model: 'other' } })).toBe(false)
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false)
expect(headerEquals({ config: CONFIG, tools: [tool('a'), tool('b')] }, { config: CONFIG, tools: [tool('b'), tool('a')] })).toBe(false)
})
it('encodes a mid-prompt line change as a prefix/suffix trim', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'keep1\nold\nkeep2\nkeep3' })
const next = canonicalHeader({ config: CONFIG, system: 'keep1\nnew A\nnew B\nkeep2\nkeep3' })
const delta = roundTrip(prev, next)
expect(delta?.system).toEqual({ keepStart: 1, keepEnd: 2, insert: ['new A', 'new B'] })
expect(delta?.tools).toBeUndefined()
expect(delta?.config).toBeUndefined()
})
it('degenerates to a full replacement when nothing is shared, and round-trips absence transitions', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, system: 'x\ny' })
const gained = roundTrip(none, some)
expect(gained?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: ['x', 'y'] })
const lost = roundTrip(some, none)
expect(lost?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: [] })
})
it('does not double-count overlapping prefix and suffix (repeated lines)', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'a\na' })
const next = canonicalHeader({ config: CONFIG, system: 'a\na\na' })
roundTrip(prev, next)
})
it('encodes tool addition, removal, and in-place schema change by name', () => {
const prev = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('drop'), tool('edit', 'before')] })
const next = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('edit', 'after'), tool('new')] })
const delta = roundTrip(prev, next)
expect(delta?.tools?.added.map(t => t.name)).toEqual(['new'])
expect(delta?.tools?.removed).toEqual(['drop'])
expect(delta?.tools?.changed.map(t => t.name)).toEqual(['edit'])
})
it('round-trips a tool set gained from a tool-less header and lost back to one', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, tools: [tool('t')] })
const gained = roundTrip(none, some)
expect(gained?.tools?.added.map(t => t.name)).toEqual(['t'])
const lost = roundTrip(some, none)
expect(lost?.tools?.removed).toEqual(['t'])
})
it('cannot express a pure reordering — the writer detects it via the round-trip check', () => {
const prev = canonicalHeader({ config: CONFIG, tools: [tool('a'), tool('b')] })
const next = canonicalHeader({ config: CONFIG, tools: [tool('b'), tool('a')] })
const delta = diffHeader(prev, next)
// A delta IS produced (the lists differ)…
expect(delta).toBeDefined()
// …but applying it cannot reproduce the new order — exactly the case the
// writer's guard turns into a 'fallback' snapshot.
expect(applyHeaderDelta(prev, delta!)).not.toEqual(next)
})
it('replaces the config whole and leaves untouched parts alone', () => {
const prev = canonicalHeader({ config: { model: 'm' }, system: 's', tools: [tool('t')] })
const next = canonicalHeader({ config: { model: 'm2', temperature: 0.1 }, system: 's', tools: [tool('t')] })
const delta = roundTrip(prev, next)
expect(delta).toEqual({ config: { model: 'm2', temperature: 0.1 } })
})
})
describe('the session prefix (messagePrefix)', () => {
it('canonicalHeader normalizes an empty prefix to an absent field', () => {
expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
expect(full.messagePrefix).toEqual([msg('p')])
})
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
})
it('replaces a changed prefix whole and leaves untouched parts alone', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
const delta = roundTrip(prev, next)
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
})
it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
const gained = roundTrip(none, some)
expect(gained).toEqual({ messagePrefix: [msg('p')] })
const lost = roundTrip(some, none)
expect(lost).toEqual({ messagePrefix: [] })
})
it('folds prefix deltas over the log like any other header amendment', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
session.append('request/header', { header: first, reason: 'initial' })
const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
session.append('request/header-delta', diffHeader(first, second)!)
expect(foldRequestHeader(session.events)).toEqual(second)
session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
it('treats absent and empty prefix/tool arrays as equivalent canonical absence', () => {
expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [], messagePrefix: [] })).toBe(true)
})
})
describe('foldRequestHeader', () => {
function headerEvents(session: Session): readonly SessionEvent[] {
return session.events
}
it('returns undefined on a log with no header events', () => {
const session = new Session(SessionId('fold-none'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(foldRequestHeader(headerEvents(session))).toBeUndefined()
it('returns the supplied baseline when no snapshot follows', () => {
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
const unrelated: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
]
expect(foldRequestHeader(unrelated)).toBeUndefined()
expect(foldRequestHeader(unrelated, from)).toBe(from)
})
it('folds snapshot then deltas into the header in force, skipping unrelated events', () => {
it('takes the latest full snapshot and skips unrelated events', () => {
const session = new Session(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] })
session.append('request/header', { header: first, reason: 'initial' })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const second = canonicalHeader({ config: { model: 'm' }, system: 'a\nc', tools: [tool('t')] })
session.append('request/header-delta', diffHeader(first, second)!)
expect(foldRequestHeader(headerEvents(session))).toEqual(second)
// A later snapshot replaces the state wholesale (the 'resume'/'fallback' anchor).
const third = canonicalHeader({ config: { model: 'other' } })
session.append('request/header', { header: third, reason: 'resume' })
expect(foldRequestHeader(headerEvents(session))).toEqual(third)
})
it('throws on a delta before any snapshot (corrupt log)', () => {
const session = new Session(SessionId('fold-corrupt'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('request/header-delta', { config: { model: 'x' } })
expect(() => foldRequestHeader(headerEvents(session))).toThrow(/before any request\/header snapshot/)
session.append('request/header', { header: { config: { model: 'other' }, tools: [] }, reason: 'change' })
expect(foldRequestHeader(session.events)).toEqual({ config: { model: 'other' } })
})
})
describe('legacy request-header format', () => {
it('rejects a v0 seed containing request/header-delta', () => {
const legacy = [{
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
})
})

View File

@@ -1171,8 +1171,8 @@ describe('todo/write event', () => {
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
// The todo event must not add a message to the derived history…
expect(session.deriveMessages()).toHaveLength(before)
// …and must not appear on the surface linked list.
expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false)
// …and must not appear on the ordered surface.
expect(session.surface.nodes).not.toContain(session.seq - 1)
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {

View File

@@ -14,18 +14,12 @@ function surfaceSession(): Session {
}
describe('SurfaceManager', () => {
it('rebuilds a linked list from surfaceOp: append markers', () => {
it('folds an ordered sequence list from surfaceOp: append markers', () => {
const s = surfaceSession()
const nodes = s.surface.nodes
// Only the user/message and assistant/message carry surfaceOp: 'append'.
// The turn boundaries do not have surface markers.
expect(nodes.length).toBe(2)
expect(nodes[0]!.seq).toBe(1) // user/message (turn/start is seq 0)
expect(nodes[0]!.prev).toBeNull()
expect(nodes[0]!.next).toBe(2) // assistant/message (seq 2)
expect(nodes[1]!.seq).toBe(2)
expect(nodes[1]!.prev).toBe(1)
expect(nodes[1]!.next).toBeNull()
expect(nodes).toEqual([1, 2])
})
it('empty surface yields empty nodes', () => {
@@ -46,9 +40,7 @@ describe('SurfaceManager', () => {
// Append another surface node
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
expect(s.surface.nodes.length).toBe(3)
expect(s.surface.nodes[2]!.seq).toBe(4) // seq 4: after turn/end at seq 3
expect(s.surface.nodes[2]!.prev).toBe(2)
expect(s.surface.nodes[1]!.next).toBe(4)
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
})
it('replays identically from a seeded log with surface markers', () => {
@@ -56,7 +48,7 @@ describe('SurfaceManager', () => {
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events])
// Surface rebuilds from the seeded log's markers.
expect(replayed.surface.nodes.map(n => n.seq)).toEqual([1, 2, 4])
expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
})
@@ -70,10 +62,7 @@ describe('SurfaceManager', () => {
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
)
// Now the surface should have just the compaction node.
expect(s.surface.nodes.length).toBe(1)
expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker
expect(s.surface.nodes[0]!.prev).toBeNull()
expect(s.surface.nodes[0]!.next).toBeNull()
expect(s.surface.nodes).toEqual([4])
})
it('replace with both ends at real nodes splices only the range', () => {
@@ -86,12 +75,7 @@ describe('SurfaceManager', () => {
{ turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
{ surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
) // seq 3
expect(s.surface.nodes.map(n => n.seq)).toEqual([3, 2])
// Links: 3 ↔ 2
expect(s.surface.nodes[0]!.prev).toBeNull()
expect(s.surface.nodes[0]!.next).toBe(2)
expect(s.surface.nodes[1]!.prev).toBe(3)
expect(s.surface.nodes[1]!.next).toBeNull()
expect(s.surface.nodes).toEqual([3, 2])
})
it('single-node replacement (start === end)', () => {
@@ -103,9 +87,7 @@ describe('SurfaceManager', () => {
{ turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
) // seq 2
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 2])
expect(s.surface.nodes[0]!.next).toBe(2)
expect(s.surface.nodes[1]!.prev).toBe(0)
expect(s.surface.nodes).toEqual([0, 2])
})
it('throws when replace start is not found', () => {
@@ -151,7 +133,7 @@ describe('SurfaceManager', () => {
expect(logged.sourceEventSeqs).toEqual([10, 20])
})
it('replace starting at non-head position links to previous node correctly', () => {
it('replace starting at non-head position preserves surrounding order', () => {
const s = new Session(SessionId('mid-replace'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
@@ -161,14 +143,7 @@ describe('SurfaceManager', () => {
{ turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
) // seq 3
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 3, 2])
// Links: 0 → 3 → 2
expect(s.surface.nodes[0]!.prev).toBeNull()
expect(s.surface.nodes[0]!.next).toBe(3)
expect(s.surface.nodes[1]!.prev).toBe(0)
expect(s.surface.nodes[1]!.next).toBe(2)
expect(s.surface.nodes[2]!.prev).toBe(3)
expect(s.surface.nodes[2]!.next).toBeNull()
expect(s.surface.nodes).toEqual([0, 3, 2])
})
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
@@ -340,7 +315,7 @@ describe('SurfaceManager.replaceGeneration', () => {
const nodes = s.surface.nodes
s.append('context/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)
})
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
import type { SessionEvent } from '../src/index.ts'
/**
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
@@ -12,8 +12,8 @@ import type { SessionEvent, SurfaceNode } from '../src/index.ts'
* no step (pre-step user message, inter-step steering, injection context) are
* pairing-neutral, so their cuts are free boundaries.
*
* The fixtures are built through a real {@link Session} so the surface linked
* list is derived exactly as production does — including the non-monotonic
* The fixtures are built through a real {@link Session} so the ordered surface
* sequence list is derived exactly as production does — including the non-monotonic
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
* sitting at the surface head), which is the case the abandoned log-position
* scan mis-classified.
@@ -27,7 +27,7 @@ import type { SessionEvent, SurfaceNode } from '../src/index.ts'
const SURFACE = { surfaceOp: 'append' as const }
/** Surface nodes + log for a session, the two args the balance check takes. */
function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
function surfaceOf(session: Session): { nodes: readonly number[]; events: readonly SessionEvent[] } {
return { nodes: session.surface.nodes, events: session.events }
}
@@ -40,9 +40,9 @@ function startBalanced(session: Session, seq: number): boolean {
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
function endBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
const node = nodes.find(n => n.seq === seq)
if (!node) throw new Error(`seq ${seq} is not a surface node`)
return isToolPairingBalanced(nodes, events, node.next)
const index = nodes.indexOf(seq)
if (index === -1) throw new Error(`seq ${seq} is not a surface node`)
return isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null)
}
/** Surface seq of the nth (0-based) event of a given type. */
@@ -274,20 +274,20 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
const s = checkpointHeadedSession()
const nodes = s.surface.nodes
const checkpointSeq = nodes[0]!.seq
const checkpointSeq = nodes[0]!
// The checkpoint heads the surface, yet a surface node (the open step's
// assistant) follows it in LOG order — the exact split between surface
// position and log position that the log-position scan tripped on.
const laterSurfaceInLog = s.events.find(
e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
e => e.seq > checkpointSeq && nodes.includes(e.seq),
)
expect(laterSurfaceInLog).toBeDefined()
expect(nodes[0]!.seq).toBe(checkpointSeq)
expect(nodes[0]!).toBe(checkpointSeq)
})
it('start cut before the head checkpoint is balanced (it is the head)', () => {
const s = checkpointHeadedSession()
expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
expect(startBalanced(s, s.surface.nodes[0]!)).toBe(true)
})
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
@@ -296,7 +296,7 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
// wrongly reported mid-step. The surface balance sees a neutral node whose
// following cut closes no open call.
const s = checkpointHeadedSession()
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
expect(endBalanced(s, s.surface.nodes[0]!)).toBe(true)
})
})