Merge remote-tracking branch 'origin/master' into session-surface

Reconcile the session-surface feature with master's package reorg and
simplifications:

- Adopt master's folded usage (assistant/message.usage; standalone `usage`
  event dropped) and re-attach surface metadata (surfaceOp/sourceEventSeqs).
- Add surface opts to master's new max-tokens assistant/message append.
- Port surface columns onto the coordinator-refactored SQLite backend at its
  new path; drop the dead v1->v2 migration (bump-and-reject, no migration per
  pre-release policy).
- Move the session-surface RFC into implemented/architecture/ and refresh its
  stale body (no migration, SESSION_FORMAT_VERSION=0, renamed package paths).
- Update the core-data-structures catalog SessionEvent blocks for the two new
  surface fields; regenerate the cordis catalog.
- Re-harvest ACP snapshot fixtures (keyless replay) to carry surface metadata.
This commit is contained in:
Hypatia May
2026-06-22 10:35:59 +08:00
388 changed files with 22901 additions and 6944 deletions

View File

@@ -0,0 +1,74 @@
# 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.
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`.
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
#### Advanced: ordered-teardown lifecycle primitives
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check.
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload.
### Events
| Event | Mode | Purpose |
|---|---|---|
| `session/created` | emit | A session was created |
| `session/event` | emit | An event was appended (sync, fire-and-forget) |
| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). An optional third parameter `opts: SurfaceAppendOpts` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from).
- `session.deriveMessages(): Message[]` — derive the LLM message history. If any event in the log carries `surfaceOp`, derivation walks the surface linked list (skipping non-surface events). Otherwise, falls back to a linear scan of the raw log (legacy sessions without surface markers).
- `session.surface: SurfaceManager` — the derived surface, 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.
- `session.events`, `session.seq`, `session.id`
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
### 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.
- `SurfaceAppendOpts` — `{ surfaceOp?: SurfaceOp; sourceEventSeqs?: number[] }`, the optional third parameter to `session.append()`.
- `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
### Session event vocabulary (`types.ts`)
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
Every `SessionEvent` carries two optional top-level fields (structural metadata):
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction marker).
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
### Metadata types (`types.ts`)
- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events.
- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes.
### What is NOT here (TODO)
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking.

View File

