Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
61
packages/core/session/README.md
Normal file
61
packages/core/session/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 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.
|
||||
|
||||
## 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): 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).
|
||||
- `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages.
|
||||
- `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.
|
||||
|
||||
### 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).
|
||||
|
||||
### 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).
|
||||
|
||||
### 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.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Session branching/tree** (pi-style entry tree) — defered unless needed beyond seed-based forking.
|
||||
34
packages/core/session/package.json
Normal file
34
packages/core/session/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
340
packages/core/session/src/index.ts
Normal file
340
packages/core/session/src/index.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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'
|
||||
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types'
|
||||
import { isJsonValue } from './json'
|
||||
|
||||
export * from './types'
|
||||
export { isJsonValue } from './json'
|
||||
export { interruptedTurnClosers } from './repair'
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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]): 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.
|
||||
const event = { type, seq: this.log.length, time: Date.now(), data: structuredClone(data) } as SessionEvent<T>
|
||||
this.log.push(event)
|
||||
this.onAppend?.(event)
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the LLM message history from the event log.
|
||||
*
|
||||
* - `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[] {
|
||||
const messages: Message[] = []
|
||||
for (const event of this.log) {
|
||||
// Intentionally non-exhaustive: only message-producing events derive
|
||||
// history; turn/step boundaries and chunks are trace/replay data.
|
||||
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
messages.push({ role: 'user', content: structuredClone(event.data.content) })
|
||||
break
|
||||
}
|
||||
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) break
|
||||
messages.push({ role: 'assistant', content: structuredClone(event.data.content) })
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
messages.push({ role: 'user', content: renderTagged('context', structuredClone(content), source) })
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
messages.push({ role: 'user', content: renderTagged('steering', structuredClone(content), source) })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
71
packages/core/session/src/json.ts
Normal file
71
packages/core/session/src/json.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
140
packages/core/session/src/repair.ts
Normal file
140
packages/core/session/src/repair.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
const pendingCalls = new Map<CallId, { step: 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/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 }] 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' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
200
packages/core/session/src/types.ts
Normal file
200
packages/core/session/src/types.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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]
|
||||
}
|
||||
}[T]
|
||||
114
packages/core/session/tests/properties.spec.ts
Normal file
114
packages/core/session/tests/properties.spec.ts
Normal 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)
|
||||
}))
|
||||
})
|
||||
})
|
||||
140
packages/core/session/tests/repair.spec.ts
Normal file
140
packages/core/session/tests/repair.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { interruptedTurnClosers } from '../src/index'
|
||||
import type { SessionEvent } from '../src/index'
|
||||
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
338
packages/core/session/tests/session.spec.ts
Normal file
338
packages/core/session/tests/session.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
24
packages/core/session/tsconfig.json
Normal file
24
packages/core/session/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user