Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows
Master removed the stdio demo (#702c8cc30) — accept the deletion; this branch's packChunks passthrough survives in acp-demo (auto-merged), and cli-demo/tui-demo arrived from master without one (the follow-up snapshot PR decides which demos expose the switch). Generated catalogs regenerated over merged sources; the hand-written session.md durability paragraph re-weaves this branch's lossless-encoding wording with master's invariant- companion sentence.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
|
||||
|
||||
The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package.
|
||||
|
||||
## Service: `SessionStore` (ctx key: `sessions`)
|
||||
|
||||
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
|
||||
@@ -10,6 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
@@ -34,7 +38,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
@@ -64,11 +68,13 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
|
||||
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded.
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
|
||||
|
||||
An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state.
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
|
||||
@@ -82,7 +88,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
### 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: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,12 +28,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -29,6 +29,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed message-triggered turn, excluding injection and
|
||||
* plugin-owned zero-step turns.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const messageTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
@@ -139,6 +160,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
assertCurrentLlmShape(event, index)
|
||||
assertCurrentTurnEndShape(event, index)
|
||||
}
|
||||
|
||||
/** Reject pre-provider request headers and assistant messages at the seed/load boundary. */
|
||||
@@ -156,6 +178,22 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
|
||||
function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
|
||||
if (event['type'] !== 'turn/end') return
|
||||
const data = event['data']
|
||||
/* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
const reason = (data as Record<string, unknown>)['reason']
|
||||
/* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
|
||||
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
|
||||
const record = reason as Record<string, unknown>
|
||||
if (record['kind'] === 'aborted'
|
||||
&& (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
|
||||
throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an unknown value carries the current provider/model pair. */
|
||||
function hasProviderModel(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
@@ -212,6 +250,7 @@ interface SessionEntry {
|
||||
announced: boolean
|
||||
announcing: boolean
|
||||
appending: boolean
|
||||
outOfBand: boolean
|
||||
detachRequested: boolean
|
||||
detach(): void
|
||||
}
|
||||
@@ -386,7 +425,7 @@ export class Session {
|
||||
} finally {
|
||||
if (entry !== undefined) {
|
||||
entry.appending = false
|
||||
if (entry.detachRequested && !entry.announcing) entry.detach()
|
||||
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,6 +709,7 @@ export class SessionStore extends Service {
|
||||
announced: false,
|
||||
announcing: false,
|
||||
appending: false,
|
||||
outOfBand: false,
|
||||
detachRequested: false,
|
||||
detach: () => { this.detachEntered(entry) },
|
||||
}
|
||||
@@ -682,7 +722,7 @@ export class SessionStore extends Service {
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (entry.announcing || entry.appending) {
|
||||
if (entry.announcing || entry.appending || entry.outOfBand) {
|
||||
entry.detachRequested = true
|
||||
return
|
||||
}
|
||||
@@ -736,7 +776,7 @@ export class SessionStore extends Service {
|
||||
}
|
||||
} finally {
|
||||
entry.announcing = false
|
||||
if (entry.detachRequested && !entry.appending) entry.detach()
|
||||
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +820,87 @@ export class SessionStore extends Service {
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one plugin-declared log-only event without borrowing the agent
|
||||
* loop's lifecycle. An open turn receives the event directly and remains
|
||||
* responsible for its ordinary checkpoint. A closed log receives one
|
||||
* zero-step turn around the event, followed by an awaited flush.
|
||||
*
|
||||
* Once the synthetic `turn/start` commits, this method always attempts its
|
||||
* matching `turn/end` and flush, including when the target append fails.
|
||||
* Detachment requested by an event or flush listener is deferred until that
|
||||
* sequence settles, so publication cannot switch from a live scoped session
|
||||
* to an unobserved bare `Session` halfway through the update.
|
||||
*
|
||||
* @param session - exact live session that owns the target log.
|
||||
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
|
||||
* @param data - typed JSON payload for the target event.
|
||||
* @param trigger - plugin-owned turn trigger used only when the log is closed.
|
||||
* @returns the accepted target event with its assigned sequence and timestamp.
|
||||
* @throws when the session is detached, another out-of-band append is active,
|
||||
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
|
||||
*/
|
||||
async appendOutOfBand<T extends OutOfBandSessionEventType>(
|
||||
session: Session,
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
trigger: TurnTrigger,
|
||||
): Promise<SessionEvent<T>> {
|
||||
const entry = this.liveEntryFor(session)
|
||||
if (entry.outOfBand) {
|
||||
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
|
||||
}
|
||||
entry.outOfBand = true
|
||||
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
|
||||
// TypeScript does not reduce Session.append's conditional rest parameter
|
||||
// through a generic intersection. Preserve that proven two-argument call
|
||||
// shape without widening the public Session.append overload.
|
||||
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
|
||||
eventType: K,
|
||||
eventData: SessionEventMap[K],
|
||||
) => SessionEvent<K>
|
||||
try {
|
||||
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
|
||||
if (lastBoundary?.type === 'turn/start') {
|
||||
return appendLogOnly(type, data)
|
||||
}
|
||||
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
let accepted: SessionEvent<T> | undefined
|
||||
let failure: unknown
|
||||
let opened = false
|
||||
try {
|
||||
session.append('turn/start', { turn, trigger })
|
||||
opened = true
|
||||
accepted = appendLogOnly(type, data)
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
} finally {
|
||||
if (opened) {
|
||||
// The only target types admitted by OutOfBandSessionEventMap are
|
||||
// log-only plugin events, so the synthetic turn remains open here.
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
try {
|
||||
await this.flush(session)
|
||||
} catch (error: unknown) {
|
||||
if (failure === undefined) failure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
|
||||
throw failure
|
||||
}
|
||||
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
|
||||
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
|
||||
return accepted
|
||||
} finally {
|
||||
entry.outOfBand = false
|
||||
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): SessionEntry {
|
||||
const entry = attachments.get(session)
|
||||
|
||||
238
packages/core/session/src/invariant.ts
Normal file
238
packages/core/session/src/invariant.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Package-owned relational invariants for the session event log. Load this
|
||||
* companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Per-session bookkeeping for relational log checks. */
|
||||
interface SessionTrace {
|
||||
lastSeq: number
|
||||
openTurn: number | null
|
||||
openStep: number | null
|
||||
nextTurn: number
|
||||
nextStep: number
|
||||
pendingCalls: Set<CallId>
|
||||
}
|
||||
|
||||
/** One accepted event's deferred mutation of a committed session trace. */
|
||||
interface SessionTraceTransition {
|
||||
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
|
||||
pendingCalls:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'add' | 'delete'; callId: CallId }
|
||||
| { kind: 'clear' }
|
||||
}
|
||||
|
||||
/** Assert that a step-scoped event names the currently open turn and step. */
|
||||
function requireOpenStep(
|
||||
trace: SessionTrace,
|
||||
kind: string,
|
||||
turn: number,
|
||||
step: number,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (trace.openTurn !== turn || trace.openStep !== step) {
|
||||
fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one candidate event without mutating the committed trace. */
|
||||
function validateEvent(
|
||||
trace: SessionTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): SessionTraceTransition {
|
||||
if (event.seq <= trace.lastSeq) {
|
||||
fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
|
||||
}
|
||||
let openTurn = trace.openTurn
|
||||
let openStep = trace.openStep
|
||||
let nextTurn = trace.nextTurn
|
||||
let nextStep = trace.nextStep
|
||||
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
|
||||
|
||||
// SessionEventMap is merge-extensible, so the default enforces turn
|
||||
// enclosure for package-added events as well as the built-in variants.
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
if (trace.openTurn !== null) {
|
||||
fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
|
||||
}
|
||||
if (event.data.turn !== trace.nextTurn) {
|
||||
fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
|
||||
}
|
||||
openTurn = event.data.turn
|
||||
nextStep = 1
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (trace.openTurn !== event.data.turn) {
|
||||
fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
|
||||
}
|
||||
if (trace.openStep !== null) {
|
||||
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
openTurn = null
|
||||
nextTurn += 1
|
||||
break
|
||||
}
|
||||
case 'step/start': {
|
||||
if (trace.openTurn !== event.data.turn) {
|
||||
fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
|
||||
}
|
||||
if (trace.openStep !== null) {
|
||||
fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
if (event.data.step !== trace.nextStep) {
|
||||
fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
|
||||
}
|
||||
openStep = event.data.step
|
||||
break
|
||||
}
|
||||
case 'step/end': {
|
||||
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
|
||||
pendingCalls = { kind: 'clear' }
|
||||
openStep = null
|
||||
nextStep += 1
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail)
|
||||
break
|
||||
}
|
||||
case 'tool/call': {
|
||||
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail)
|
||||
pendingCalls = { kind: 'add', callId: event.data.callId }
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
// Session has already validated a provenance-backed content rewrite.
|
||||
// It is durable turn work, not a second execution of the original call.
|
||||
if (event.surfaceOp !== 'append') {
|
||||
if (trace.openTurn === null) {
|
||||
fail('tool/result surface replacement appended outside any open turn')
|
||||
}
|
||||
break
|
||||
}
|
||||
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
pendingCalls = { kind: 'delete', callId: event.data.callId }
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (trace.openTurn === null) {
|
||||
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
|
||||
pendingCalls,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one already-validated transition after its event commits. */
|
||||
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
|
||||
Object.assign(trace, transition.scalars)
|
||||
switch (transition.pendingCalls.kind) {
|
||||
case 'none':
|
||||
break
|
||||
case 'add':
|
||||
trace.pendingCalls.add(transition.pendingCalls.callId)
|
||||
break
|
||||
case 'delete':
|
||||
trace.pendingCalls.delete(transition.pendingCalls.callId)
|
||||
break
|
||||
case 'clear':
|
||||
trace.pendingCalls.clear()
|
||||
break
|
||||
/* v8 ignore next -- validateEvent produces this closed transition union */
|
||||
default:
|
||||
assertNever(transition.pendingCalls, 'session trace pending-call transition')
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the session contribution into its child registration fiber. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, SessionTrace>()
|
||||
const stagedTransitions = new WeakMap<SessionEvent, {
|
||||
session: Session
|
||||
trace: SessionTrace
|
||||
transition: SessionTraceTransition
|
||||
}>()
|
||||
|
||||
const freshTrace = (): SessionTrace => ({
|
||||
lastSeq: -1,
|
||||
openTurn: null,
|
||||
openStep: null,
|
||||
nextTurn: 1,
|
||||
nextStep: 1,
|
||||
pendingCalls: new Set(),
|
||||
})
|
||||
|
||||
const seedSession = (session: Session): SessionTrace => {
|
||||
const trace = freshTrace()
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
applyTransition(trace, validateEvent(trace, event, fail))
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
/* v8 ignore next -- session/event always follows list() or session/created seeding */
|
||||
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seedSession(session)
|
||||
|
||||
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const staged = stagedTransitions.get(event)
|
||||
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
||||
if (staged === undefined || staged.session !== session) {
|
||||
return fail('session/event reached publication without matching pre-commit validation')
|
||||
}
|
||||
stagedTransitions.delete(event)
|
||||
applyTransition(staged.trace, staged.transition)
|
||||
}, { global: true })
|
||||
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const trace = traceFor(session)
|
||||
const transition = validateEvent(trace, event, fail)
|
||||
// A later dispatch listener may veto. Validation is pure, so abandoning
|
||||
// this weakly keyed transition does not advance or retain the session.
|
||||
stagedTransitions.set(event, { session, trace, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
/**
|
||||
* Register the session invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
@@ -101,14 +101,19 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
*/
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
aborted: { kind: 'aborted'; reason?: string }
|
||||
/** A cancellation request interrupted the live turn. */
|
||||
aborted: { kind: 'aborted' }
|
||||
/**
|
||||
* 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.
|
||||
* `agent/error`). Final model-request failures retain their normalized facts
|
||||
* as one `failure`; other turn failures retain their live Error message/code.
|
||||
*/
|
||||
error: { kind: 'error'; step: number; message: string; code?: string }
|
||||
error: { kind: 'error'; step: number } & (
|
||||
| { failure: LlmFailure; message?: never; code?: never }
|
||||
| { message: string; code?: string; failure?: never }
|
||||
)
|
||||
disposed: { kind: 'disposed' }
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
@@ -259,9 +264,23 @@ export interface SessionEventMap {
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker map for plugin-owned log-only events accepted by
|
||||
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
|
||||
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
|
||||
* ineligible unless their owner explicitly opts them into this narrow seam.
|
||||
*/
|
||||
export interface OutOfBandSessionEventMap {}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
|
||||
export type OutOfBandSessionEventType = Exclude<
|
||||
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
|
||||
SurfaceEventType
|
||||
>
|
||||
|
||||
/**
|
||||
* The subset of {@link SessionEventType} values whose events produce LLM
|
||||
* messages and are eligible to appear on the ordered surface. Only these
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('SessionStore.fork', () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const reasons: TurnEndReason[] = [
|
||||
{ kind: 'completed' },
|
||||
{ kind: 'aborted', reason: 'cancelled by user' },
|
||||
{ kind: 'aborted' },
|
||||
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
|
||||
{ kind: 'disposed' },
|
||||
{ kind: 'max-tokens' },
|
||||
|
||||
344
packages/core/session/tests/invariant.spec.ts
Normal file
344
packages/core/session/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
const fiber = await ctx.plugin(SessionInvariant)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('session-log invariants', () => {
|
||||
it('keeps registration global when the companion is mounted under a scope', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
let scopedCtx!: Context
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scopedCtx = createScope(inner, {}).ctx
|
||||
}, { inject: ['sessions', 'invariants'] }))
|
||||
await scopedCtx.plugin(SessionInvariant)
|
||||
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a well-formed turn, step, and tool sequence', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not advance committed trace state when a later dispatch listener vetoes', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
|
||||
let veto = true
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name !== 'session/event' || !veto) return
|
||||
veto = false
|
||||
throw new Error('later dispatch veto')
|
||||
})
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('later dispatch veto')
|
||||
expect(session.events).toEqual([])
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the committed transition after another postcommit observer throws', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('postcommit-peer'))
|
||||
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
expect(warnings).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects non-monotonic event sequence numbers', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
} as never)
|
||||
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
|
||||
type: 'turn/end',
|
||||
seq: 0,
|
||||
time: 2,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
} as never) }).toThrow(/seq must strictly increase/)
|
||||
})
|
||||
|
||||
it('enforces turn numbering and enclosure', async () => {
|
||||
const first = await setup()
|
||||
const open = first.ctx.sessions.create()
|
||||
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
|
||||
.toThrow(/does not match open turn 1/)
|
||||
|
||||
const second = (await setup()).ctx.sessions.create()
|
||||
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/expected turn 2, got 3/)
|
||||
|
||||
const outside = (await setup()).ctx.sessions.create()
|
||||
expect(() => outside.append('user/message', {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
|
||||
expect(() => outside.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
|
||||
// Merge-extensible session events use the same default enclosure branch.
|
||||
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
|
||||
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('enforces open-step identity and numbering', async () => {
|
||||
const wrongTurn = (await setup()).ctx.sessions.create()
|
||||
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
|
||||
|
||||
const nested = (await setup()).ctx.sessions.create()
|
||||
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
nested.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
|
||||
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
|
||||
.toThrow(/while step 1 is still open/)
|
||||
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
|
||||
expect(() => nested.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1,
|
||||
step: 2,
|
||||
content: [],
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
|
||||
|
||||
const skipped = (await setup()).ctx.sessions.create()
|
||||
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
skipped.append('step/start', { turn: 1, step: 1 })
|
||||
skipped.append('step/end', { turn: 1, step: 1 })
|
||||
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
|
||||
.toThrow(/expected step 2 in turn 1, got 3/)
|
||||
})
|
||||
|
||||
it('requires step-scoped stream and tool events to name the open step', async () => {
|
||||
const chunk = (await setup()).ctx.sessions.create()
|
||||
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => chunk.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'x' },
|
||||
})).toThrow(/open is turn 1\/step null/)
|
||||
|
||||
const tool = (await setup()).ctx.sessions.create()
|
||||
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
tool.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => tool.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('ghost'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
|
||||
})
|
||||
|
||||
it('keeps fresh tool-result appends open-step checked', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('closed'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
|
||||
})
|
||||
|
||||
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tool-result replacement outside a turn', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('allows interrupted repair results and unresolved calls at step end', async () => {
|
||||
const repaired = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
repaired.append('step/start', { turn: 1, step: 1 })
|
||||
repaired.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('crashed'),
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
}, { surfaceOp: 'append' })
|
||||
repaired.append('step/end', { turn: 1, step: 1 })
|
||||
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const unresolved = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
unresolved.append('step/start', { turn: 1, step: 1 })
|
||||
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
unresolved.append('step/end', { turn: 1, step: 1 })
|
||||
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not let a result in a later step satisfy an earlier call', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
|
||||
})
|
||||
|
||||
it('replays seeded sessions and tracks each session independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const badSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
]
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
|
||||
|
||||
const a = ctx.sessions.create(SessionId('a'))
|
||||
const b = ctx.sessions.create(SessionId('b'))
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(SessionInvariant)
|
||||
expect(() => session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'h' },
|
||||
})).not.toThrow()
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
|
||||
it('removes all listeners when the companion is disposed', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await fiber.dispose()
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).not.toThrow()
|
||||
})
|
||||
})
|
||||
226
packages/core/session/tests/out-of-band.spec.ts
Normal file
226
packages/core/session/tests/out-of-band.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
'test/log-only': { value: string }
|
||||
}
|
||||
|
||||
interface OutOfBandSessionEventMap {
|
||||
'test/log-only': true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
|
||||
|
||||
describe('SessionStore.appendOutOfBand', () => {
|
||||
it('joins an open turn without adding a boundary or flushing it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('open'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
const event = await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'inside' },
|
||||
updateTrigger,
|
||||
)
|
||||
|
||||
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
|
||||
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('closed'))
|
||||
const flushedTypes: string[][] = []
|
||||
ctx.on('session/flush', (flushed) => {
|
||||
flushedTypes.push(flushed.events.map(event => event.type))
|
||||
})
|
||||
|
||||
const first = await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'first' },
|
||||
updateTrigger,
|
||||
)
|
||||
const second = await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'second' },
|
||||
updateTrigger,
|
||||
)
|
||||
|
||||
expect(first.seq).toBe(1)
|
||||
expect(second.seq).toBe(4)
|
||||
expect(session.events).toMatchObject([
|
||||
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
|
||||
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
|
||||
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
|
||||
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
|
||||
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
])
|
||||
expect(flushedTypes).toEqual([
|
||||
['turn/start', 'test/log-only', 'turn/end'],
|
||||
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
|
||||
])
|
||||
})
|
||||
|
||||
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('rejected'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 1n } as never,
|
||||
updateTrigger,
|
||||
)).rejects.toThrow(/non-JSON-serializable/)
|
||||
|
||||
expect(session.events).toMatchObject([
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
expect(flushes).toBe(1)
|
||||
})
|
||||
|
||||
it('does not flush when the synthetic turn cannot open', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('start-failure'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'unreachable' },
|
||||
{ ...updateTrigger, invalid: 1n } as never,
|
||||
)).rejects.toThrow(/non-JSON-serializable/)
|
||||
|
||||
expect(session.events).toEqual([])
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves a target rejection when the balancing flush also rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
|
||||
ctx.on('session/flush', () => { throw new Error('disk failed') })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 1n } as never,
|
||||
updateTrigger,
|
||||
)).rejects.toThrow(/non-JSON-serializable/)
|
||||
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'turn/end',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the session attached through publication and its flush', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.prepare(SessionId('dispose'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
let liveDuringFlush = false
|
||||
ctx.on('session/event', (_observed, event) => {
|
||||
if (event.type === 'turn/start') detach()
|
||||
})
|
||||
ctx.on('session/flush', () => {
|
||||
liveDuringFlush = ctx.sessions.get(session.id) === session
|
||||
})
|
||||
|
||||
await ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'last' },
|
||||
updateTrigger,
|
||||
)
|
||||
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'test/log-only',
|
||||
'turn/end',
|
||||
])
|
||||
expect(liveDuringFlush).toBe(true)
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects detached sessions before opening a turn', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.prepare(SessionId('detached'))
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'nope' },
|
||||
updateTrigger,
|
||||
)).rejects.toThrow('session "detached" is not live in this store')
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a balanced log when the durability checkpoint rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('flush-failure'))
|
||||
ctx.on('session/flush', () => { throw new Error('disk failed') })
|
||||
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'accepted' },
|
||||
updateTrigger,
|
||||
)).rejects.toThrow('disk failed')
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'test/log-only',
|
||||
'turn/end',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects overlapping updates while the first append is still settling', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('overlap'))
|
||||
let release!: () => void
|
||||
const checkpoint = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
ctx.on('session/flush', () => checkpoint)
|
||||
|
||||
const first = ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'first' },
|
||||
updateTrigger,
|
||||
)
|
||||
await expect(ctx.sessions.appendOutOfBand(
|
||||
session,
|
||||
'test/log-only',
|
||||
{ value: 'overlap' },
|
||||
updateTrigger,
|
||||
)).rejects.toThrow(/out-of-band append in progress/)
|
||||
release()
|
||||
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } 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'
|
||||
import SessionStore, {
|
||||
findLastMessageTurnEnd,
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
@@ -48,6 +54,68 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('finds the latest message-turn outcome past later non-message turns', () => {
|
||||
const session = new Session(SessionId('message-turn-outcome'))
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'before' }],
|
||||
source: { kind: 'plugin', plugin: 'before' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'bounded prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 3,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
source: { kind: 'plugin', plugin: 'after' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
|
||||
})
|
||||
|
||||
it('round-trips the coarse aborted turn outcome', () => {
|
||||
const session = new Session(SessionId('aborted'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
expect(replayed.events).toEqual(session.events)
|
||||
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
})
|
||||
|
||||
it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => {
|
||||
const legacy = [
|
||||
{
|
||||
type: 'turn/start', seq: 0, time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'turn/end', seq: 1, time: 2,
|
||||
data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } },
|
||||
},
|
||||
] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('legacy-aborted'), legacy))
|
||||
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
|
||||
})
|
||||
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
@@ -717,10 +785,11 @@ describe('SessionStore', () => {
|
||||
// may create an unrelated property with the old implementation's name,
|
||||
// but cannot suppress the durable event feed.
|
||||
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]![0]).toBe(session)
|
||||
expect(events[0]![1].type).toBe('user/message')
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[1]![0]).toBe(session)
|
||||
expect(events[1]![1].type).toBe('user/message')
|
||||
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.sessions.list()).toEqual([session])
|
||||
@@ -732,6 +801,7 @@ describe('SessionStore', () => {
|
||||
const a = ctx.sessions.create(SessionId('fixed'))
|
||||
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
|
||||
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
|
||||
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
|
||||
@@ -973,8 +1043,9 @@ describe('SessionStore', () => {
|
||||
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('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events.at(-1)?.type).toBe('user/message')
|
||||
})
|
||||
|
||||
it('contains session/event observer failures after the append commit point', async () => {
|
||||
@@ -1058,6 +1129,8 @@ describe('SessionStore', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'source' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -1077,19 +1150,19 @@ describe('SessionStore', () => {
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'replacement' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
surfaceOp: { op: 'replace', start: 2, end: 2 },
|
||||
sourceEventSeqs: [2],
|
||||
})).toThrow('reject surface candidate')
|
||||
|
||||
expect(session.events).toHaveLength(1)
|
||||
expect(surface.nodes).toEqual([0])
|
||||
expect(session.events).toHaveLength(3)
|
||||
expect(surface.nodes).toEqual([2])
|
||||
expect(surface.replaceGeneration).toBe(0)
|
||||
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'next' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(surface.nodes).toEqual([0, 1])
|
||||
expect(surface.nodes).toEqual([2, 3])
|
||||
expect(surface.replaceGeneration).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function toolResultEvent(
|
||||
seq: number,
|
||||
callId: string,
|
||||
surfaceOp: SurfaceEvent['surfaceOp'] = 'append',
|
||||
sourceEventSeqs?: number[],
|
||||
): SessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
seq,
|
||||
time: seq,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId(callId),
|
||||
content: [{ type: 'text', text: `result ${seq}` }],
|
||||
isError: false,
|
||||
},
|
||||
surfaceOp,
|
||||
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
|
||||
}
|
||||
}
|
||||
|
||||
describe('foldSurface provenance', () => {
|
||||
it('accepts absent or valid provenance and complete replacement coverage', () => {
|
||||
const events = [
|
||||
@@ -94,6 +116,33 @@ describe('foldSurface provenance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('foldSurface tool-result rewrites', () => {
|
||||
it('rejects a replacement spanning multiple current nodes', () => {
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
provenanceEvent(1, undefined),
|
||||
toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]),
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
|
||||
})
|
||||
|
||||
it('rejects a replacement targeting a non-result node', () => {
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
|
||||
})
|
||||
|
||||
it('rejects changes outside tool-result content', () => {
|
||||
const events = [
|
||||
toolResultEvent(0, 'original'),
|
||||
toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/may change only content/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
packages/core/session/tsdown.config.ts
Normal file
25
packages/core/session/tsdown.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user