@@ -0,0 +1,32 @@
{
"name": "@deepseek-ai/dsh-session",
"description": "Event-sourced session store for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,414 @@
/**
* Event-sourced session service: append-only session log, in-memory store, and
* the derived LLM message history. Persistence is a plugin concern (subscribe
* to `session/event`, drain on `session/flush`).
*
* @module @deepseek-ai/dsh-session
*/
import { Context, Service } from 'cordis'
import { isAbsolute } from 'node:path'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceAppendOpts, SurfaceEventType } from './types.ts'
import { isJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
export * from './types.ts'
export { isJsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent } from './surface.ts'
declare module 'cordis' {
interface Context {
sessions: SessionStore
}
interface Events {
/**
* A session was created in the store.
* @mode emit
*/
'session/created'(session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
* @mode emit
*/
'session/event'(session: Session, event: SessionEvent): void
/**
* Awaited durability checkpoint. The agent loop awaits
* `ctx.parallel('session/flush', session)` at every turn end; persistence
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
* and the loop waits for all of them, but none can veto.
* @mode parallel
*/
'session/flush'(session: Session): Promise<void> | void
}
}
/**
* Renders a `context/message` or `steering/message` event as a tagged
* synthetic user-role message (the system-reminder pattern: zero adapter
* burden, models distinguish it from real user prompts by the envelope).
*
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
*/
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
const close = `</${tag}>`
return [
{ type: 'text', text: open },
...content,
{ type: 'text', text: close },
]
}
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
* Seeding with an existing event log replays/forks a session.
*/
export class Session {
private log: SessionEvent[] = []
/** Set by the store so appends are observable; undefined when detached. */
onAppend: ((event: SessionEvent) => void) | undefined
/**
* Derived surface — a cached linked list of message-producing events.
* 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.
* `append`. Undefined until first accessed (including after fork/seed).
*/
private _surface: SurfaceManager | undefined
/** The surface linked list over this session's event log. */
get surface(): SurfaceManager {
if (!this._surface) this._surface = new SurfaceManager(this.log)
return this._surface
}
/**
* Immutable creation metadata (format version, cwd, lineage). Supplied by
* the store via `ctx.sessions.create()`. When a `Session` is constructed
* bare (tests, ad-hoc replay), a minimal header is synthesized (stamped with
* the current {@link SESSION_FORMAT_VERSION}) so `session.header` is always
* present. Kept out of the event log — it is a storage concern, not
* replayable conversation state.
*/
readonly header: SessionHeader
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
if (seed) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
// must be JSON-serializable, and `seq` must be contiguous from 0 (the
// `seq = log.length` contract the whole system relies on). Without this,
// a bad seed would surface only later as a backend rejection or a silent
// divergence between the live log and disk.
seed.forEach((event, index) => {
if (event.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
}
if (!isJsonValue(event.data)) {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
}
})
// Deep-clone each seed event, NOT just the array: the seed events and
// their `data` are still owned by the caller (or the source session of a
// fork), so keeping the references would let a post-create mutation of the
// original rewrite this session's durable log — or reintroduce a
// non-JSON-serializable value AFTER the validation above. Snapshotting at
// the boundary makes `session.events` independent and keeps it equal to
// what was validated. Serializability is guaranteed by the check above, so
// structuredClone can never hit a non-cloneable value here.
this.log = seed.map(event => structuredClone(event))
}
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
}
get events(): readonly SessionEvent[] {
return this.log
}
get seq(): number {
return this.log.length
}
/**
* Append one typed event to the log and synchronously notify observers via
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
* asynchronously.
*
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
* @param opts - Optional surface metadata: `surfaceOp` controls how the
* event enters the surface linked list; `sourceEventSeqs` records
* provenance (the seq numbers of events this one derives from). Only
* accepted for {@link SurfaceEventType} events — the compiler rejects
* surface opts for non-surface types like `turn/start` or `assistant/chunk`.
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
* symbol, undefined, non-finite number, circular ref, or an exotic object
* like Map/Set/Date). The event log is the durable source of truth, so this
* invariant is enforced at the source — a bad event never enters the log,
* keeping `session.events` always equal to what a backend can persist. The
* throw surfaces at the buggy caller's append site, not asynchronously in a
* backend flush.
*/
append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts?: SurfaceAppendOpts] : []
): SessionEvent<T> {
if (!isJsonValue(data)) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
// Snapshot `data` into the log, NOT the caller's reference: the validation
// above proves it is JSON-serializable AT THIS MOMENT, but the caller still
// owns the object and could mutate it afterwards (before a persistence
// flush, or permanently in the in-memory history) — making `session.events`
// diverge from the value that passed validation, or reintroducing a
// non-serializable value. Cloning here keeps the log equal to what was
// validated. structuredClone is safe because serializability was just
// checked. The returned event carries the SAME snapshot, so a caller reading
// back `event.data` sees the logged value, not its own mutable input.
//
// Surface metadata is snapshot separately: sourceEventSeqs (number[] —
// primitives, so array spread is a complete copy) and surfaceOp (a string
// primitive, or cloned if it's a replace object).
const surfaceOpts: SurfaceAppendOpts | undefined = opts[0]
// Build the event shape with conditional surface fields via spreading.
// The result is cast through `unknown` because the conditional spreads
// produce an intersection type that the assignability checker can't
// narrow to a specific discriminated-union member when T is generic.
// This is a safe internal boundary: data was validated above, and
// surface metadata was snapshot from primitive/clone-safe values.
const event = {
type,
seq: this.log.length,
time: Date.now(),
data: structuredClone(data),
...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {},
...surfaceOpts?.surfaceOp !== undefined ? {
surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
} : {},
} as unknown as SessionEvent<T>
this.log.push(event as unknown as SessionEvent)
this.onAppend?.(event as unknown as SessionEvent)
return event
}
/**
* Derive the LLM message history from the session surface (when surface
* markers exist) or from a linear scan of the raw event log (legacy sessions
* without surface markers).
*
* - `user/message` → user message
* - `assistant/message` → assistant message (chunks are skipped — they are
* replay/UI data; the assembled message is authoritative for history). An
* EMPTY-content assistant/message is skipped: a max-tokens step cut off with
* no content still records an assistant/message to host its `usage`, but a
* content-less assistant turn must not enter the provider transcript.
* - `tool/result` → user message carrying a tool-result block
* - `context/message` / `steering/message` → tagged synthetic user messages
* at their chronological position
*
* The returned `content` is **deep-cloned** off the logged events: the loop
* hands these messages into the mutable `agent/request` waterfall and on to
* adapters, where mutating the request is sanctioned — but the session log
* is append-only by contract. Cloning at this boundary keeps in-flight
* mutation from reaching back and rewriting history (which would silently
* break replay equivalence). Cost is one structured clone per step,
* negligible next to a model call.
*/
deriveMessages(): Message[] {
if (this.surface.hasSurface) {
const messages: Message[] = []
for (const node of this.surface.nodes) {
// Surface nodes are built from this.log — node.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._deriveOneMessage(this.log[node.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.
if (msg) messages.push(msg)
}
return messages
}
// Legacy path: linear scan for sessions without surface markers.
const messages: Message[] = []
for (const event of this.log) {
const msg = this._deriveOneMessage(event)
if (msg) messages.push(msg)
}
return messages
}
/**
* Derive a single LLM message from one event, or null if the event type
* does not produce a message. Extracted so both the surface path and the
* legacy linear-scan path share the same derivation rules.
*/
private _deriveOneMessage(event: SessionEvent): Message | null {
// Intentionally non-exhaustive: only message-producing events derive
// history; turn/step boundaries, chunks, usage, and errors are
// trace/replay data.
switch (event.type) {
case 'user/message': {
return { role: 'user', content: structuredClone(event.data.content) }
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.content.length === 0) return null
return { role: 'assistant', content: structuredClone(event.data.content) }
}
case 'tool/result': {
const { callId, content, isError } = event.data
return {
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
}
}
case 'context/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
}
case 'steering/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
}
default:
return null
}
}
}
/**
* In-memory session store (`ctx.sessions`).
*
* Persistence is intentionally not implemented here — persistence plugins
* subscribe to `session/event` and flush on `session/flush` / dispose.
*/
export class SessionStore extends Service {
private store = new Map<SessionId, Session>()
private counter = 0
constructor(ctx: Context) {
super(ctx, 'sessions')
}
/**
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
* populates the session with a copy of those events (replay/fork);
* `options.meta` attaches creation metadata (validated absolute `cwd`,
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
* fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before `onAppend` detaches), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
* `startOwned`).
*
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path (storage backends key directories off it).
*/
create(id?: SessionId, options?: CreateSessionOptions): Session {
const session = this.prepare(id, options)
// Single effect owned by the calling fiber. Yield the detach BEFORE
// announcing so a throwing `session/created` listener rolls the attach back
// (the generator effect disposes already-yielded disposers on a throw)
// instead of leaking the store entry + onAppend.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
}.bind(this), 'sessions.create()')
return session
}
/**
* Build a session WITHOUT entering it into the store — validate the id/cwd and
* construct the {@link Session} (with its immutable {@link SessionHeader}).
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would detach `onAppend`
* before the loop's closing `session/flush`, dropping the closing events.
*
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path.
*/
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
const sessionId = SessionId(id ?? `session-${++this.counter}`)
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const cwd = options?.meta?.cwd
if (cwd !== undefined && !isAbsolute(cwd)) {
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
}
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: options?.meta?.createdAt ?? Date.now(),
...cwd !== undefined ? { cwd } : {},
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
}
return new Session(sessionId, options?.seed, header)
}
/**
* Enter a {@link prepare}d session into the store: wire `onAppend` →
* `session/event` and add it to the store. Returns the DETACH disposer
* (`onAppend = undefined` + store removal). Does NOT emit `session/created` —
* the caller yields this disposer inside its effect and THEN calls
* {@link announce}, so a throwing `session/created` listener rolls the attach
* back instead of leaking it.
*
* Re-checks the id for a duplicate: `prepare` and `enter` are public
* cross-package primitives and a caller may interleave arbitrary work (or
* another create) between them, so a stale prepared session must NOT overwrite
* a live store entry of the same id — its detach disposer would later delete
* the REAL session. The {@link create} convenience and the agent factory call
* the two back-to-back so they never trip this, but the public seam cannot
* assume that.
*
* @throws if a session with this id is already in the store.
*/
enter(session: Session): () => void {
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
this.store.set(session.id, session)
return () => {
session.onAppend = undefined
this.store.delete(session.id)
}
}
/** Emit `session/created` for an {@link enter}ed session. Separate from
* {@link enter} so the caller can yield the detach disposer first (rollback
* safety — see {@link enter}). */
announce(session: Session): void {
this.ctx.emit('session/created', session)
}
get(id: SessionId): Session | undefined {
return this.store.get(id)
}
list(): Session[] {
return [...this.store.values()]
}
}
export default SessionStore

View File

@@ -0,0 +1,71 @@
/**
* JSON-serializability validation for session event data.
*
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
* `event.data` must round-trip losslessly through JSON so any persistence
* backend can store and reload it byte-identically. This invariant belongs to
* the log itself — `Session.append` enforces it at the source, so a
* non-serializable event never enters `session.events` and the live log can
* never diverge from what a backend can persist. Backends re-use the same
* predicate to validate their own `append(events)` entry point (replay/fork
* paths that do not go through a live `Session`).
*
* @module @deepseek-ai/dsh-session/json
*/
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
* booleans, strings, plain arrays, and plain objects of such values. Rejects
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
* which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
* `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
* convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
* so `[1, , 3]` would not round-trip. Detects circular references (which would
* throw) and reports them as non-serializable rather than propagating the throw.
*
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise
* drops them — they never reach the durable form, so a non-serializable value
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
* Getters are invoked during the check (again as `JSON.stringify` would), so the
* contract is for plain data records, not objects with side-effecting accessors.
*/
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
if (value === null) return true
switch (typeof value) {
case 'boolean':
case 'string':
return true
case 'number':
return Number.isFinite(value)
case 'bigint':
case 'function':
case 'symbol':
case 'undefined':
return false
case 'object':
break // handled below
}
// object
if (seen.has(value)) return false // circular
seen.add(value)
try {
if (Array.isArray(value)) {
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
// lossily. Require every index 0..length-1 to be an OWN property.
for (let i = 0; i < value.length; i++) {
if (!Object.prototype.hasOwnProperty.call(value, i)) return false
if (!isJsonValue(value[i], seen)) return false
}
return true
}
// Plain object only (reject Map/Set/Date/class instances).
const proto = Object.getPrototypeOf(value) as unknown
if (proto !== Object.prototype && proto !== null) return false
return Object.values(value).every(v => isJsonValue(v, seen))
} finally {
seen.delete(value)
}
}

View File

@@ -0,0 +1,159 @@
/**
* Crash-recovery repair for an interrupted session log.
*
* A persistence backend flushes only at `turn/end`, so a crash can leave a
* durable log whose final turn never closed: real, fully-written events sit
* after the last `turn/end` with no closing boundary. A single turn can be huge
* in a long-horizon task (many steps, large tool output), so those events MUST
* be preserved — truncating the turn would silently destroy real work. Instead,
* on reload the backend CLOSES the orphaned turn by appending the minimal
* synthetic boundary events:
*
* 1. an error `tool/result` for every `tool-call` in the interrupted turn that
* never got its matching `tool/result` (so the rehydrated history is a
* VALID provider transcript — see below),
* 2. a `step/end` if a step was still open, then
* 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason.
*
* The marker records that the turn was cut short by a crash, not completed by
* the model. See the session-persistence RFC.
*
* Why the synthetic tool results matter: `deriveMessages()` renders the
* `tool-call` blocks inside a durable `assistant/message` but only emits a
* matching tool-result when a `tool/result` EVENT exists. A crash between the
* assistant message and its tool results (the loop runs the tools AFTER logging
* the assistant message, so a process killed mid-tool leaves the calls without
* results) would otherwise reload a history with a dangling assistant tool-call
* — which every provider rejects as an invalid transcript on the next request.
* Synthesizing an error result per orphaned call keeps resume safe.
*
* This module computes those synthetic closers from an event list; backends
* return them inline from `load` (so the reconstructed session is balanced and
* immediately usable) and persist them during that mutating load before any
* later append continues the log.
*
* @module @deepseek-ai/dsh-session/repair
*/
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* Scan `events` for an open turn/step at the tail and return the synthetic
* boundary events that close them, with `seq` continuing the log and `time`
* copied from the last real event (the closers stand in for the crash moment;
* reusing the last timestamp keeps them deterministic and never invents a
* "future" time). Returns an empty array when the log is already balanced
* (ends on a `turn/end`, or is empty) — the common, non-crash case.
*
* The closers, in order: an error `tool/result` for each unmatched `tool-call`
* in the interrupted turn, then a `step/end` if a step is open, then the
* `turn/end {interrupted}`. The tool-results come first so a step that issued
* tool calls is balanced (every call has a result) before its `step/end`.
*
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
* before any later `turn/start`, so an interior open turn is impossible in a
* valid committed log. Likewise at most one step is open within that turn.
*/
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
// Track tool calls vs. their results WITHIN the currently-open turn only: a
// call is "pending" until its matching tool/result arrives. Reset at every
// turn boundary so a committed earlier turn (already balanced) never leaks a
// phantom pending call into the interrupted-turn repair.
// Track pending tool calls with their callSeq (the seq of the `tool/call`
// event, captured for surface sourceEventSeqs provenance on the synthetic
// result). CallSeq is set from `tool/call` events; the assistant/message
// block scan may register a call first (it appears earlier in the log), and
// the later `tool/call` event fills in the seq.
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
for (const event of events) {
switch (event.type) {
case 'turn/start':
openTurn = event.data.turn
openStep = null
pendingCalls.clear()
break
case 'turn/end':
openTurn = null
openStep = null
pendingCalls.clear()
break
case 'step/start':
openStep = event.data.step
break
case 'step/end':
pendingCalls.clear()
openStep = null
break
case 'assistant/message':
// The assistant message carries the tool-call blocks; each is pending
// until a tool/result event with the same callId is logged.
for (const block of event.data.content) {
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
}
break
case 'tool/call':
// Capture the tool/call event seq for surface provenance on the
// synthesized tool/result. The entry may already exist (registered by
// the assistant/message above) or may be new (if the assistant/message
// came from a prior step that was already closed).
{
const entry = pendingCalls.get(event.data.callId)
if (entry) {
entry.callSeq = event.seq
}
}
break
case 'tool/result':
pendingCalls.delete(event.data.callId)
break
// Other event types do not move the turn/step boundary cursor.
default:
break
}
}
// Balanced log (no crash mid-turn): nothing to close. An open turn implies
// `events` is non-empty (its turn/start was logged), so `last` exists.
const last = events.at(-1)
if (openTurn === null || last === undefined) return []
// The last real event supplies the seq base and the timestamp for the
// synthetic closers (reusing the last timestamp keeps them deterministic and
// never invents a "future" time).
let seq = last.seq + 1
const time = last.time
const closers: SessionEvent[] = []
// Synthesize an error tool/result for each tool-call left unanswered by the
// crash, so deriveMessages() yields a valid provider transcript on resume (a
// dangling assistant tool-call is rejected by every provider). Insertion
// order follows the Map (insertion = log order of the assistant messages).
for (const [callId, { step, callSeq }] of pendingCalls) {
closers.push({
type: 'tool/result',
seq: seq++,
time,
data: {
turn: openTurn,
step,
callId,
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
},
surfaceOp: 'append',
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},
})
}
// Close an open step next — a turn/end while a step is open is an invariant
// violation, so the step's boundary must be synthesized before the turn's.
if (openStep !== null) {
closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } })
}
closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
return closers
}

View File

@@ -0,0 +1,161 @@
/**
* Surface layer on top of the session event log: a derived, cached linked list
* of events that produce LLM messages. Rebuilt deterministically from
* `surfaceOp` markers in the log — the log is the source of truth; the surface
* is a view.
*
* @module @deepseek-ai/dsh-session/surface
*/
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
/**
* The set of event type strings that are eligible for the surface linked list.
* Mirrors the {@link SurfaceEventType} union; kept as a runtime set so the
* type guard can check membership without a chain of string comparisons.
*/
const SURFACE_EVENT_TYPES = new Set<string>([
'user/message',
'assistant/message',
'tool/result',
'context/message',
'steering/message',
])
/**
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
* event's `type` is surface-eligible AND that `surfaceOp` is present.
* The narrowed type has mandatory {@link SurfaceOp}.
*/
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
if (!SURFACE_EVENT_TYPES.has(event.type)) return false
// surfaceOp is optional on SessionEvent (even for surface-eligible types)
// but mandatory on SurfaceEvent — this check is the narrowing gate.
if ((event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) return false
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
* `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 for O(1) lookup during replacements. */
private _nodeBySeq = new Map<number, SurfaceNode>()
/** The last processed seq. -1 forces a full rebuild on first access. */
private _lastProcessedSeq = -1
constructor(private log: readonly SessionEvent[]) {}
/**
* Reset to unprocessed state. Call after the log has been replaced
* wholesale (e.g. after Session seed). Not needed for normal appends —
* those are picked up incrementally.
*/
invalidate(): void {
this._lastProcessedSeq = -1
this._nodes = []
this._nodeBySeq.clear()
}
/** The surface nodes in linked-list order (head to tail). */
get nodes(): readonly SurfaceNode[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
return this._nodes
}
/** Whether any event in the log carries `surfaceOp` markers. */
get hasSurface(): boolean {
if (this._nodes.length > 0) return true
// Never processed anything — scan the whole log.
if (this._lastProcessedSeq === -1) return this.log.some(e => isSurfaceEvent(e))
// Processed up to _lastProcessedSeq without finding surface nodes; check
// only new events.
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isSurfaceEvent(this.log[i]!)) return true
}
return false
}
/**
* Process events from `_lastProcessedSeq + 1` through the end of the log,
* folding new surface markers into the existing linked list.
*/
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// Index is bounded by i < this.log.length — never undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = this.log[i]!
// isSurfaceEvent checks event.type first (is it a surface-eligible type?)
// then checks that surfaceOp is present. Only after both pass do we treat
// it as a SurfaceEvent with mandatory surfaceOp.
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)
} else {
this._replace(this._nodes, this._nodeBySeq, event.seq, event.surfaceOp)
}
}
this._lastProcessedSeq = this.log.length - 1
}
/** Apply a replace operation to the in-progress surface. */
private _replace(
nodes: SurfaceNode[],
nodeBySeq: Map<number, SurfaceNode>,
newSeq: number,
op: Extract<SurfaceOp, { op: 'replace' }>,
): void {
const startIdx = nodes.findIndex(n => n.seq === op.start)
if (startIdx === -1) {
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
}
const endIdx = nodes.findIndex(n => n.seq === op.end)
if (endIdx === -1) {
throw new Error(`surface replace: end seq ${op.end} not found in surface`)
}
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 = nodes.splice(startIdx, count)
for (const r of removed) nodeBySeq.delete(r.seq)
// Insert the new node where the removed range was.
const prevNode = startIdx > 0 ? nodes[startIdx - 1] : undefined
const nextNode = startIdx < nodes.length ? 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
nodes.splice(startIdx, 0, newNode)
nodeBySeq.set(newSeq, newNode)
}
}

View File

@@ -0,0 +1,270 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>
/** Brand a string as a {@link SessionId}. */
export function SessionId(id: string): SessionId {
return id as SessionId
}
/**
* The on-disk session format version, stamped into every newly-written
* {@link SessionHeader} and enforced by every persistence backend on load. The
* single source of truth for the version — write sites and the load-time check
* all read it.
*
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
* removing a variant, …) happen freely and do NOT bump this — v0 absorbs all
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
* migration; no persisted user data exists to preserve). A real, monotonically
* bumped version policy begins at the first tagged release, when a specific
* format boundary becomes worth distinguishing.
*/
export const SESSION_FORMAT_VERSION = 0
/**
* Immutable session metadata — written once at creation and never rewritten.
*
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
* lineage are storage concerns, not conversation events, so they stay out of
* {@link SessionEventMap} and never reach `deriveMessages()`. Every reference
* system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail
* metadata) writes such a header.
*/
export interface SessionHeader {
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
* session is created. A persistence backend rejects any other version on load
* (no migration — see the constant).
*/
version: number
/** The session's id (mirrors the {@link Session}'s id). */
id: SessionId
/** Unix epoch milliseconds when the session was created. */
createdAt: number
/** Absolute working directory the session was created in (if any). */
cwd?: string
/** The session this one was forked from (seed lineage), if any. */
parentSession?: SessionId
}
/**
* Options for creating a {@link Session} via the store. `seed` replays/forks
* an existing event log; `meta` carries the caller-supplied storage fields the
* store folds into a {@link SessionHeader}.
*/
export interface CreateSessionOptions {
/** Events to seed the new session with (replay/fork). */
seed?: SessionEvent[]
/**
* Creation metadata. The store fills in `version`/`id` and defaults
* `createdAt` to now; the caller supplies the storage-level fields (validated
* absolute `cwd`, `parentSession` lineage, and — when reconstructing a
* persisted session — the original `createdAt` to preserve it).
*/
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number }
}
/**
* What started a turn.
* Merge-extensible sum type (same pattern as MessageSourceMap).
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
continuation: { kind: 'continuation' }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
* was idle. The loop wraps the injected `context/message` in a one-shot turn
* (`turn/start` → `context/message` → `turn/end`) so every event in the log
* stays turn-enclosed — the durability/replay boundary is the turn, and a
* bare event between turns would otherwise be indistinguishable from a crash
* tail on reload.
*/
injection: { kind: 'injection'; source: MessageSource }
}
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/**
* Why a turn ended.
* Merge-extensible sum type.
*
* `max-tokens` mirrors the model-call `FinishReasonMap` variant (DeepSeek's
* `length`): the turn ended because a step hit the output-token ceiling, not
* because the model chose to stop. The agent-loop surfaces it via the rule
* "any `max-tokens` step in the turn makes the turn end `max-tokens`" (a
* continuation plugin can run further steps after one, but the cut-short fact
* still wins). It is distinct from `completed` so a consumer (e.g. the ACP
* bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a
* truncated one. The next variants to add — when an adapter/loop first emits
* them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP
* stop reasons); no current adapter produces a `refusal` finish (unknown
* DeepSeek finish reasons collapse to `error`), so it is deliberately omitted
* until one does.
*/
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason?: string }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). `code` is the error's code when one was attached.
*/
error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' }
'max-tokens': { kind: 'max-tokens' }
/**
* The turn never ended on its own: the process crashed mid-turn and a
* persistence backend later closed the orphaned (open) turn on reload so the
* log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no
* loop ever emits this. Its events are real (they were durably appended before
* the crash) and are PRESERVED, not discarded: a single turn can be huge in a
* long-horizon task (many steps, large tool output), so truncating it would
* lose real work. The marker records that the turn was cut short, not that the
* model completed it. See the session-persistence RFC.
*/
interrupted: { kind: 'interrupted' }
}
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
/**
* The session event vocabulary — the append-only source of truth for an
* agent's whole interaction history. The LLM message history is *derived*
* from this log; nothing else is authoritative. Replay = re-derive from the
* same events; trace/telemetry = subscribe to the log.
*
* Merge-extensible: plugins declare extra event types via declaration merging
* (e.g. a compaction plugin adds `'compaction/marker'`).
*
* Durability contract (what a persistence backend relies on): the durable log
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
* contiguous (`seq = log.length`), so chunks cannot be filtered out of the
* canonical log. All `event.data` must be JSON-serializable — `Session.append`
* (and the seed path in the constructor) enforces this at the source (throwing
* on non-serializable data), so a bad event never enters the log and
* `session.events` always equals what a backend can persist. Adding a new event
* type that carries non-serializable data, or that breaks the turn/step nesting
* the invariants plugin checks, is a breaking change to the on-disk format.
*/
export interface SessionEventMap {
'turn/start': { turn: number; trigger: TurnTrigger }
'turn/end': { turn: number; reason: TurnEndReason }
'step/start': { turn: number; step: number }
'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
* as tagged synthetic context — NOT a user prompt.
*/
'context/message': { content: ContentBlock[]; source: MessageSource }
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
* the model output and its accounting travel together (there is no separate
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } }
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
}
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
* event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
*/
export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
| 'context/message'
| 'steering/message'
/**
* A {@link SessionEvent} that is **on** the surface linked list — its
* `surfaceOp` is guaranteed present (mandatory), narrowed from a
* surface-eligible {@link SessionEvent} by checking both `type` and
* `surfaceOp` at runtime.
*
* Use the `isSurfaceEvent` type guard (in `surface.ts`) to narrow a
* `SessionEvent` to this type.
*/
export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: SurfaceOp }
/**
* How a session event entered the surface linked list. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
* surface nodes in the current surface. `start === end` replaces a single
* node. The node's {@link SessionEvent.sourceEventSeqs} must include every
* shadowed surface node. Used by compaction and possible other manipulations.
*/
export type SurfaceOp =
| 'append'
| { op: 'replace'; start: number; end: number }
/**
* Optional surface metadata passed to {@link Session.append}.
* `surfaceOp` controls how the event enters the surface linked list;
* `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).
*
* Only accepted for {@link SurfaceEventType} events — non-surface event types
* (`turn/start`, `assistant/chunk`, `error`, …) cannot carry surface metadata.
*/
export interface SurfaceAppendOpts {
surfaceOp?: SurfaceOp
sourceEventSeqs?: number[]
}
/**
* One immutable entry in the session log.
*
* A proper discriminated union over `type` (not independent `type`/`data`
* unions), so `switch (event.type)` narrows `event.data` without casts.
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`, `context/message`, `steering/message`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
/** Monotonic sequence number within the session. */
seq: number
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
} & (K extends SurfaceEventType ? {
/**
* Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
* or the surface nodes shadowed by a compaction marker).
*/
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */
surfaceOp?: SurfaceOp
} : object)
}[T]

View File

@@ -0,0 +1,114 @@
/**
* Property-based tests for the Session event log (the property-testing RFC).
*
* Generates arbitrary event logs and asserts the derivation invariants the
* agent loop and replay depend on: deriveMessages is deterministic and
* replay-from-seed reproduces it; seq is strictly monotonic; non-message
* events never affect derived history.
*/
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session'
type Appendable = { [T in SessionEventType]: { type: T; data: SessionEventMap[T] } }[SessionEventType]
const textContentArb = fc.array(
fc.record({ type: fc.constant<'text'>('text'), text: fc.string() }),
{ maxLength: 3 },
)
// A message-producing event (these DO affect derived history).
const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, usage: { inputTokens: 1, outputTokens: 1 } } })),
fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() })
.map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError } })),
)
// A non-message event (trace/replay data — must NOT affect derived history).
const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),
)
const anyEventArb = fc.oneof(messageEventArb, nonMessageEventArb)
const logArb = fc.array(anyEventArb, { maxLength: 25 })
let counter = 0
function build(events: Appendable[]): Session {
const session = new Session(SessionId(`prop-${counter++}`))
for (const e of events) session.append(e.type, e.data)
return session
}
describe('Session properties', () => {
it('deriveMessages is deterministic (same log → identical derivation)', () => {
fc.assert(fc.property(logArb, (events) => {
const a = build(events)
expect(a.deriveMessages()).toEqual(a.deriveMessages())
}))
})
it('seq is strictly monotonic and zero-based contiguous', () => {
fc.assert(fc.property(logArb, (events) => {
const session = build(events)
session.events.forEach((event, i) => { expect(event.seq).toBe(i) })
expect(session.seq).toBe(events.length)
}))
})
it('replay-from-seed reproduces the derivation identically', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
expect(replayed.seq).toBe(original.seq)
}))
})
it('non-message events never affect derived history (any interleaving)', () => {
fc.assert(fc.property(
fc.array(messageEventArb, { maxLength: 12 }),
fc.array(nonMessageEventArb, { maxLength: 12 }),
// An arbitrary merge of the two streams that PRESERVES each stream's
// relative order (a random interleaving, not a fixed alternation).
fc.infiniteStream(fc.boolean()),
(messages, noise, pick) => {
const clean = build(messages).deriveMessages()
const interleaved: Appendable[] = []
let mi = 0
let ni = 0
const picker = pick[Symbol.iterator]()
while (mi < messages.length || ni < noise.length) {
// take from noise when chosen and available, else from messages
const takeNoise = ni < noise.length && (mi >= messages.length || picker.next().value === true)
if (takeNoise) { interleaved.push(noise[ni]!); ni++ }
else { interleaved.push(messages[mi]!); mi++ }
}
const withNoise = build(interleaved).deriveMessages()
expect(withNoise).toEqual(clean)
},
))
})
it('every derived message has a known role and decoupled content', () => {
fc.assert(fc.property(logArb, (events) => {
const session = build(events)
const messages = session.deriveMessages()
const before = structuredClone(session.events)
for (const m of messages) {
expect(['user', 'assistant', 'system']).toContain(m.role)
// Mutating derived content must not touch the log (append-only).
m.content.push({ type: 'text', text: 'mutation' })
}
expect(session.events).toEqual(before)
}))
})
})

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
* Unit coverage for the crash-recovery closer synthesis. The persistence
* contract exercises it end-to-end through both backends; these tests pin the
* pure function's branches directly — especially the synthetic error
* `tool/result` for a tool call the crash left unanswered (without it a
* resumed session replays a dangling assistant tool-call and the provider
* rejects the transcript).
*/
const userTurnStart = (turn: number, seq: number): SessionEvent =>
({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
describe('interruptedTurnClosers', () => {
it('returns nothing for a balanced log (ends on turn/end)', () => {
const balanced: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(interruptedTurnClosers(balanced)).toEqual([])
})
it('returns nothing for an empty log', () => {
expect(interruptedTurnClosers([])).toEqual([])
})
it('closes an open turn with no open step (turn/end {interrupted} only)', () => {
const events: SessionEvent[] = [userTurnStart(1, 0)]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
const end = closers[0]!
expect(end.seq).toBe(1)
expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
})
it('closes an open step before the turn (step/end then turn/end)', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
expect(closers.map(e => e.seq)).toEqual([2, 3])
})
it('synthesizes an error tool/result for a tool-call the crash left unanswered', () => {
// A step issued one tool call (in the assistant message) but crashed before
// the tool/result was logged — the classic mid-tool crash.
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'text', text: 'calling a tool' },
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
] } },
]
const closers = interruptedTurnClosers(events)
// tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
})
})
it('does NOT synthesize a result for a tool-call that already has one', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
] } },
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 2, step: 1, callId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }], isError: false } },
]
// The call is answered, so only the open step + turn need closing.
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
it('does NOT synthesize a result after the owning step already closed', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
] } },
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
expect(closers[0]?.seq).toBe(4)
})
it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
// Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
// with an unanswered call. Only turn 2's call must get a synthetic result.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' },
] } },
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('old-call'), content: [], isError: false } },
{ type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
userTurnStart(2, 6),
{ type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 8, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' },
] } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.callId).toBe('new-call')
})
it('synthesizes a result for each of multiple unanswered calls, in log order', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' },
{ type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' },
] } },
// call-a got answered before the crash; call-b did not.
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-a'), content: [], isError: false } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.callId).toBe('call-b')
})
it('synthesized tool/result carries surfaceOp and sourceEventSeqs when tool/call was logged', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
] } },
{ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-1'), name: 'bash', arguments: '{}' } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect((result as SurfaceEvent).surfaceOp).toBe('append')
expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3])
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
// A tool/call event exists in the log but no assistant/message registered
// the callId in pendingCalls (e.g., a plugin appended it directly, or the
// assistant/message from a prior step didn't have this call). The repair
// should still close the turn — it just won't synthesize a result for this
// call (there's nothing to answer).
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'tool/call', seq: 2, time: 2, data: { turn: 1, step: 1, callId: CallId('orphan'), name: 'bash', arguments: '{}' } },
]
const closers = interruptedTurnClosers(events)
// No pending calls → no synthetic tool/result, just step/end + turn/end.
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})

View File

@@ -0,0 +1,338 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('derives message history from the event log', () => {
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
session.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'text', text: 'let me check' },
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
],
})
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const messages = session.deriveMessages()
expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
// raw chunks must NOT appear in derived history
expect(messages[1]!.content).toHaveLength(2)
expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') })
})
it('accepts and round-trips a max-tokens turn/end reason', () => {
// The max-tokens TurnEndReason variant carries no extra data, so it must
// append and persist like any other reason (JSON-serializable, no fields).
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
// survives a structuredClone (the persistence-serialization boundary)
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('renders context and steering messages as tagged synthetic user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
})
session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
})
const [contextMessage, steeringMessage] = session.deriveMessages()
expect(contextMessage!.role).toBe('user')
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
})
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
expect(replayed.seq).toBe(original.seq)
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
content: [{ type: 'text', text: 'tool out' }], isError: false,
})
const before = structuredClone(session.events)
// A request middleware / adapter mutates the messages it was handed.
const messages = session.deriveMessages()
const userBlock = messages[0]!.content[0]!
if (userBlock.type === 'text') userBlock.text = 'HACKED'
const toolBlock = messages[1]!.content[0]!
if (toolBlock.type === 'tool-result') {
toolBlock.content.push({ type: 'text', text: 'injected' })
}
messages[0]!.content.push({ type: 'text', text: 'extra' })
// The log is unchanged: deep-equal to the snapshot taken before mutation.
expect(session.events).toEqual(before)
// And a fresh derivation still reflects the original content.
expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
})
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
const session = new Session(SessionId('s5'))
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never)
expect(bad(1n)).toThrow(/non-JSON-serializable/)
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
expect(bad(new Map())).toThrow(/non-JSON-serializable/)
expect(bad(undefined)).toThrow(/non-JSON-serializable/)
expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
// A sparse array: `every` skips the hole but JSON.stringify writes it null.
// Build the hole without a sparse literal or `delete` (both linted).
const sparse: unknown[] = Array(3)
sparse[0] = 1
sparse[2] = 3 // index 1 stays a hole
expect(bad(sparse)).toThrow(/non-JSON-serializable/)
// A DENSE array carrying a non-serializable element is rejected too.
expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
// A nested non-serializable value (inside a plain object) is rejected.
expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
// A circular reference is rejected (the seen-set guard, not a stack blow-up).
const cyclic: Record<string, unknown> = { a: 1 }
cyclic['self'] = cyclic
expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
// The rejected appends never entered the log.
expect(session.events).toHaveLength(0)
})
it('accepts dense arrays and nested plain objects', () => {
const session = new Session(SessionId('s6'))
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never)).not.toThrow()
expect(session.events).toHaveLength(1)
})
it('validates seed events: rejects a non-JSON-serializable seed', () => {
// A replay/fork seed must satisfy the SAME invariant as Session.append, or
// it builds a live log no backend can persist.
const badSeed = [
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/)
})
it('validates seed events: rejects a non-contiguous seq', () => {
const gapSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
})
it('accepts a well-formed contiguous serializable seed', () => {
const goodSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
expect(session.events).toHaveLength(3)
})
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
// Mutate the ORIGINAL seed objects after construction: a shared reference
// would let this rewrite the forked log (or reintroduce non-serializable
// data past validation). The snapshot must shield session.events.
const um = seed[1]!
;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
;(um.data as Record<string, unknown>)['injected'] = 1n // would have failed validation
const logged = session.events[1]!
expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
})
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
const session = new Session(SessionId('append-snapshot'))
const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
const event = session.append('user/message', data)
// Mutate the caller's object after append returns. A shared reference would
// make session.events diverge from the value that passed validation.
data.content[0]!.text = 'HACKED'
;(data as Record<string, unknown>)['injected'] = 1n
const logged = session.events[0]!
expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
// The returned event carries the same snapshot, not the caller's input.
expect((event.data.content[0] as { text: string }).text).toBe('original')
})
})
describe('SessionStore', () => {
it('creates sessions, emits session/created and session/event', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const created: Session[] = []
const events: [Session, SessionEvent][] = []
ctx.on('session/created', session => void created.push(session))
ctx.on('session/event', (session, event) => void events.push([session, event]))
const session = ctx.sessions.create()
expect(created).toEqual([session])
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
expect(events).toHaveLength(1)
expect(events[0]![0]).toBe(session)
expect(events[0]![1].type).toBe('user/message')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.sessions.list()).toEqual([session])
})
it('rejects duplicate ids and supports seeding', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
})
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
// prepare()/enter() are public cross-package primitives that a caller may
// separate with arbitrary work. A stale prepared session must NOT overwrite
// a live store entry of the same id — its detach disposer would later delete
// the REAL session, breaking the store-uniqueness invariant.
const ctx = new Context()
await ctx.plugin(SessionStore)
const stale = ctx.sessions.prepare(SessionId('racy'))
const live = ctx.sessions.create(SessionId('racy'))
expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
// The live session is intact and still the store entry.
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const created: Session[] = []
ctx.on('session/created', session => void created.push(session))
const session = ctx.sessions.prepare(SessionId('lifecycle'))
// prepare alone does NOT enter the store.
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
const detach = ctx.sessions.enter(session)
expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
// enter does NOT announce.
expect(created).toEqual([])
ctx.sessions.announce(session)
expect(created).toEqual([session])
// The detach disposer removes the entry + stops notification.
detach()
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
})
it('attaches cwd and parentSession from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('child'), {
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
})
expect(session.header).toMatchObject({
version: SESSION_FORMAT_VERSION,
id: 'child',
cwd: '/work/project',
parentSession: 'parent',
})
})
it('rejects a non-absolute meta.cwd', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
.toThrow(/cwd must be an absolute path/)
// the rejected session was not registered
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
})
it('a bare Session() constructed without the store still exposes a current-version header', () => {
const session = new Session(SessionId('bare'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
expect(typeof session.header.createdAt).toBe('number')
})
it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let session!: Session
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('scoped'))
}, { inject: ['sessions'] }))
expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
let observed = 0
ctx.on('session/event', () => void observed++)
await fiber.dispose()
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
expect(observed).toBe(0)
})
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let threw = false
ctx.on('session/created', () => {
if (!threw) { threw = true; throw new Error('boom created listener') }
})
// The throwing emit must roll the store entry back, not leak it.
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
// A subsequent create of the SAME id succeeds (the already-exists check is
// not wedged) and its onAppend is correctly wired (events observable).
const events: SessionEvent[] = []
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
expect(events).toHaveLength(1)
})
})

View File

@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
/** Build a minimal session with turn boundaries and a single user message. */
function surfaceSession(): Session {
const s = new Session(SessionId('ss'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
describe('SurfaceManager', () => {
it('rebuilds a linked 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()
})
it('hasSurface returns false when no events have surfaceOp', () => {
const s = new Session(SessionId('nosurface'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(s.surface.hasSurface).toBe(false)
})
it('hasSurface returns true when any event has surfaceOp', () => {
const s = surfaceSession()
expect(s.surface.hasSurface).toBe(true)
})
it('hasSurface detects surface markers that arrive after initial processing', () => {
// Start with no surface markers. Access nodes first to set _lastProcessedSeq
// (via delta processing), keeping _nodes empty. Then append a mix of non-surface
// and surface events, and verify hasSurface detects via the delta-only check.
const s = new Session(SessionId('late'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
// Access nodes to trigger processing: sets _lastProcessedSeq = 1, _nodes = [].
expect(s.surface.nodes.length).toBe(0)
// Append non-surface events first (exercises the loop-continue branch), then
// a surface event (exercises the return-true branch).
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
s.append('turn/start', { turn: 2, trigger: { kind: 'continuation' } })
s.append('assistant/message', { turn: 2, step: 1, content: [] }, { surfaceOp: 'append' })
// hasSurface checks only new seqs [2, 3, 4]; skips 2 and 3 (non-surface),
// finds surfaceOp on seq 4 and returns true.
expect(s.surface.hasSurface).toBe(true)
})
it('invalidate resets to full rebuild', () => {
const s = surfaceSession()
expect(s.surface.nodes.length).toBe(2)
// After invalidate, the surface should rebuild from scratch on next access.
;(s.surface).invalidate()
expect(s.surface.nodes.length).toBe(2) // same result, but rebuilt
})
it('empty surface yields empty nodes', () => {
const s = new Session(SessionId('empty'))
// Only turn boundaries, no surface nodes.
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(s.surface.nodes.length).toBe(0)
expect(s.surface.hasSurface).toBe(false)
// deriveMessages returns empty array
expect(s.deriveMessages()).toEqual([])
})
it('picks up new events incrementally (delta processing)', () => {
const s = surfaceSession()
expect(s.surface.nodes.length).toBe(2)
// 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)
})
it('replays identically from a seeded log with surface markers', () => {
const original = surfaceSession()
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.deriveMessages()).toEqual(original.deriveMessages())
})
it('rebuild with replace operation splices out shadowed nodes', () => {
const s = surfaceSession()
// seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end
// Surface nodes: seq 1 (user), seq 2 (assistant).
// Replace both with a compaction marker. Both 1 and 2 are valid surface seqs.
s.append('assistant/message',
{ turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
{ 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()
})
it('replace with both ends at real nodes splices only the range', () => {
const s = new Session(SessionId('range'))
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
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
// Replace seq 0 through 1 inclusive: shadow a and b, keep c.
s.append('assistant/message',
{ 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()
})
it('single-node replacement (start === end)', () => {
const s = new Session(SessionId('single'))
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
// Replace only seq 1 (single node).
s.append('assistant/message',
{ 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)
})
it('throws when replace start is not found', () => {
const s = new Session(SessionId('bad-start'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
)
expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
})
it('throws when replace end is not found', () => {
const s = new Session(SessionId('bad-end'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
)
expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
})
it('throws when start is after end', () => {
const s = new Session(SessionId('reversed'))
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
// start=1, end=0 would be reversed order.
s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
)
expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
})
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
const s = new Session(SessionId('immutable'))
const sources = [10, 20]
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
// Mutate caller's array after append.
sources.push(30)
sources[0] = 99
const logged = s.events[0]! as SurfaceEvent
expect(logged.sourceEventSeqs).toEqual([10, 20])
})
it('replace starting at non-head position links to previous node correctly', () => {
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
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
// Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
s.append('assistant/message',
{ 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()
})
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
const s = new Session(SessionId('immutable-op'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const op = { op: 'replace' as const, start: 0, end: 0 }
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
// Mutate caller's object after append.
op.start = 99
const logged = s.events[1]! as SurfaceEvent
expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
})
})
describe('deriveMessages with surface', () => {
it('uses the surface path when surface markers are present', () => {
const s = surfaceSession()
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.role).toBe('user')
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
expect(messages[1]!.role).toBe('assistant')
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
})
it('falls back to linear scan when no surface markers exist', () => {
const s = new Session(SessionId('legacy'))
s.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.role).toBe('user')
expect(messages[1]!.role).toBe('assistant')
})
it('surface path skips non-surface events (chunks, boundaries)', () => {
const s = new Session(SessionId('filter'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Chunks and boundaries are NOT in the surface, so only 2 messages.
expect(s.deriveMessages()).toHaveLength(2)
})
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
const s = new Session(SessionId('compacted'))
s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
// Only the compaction node is visible.
const messages = s.deriveMessages()
expect(messages).toHaveLength(1)
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
})
it('context/message and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
})
})
describe('Session.append surface opts', () => {
it('records sourceEventSeqs and surfaceOp on the event', () => {
const s = new Session(SessionId('opts'))
const event = s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
{ surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
)
expect(event.sourceEventSeqs).toEqual([3, 5, 7])
expect(event.surfaceOp).toBe('append')
// The logged event matches the returned event.
expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
})
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
// An empty-content assistant/message is surface-eligible (it can host usage)
// but _deriveOneMessage returns null for it, so the surface derivation path's
// null-check is exercised — the node is on the surface yet produces no message.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [] }, surfaceOp: 'append' },
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
const s = new Session(SessionId('nomessage'), seed)
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
expect(s.deriveMessages()).toHaveLength(0)
})
it('append without surface opts produces an event without surface fields', () => {
const s = new Session(SessionId('noopts'))
s.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
})
it('surfaceOp primitives are not cloned (they are immutable)', () => {
const s = new Session(SessionId('prim'))
const event = s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
// The string 'append' is a primitive — identity-preserving is fine.
expect(event.surfaceOp).toBe('append')
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
}
]
}