Merge remote-tracking branch 'origin/master' into codex/ask-user-question
# Conflicts: # packages/README.md
This commit is contained in:
@@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
|
||||
@@ -29,6 +30,7 @@ dsh-session ← dsh-llm, dsh-brand
|
||||
dsh-system-prompt ← dsh-llm
|
||||
dsh-agent ← dsh-llm, dsh-session, dsh-brand
|
||||
dsh-user-interaction ← dsh-agent, dsh-llm
|
||||
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
|
||||
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
|
||||
dsh-tool-ask-user ← dsh-tools, dsh-user-interaction
|
||||
dsh-bash-local ← dsh-bash (BashExecutor impl)
|
||||
@@ -68,6 +70,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
|
||||
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
|
||||
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
|
||||
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |
|
||||
|
||||
11
packages/compact/README.md
Normal file
11
packages/compact/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# compact/ — compaction capability family
|
||||
|
||||
A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. Only the interface tier exists today; the backend and consumer are deferred. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` (deferred) | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `compact/compact/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
56
packages/compact/compact/README.md
Normal file
56
packages/compact/compact/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-compact
|
||||
|
||||
The **compaction seam**: an abstract `CompactService` (`ctx.compact`) defining WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.
|
||||
|
||||
This package is the interface tier of the compaction capability, split so each concern evolves (and swaps) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
|
||||
|
||||
## Service API (`ctx.compact`)
|
||||
|
||||
Both methods are **abstract** — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization).
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. |
|
||||
| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start > end`. |
|
||||
|
||||
Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it.
|
||||
|
||||
## Surface contract
|
||||
|
||||
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
||||
|
||||
1. appends `compact/start` (log-only) — acquires the lock,
|
||||
2. summarizes the range,
|
||||
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count,
|
||||
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**,
|
||||
5. appends `compact/end` (log-only) — releases the lock.
|
||||
|
||||
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
|
||||
|
||||
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
|
||||
|
||||
## Blocking
|
||||
|
||||
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
|
||||
|
||||
## Events
|
||||
|
||||
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`:
|
||||
|
||||
| Event | Payload | On surface? |
|
||||
|---|---|---|
|
||||
| `compact/start` | `{ turn }` | no (log-only) |
|
||||
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | no (log-only) |
|
||||
| `compact/end` | `{ turn, error? }` | no (log-only) |
|
||||
|
||||
## Implementing a backend
|
||||
|
||||
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. See `@deepseek-ai/dsh-compact-basic` for the reference implementation.
|
||||
34
packages/compact/compact/package.json
Normal file
34
packages/compact/compact/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact",
|
||||
"description": "Abstract compaction service seam (ctx.compact) 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-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
112
packages/compact/compact/src/index.ts
Normal file
112
packages/compact/compact/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The compaction service seam (`ctx.compact`): an abstract service defining
|
||||
* WHAT compaction does — decide when to compact, summarize a range of
|
||||
* conversation history into a single surface node — without saying HOW.
|
||||
*
|
||||
* Implementations subclass {@link CompactService}, implement
|
||||
* {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion},
|
||||
* and load as a plugin — registering as `ctx.compact` (one implementation per
|
||||
* context). `@deepseek-ai/dsh-compact-basic` (char/4 estimation + token-budget
|
||||
* retention + `ctx.llm.stream()` summarization) is the first. A tokenizer- or
|
||||
* template-based backend swaps in without touching consumers.
|
||||
*
|
||||
* The split follows the capability-seams RFC — interface (this) /
|
||||
* implementation (`dsh-compact-basic`) / consumer (a `/compact` tool, deferred)
|
||||
* — modeled on the bash trio. Unlike `dsh-bash`, this interface necessarily
|
||||
* depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
|
||||
* a `Session` and its output is the `ContentBlock` vocabulary. That deviation
|
||||
* from the "interface depends only on cordis" guidance is intentional and
|
||||
* recorded in the [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
compact: CompactService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract compaction service. Subclass implement the two abstract methods,
|
||||
* and load the subclass as a plugin — it registers as `ctx.compact` (one
|
||||
* implementation per context; loading a second throws, which is cordis'
|
||||
* standard duplicate-service behavior).
|
||||
*
|
||||
* Both core methods are abstract: the contract states WHAT compaction does,
|
||||
* while the entire strategy — token estimation, retention policy, event
|
||||
* sequencing, summarization — is a HOW decision owned by the implementation.
|
||||
*
|
||||
* Implementations MUST honor:
|
||||
* - **Surface contract**: a successful compaction shadows the compacted surface
|
||||
* nodes with a SINGLE replacement node carrying the summary. Because
|
||||
* `SurfaceEventType` is a closed union, that node is a `user/message` with
|
||||
* `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are
|
||||
* log-only (lock + provenance).
|
||||
* - **Blocking**: no compaction begins while another is in progress for the
|
||||
* same session. The recommended mechanism is the log-recorded lock — append
|
||||
* `compact/start` before the slow work and `compact/end` after (even on
|
||||
* failure) — so the lock is visible to replay and crash recovery.
|
||||
*/
|
||||
export abstract class CompactService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'compact')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check token pressure and compact if the conversation is too large.
|
||||
*
|
||||
* Estimates the current history size (optionally including a system prompt),
|
||||
* and if it exceeds the backend's threshold, compacts an older range via
|
||||
* {@link compactRegion}, keeping recent context intact.
|
||||
*
|
||||
* @param session - the session whose surface may be compacted.
|
||||
* @param systemPrompt - optional system prompt, counted toward the estimate.
|
||||
* @param model - optional summarization model (falls back to backend config).
|
||||
* @param signal - optional cancellation signal. A backend that summarizes via
|
||||
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
||||
* so an abort/dispose tears down the in-flight summarization rather than
|
||||
* leaving an orphaned model call running past the cancellation.
|
||||
* @returns the compaction result, or `null` if no compaction was needed.
|
||||
*/
|
||||
abstract compactIfNeeded(
|
||||
session: Session,
|
||||
systemPrompt?: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
|
||||
/**
|
||||
* Forcibly compact a range of surface nodes into a single summary node.
|
||||
*
|
||||
* `start` and `end` are inclusive seqs of surface nodes to shadow; the backend
|
||||
* summarizes their content and appends a replacement surface node. Used by the
|
||||
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
|
||||
*
|
||||
* @param session - the session whose surface is mutated.
|
||||
* @param start - inclusive seq of the first surface node to compact.
|
||||
* @param end - inclusive seq of the last surface node to compact.
|
||||
* @param model - summarization model.
|
||||
* @param signal - optional cancellation signal. A backend that summarizes via
|
||||
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
||||
* so an abort/dispose tears down the in-flight summarization rather than
|
||||
* leaving an orphaned model call running past the cancellation.
|
||||
* @throws if compaction is already in progress, or if `start`/`end` are not
|
||||
* valid surface nodes, or if `start > end`.
|
||||
*/
|
||||
abstract compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult>
|
||||
}
|
||||
|
||||
export default CompactService
|
||||
57
packages/compact/compact/src/types.ts
Normal file
57
packages/compact/compact/src/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Compaction vocabulary: the result type and the `compact/*` session events.
|
||||
*
|
||||
* Extends {@link SessionEventMap} with `compact/*` event types via declaration
|
||||
* merging. {@link SurfaceEventType} is deliberately NOT extended — `compact/*`
|
||||
* events are log-only markers (lock + provenance); only the five
|
||||
* surface-eligible types can carry `surfaceOp`. The actual surface mutation is
|
||||
* performed by a separate `user/message` event carrying the summary (see the
|
||||
* [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
*
|
||||
* Configuration lives in the backend, not here: the contract states WHAT
|
||||
* compaction produces, while every tunable (context window, thresholds,
|
||||
* retention budget) is a HOW decision owned by the implementation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
|
||||
'compact/start': { turn: number }
|
||||
/**
|
||||
* Provenance record of a completed summarization — log-only, no surfaceOp.
|
||||
* The summary content is in `data.summary`; the actual surface replacement
|
||||
* is performed by a subsequent `user/message` event that shadows the
|
||||
* compacted range.
|
||||
*/
|
||||
'compact/summary': {
|
||||
summary: ContentBlock[]
|
||||
shadowedRange: { start: number; end: number }
|
||||
shadowedSeqs: number[]
|
||||
shadowedTokenCount: number
|
||||
}
|
||||
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
|
||||
'compact/end': { turn: number; error?: string }
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of a successful compaction operation. */
|
||||
export interface CompactionResult {
|
||||
/** The seq of the appended `compact/start` event. */
|
||||
startSeq: number
|
||||
/** The seq of the appended `compact/summary` event. */
|
||||
summarySeq: number
|
||||
/** The seq of the appended `compact/end` event. */
|
||||
endSeq: number
|
||||
/** The summary content blocks produced by the backend. */
|
||||
summary: ContentBlock[]
|
||||
/** The seq range that was shadowed [start, end] inclusive. */
|
||||
shadowedRange: { start: number; end: number }
|
||||
/** The seq numbers of all shadowed surface nodes. */
|
||||
shadowedSeqs: number[]
|
||||
/** Estimated token count of the shadowed content. */
|
||||
shadowedTokenCount: number
|
||||
}
|
||||
107
packages/compact/compact/tests/compact.spec.ts
Normal file
107
packages/compact/compact/tests/compact.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* A trivial concrete CompactService implementing the abstract contract. The
|
||||
* interface package owns no algorithm — these tests exercise the seam itself:
|
||||
* service registration, the abstract method shape, and the `compact/*` event
|
||||
* declaration merge.
|
||||
*/
|
||||
class StubCompactService extends CompactService {
|
||||
/** Records the signal handed to the most recent call, to prove it threads through. */
|
||||
lastSignal: AbortSignal | undefined
|
||||
|
||||
override async compactIfNeeded(
|
||||
_session: Session,
|
||||
_systemPrompt?: string,
|
||||
_model?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
this.lastSignal = signal
|
||||
return null
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
_model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
this.lastSignal = signal
|
||||
// Minimal stub honoring the lock + log-only event contract.
|
||||
const startEvent = session.append('compact/start', { turn: 0 })
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary: [{ type: 'text', text: 'stub' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedTokenCount: 0,
|
||||
})
|
||||
const endEvent = session.append('compact/end', { turn: 0 })
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary: [{ type: 'text', text: 'stub' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedTokenCount: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('CompactService seam', () => {
|
||||
it('registers as ctx.compact', () => {
|
||||
const ctx = new Context()
|
||||
void new StubCompactService(ctx)
|
||||
expect(ctx.compact).toBeDefined()
|
||||
expect(ctx.compact).toBeInstanceOf(StubCompactService)
|
||||
})
|
||||
|
||||
it('disposing the fiber unregisters ctx.compact (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(StubCompactService)
|
||||
expect(ctx.compact).toBeInstanceOf(StubCompactService)
|
||||
await fiber.dispose()
|
||||
expect(ctx.compact).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes the abstract contract methods', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
expect(await svc.compactIfNeeded(new Session(SessionId('s')))).toBeNull()
|
||||
})
|
||||
|
||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
|
||||
const result = await svc.compactRegion(session, 0, 0, 'm')
|
||||
|
||||
const startEvent = session.events.find(e => e.type === 'compact/start')
|
||||
expect(startEvent).toBeDefined()
|
||||
// Log-only: the compiler rejects surfaceOp on compact/* (not a SurfaceEventType);
|
||||
// verify the runtime value is absent.
|
||||
const raw = startEvent as unknown as { surfaceOp?: unknown }
|
||||
expect(raw.surfaceOp).toBeUndefined()
|
||||
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
|
||||
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
|
||||
})
|
||||
|
||||
it('threads the cancellation signal through to the backend', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await svc.compactRegion(session, 0, 0, 'm', controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
|
||||
await svc.compactIfNeeded(session, undefined, undefined, controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
})
|
||||
})
|
||||
24
packages/compact/compact/tsconfig.json
Normal file
24
packages/compact/compact/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": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
// status can be `running` with no turn open): the context/message is
|
||||
// turn-enclosed by that turn, so append it directly.
|
||||
this.session.append('context/message', { content, source })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
@@ -143,7 +143,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// can't happen for our fixed trigger — no turn was opened and none is owed.)
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. Contain a throwing
|
||||
// turn/end listener: Session.append pushes before notifying, so a throw
|
||||
|
||||
@@ -375,7 +375,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
|
||||
// while appending these is caught below and the turn is still closed.
|
||||
for (const message of queued) {
|
||||
session.append('user/message', { content: message.content, source: message.source })
|
||||
session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
ctx.emit('agent/turn-start', agent, turn)
|
||||
|
||||
@@ -543,7 +543,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source })
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
||||
}
|
||||
return messages.length > 0
|
||||
@@ -580,10 +580,12 @@ async function runStep(
|
||||
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
for await (const chunk of ctx.llm.stream(request)) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
session.append('assistant/chunk', { turn, step, chunk })
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
@@ -606,7 +608,13 @@ async function runStep(
|
||||
// deriveMessages(), so hosting usage on it never injects a spurious assistant
|
||||
// turn into derived history.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) })
|
||||
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
|
||||
// never empty here — pass the provenance unconditionally.
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
@@ -622,8 +630,15 @@ async function runStep(
|
||||
// streamed nothing) records no assistant/message — an empty-content message
|
||||
// exists only to host usage, and deriveMessages() skips it either way, so
|
||||
// appending one with no usage would be a pure trace-only row.
|
||||
//
|
||||
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
|
||||
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) })
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
@@ -633,7 +648,7 @@ async function runStep(
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
let parsedArguments: unknown
|
||||
try {
|
||||
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
|
||||
@@ -659,7 +674,7 @@ async function runStep(
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
})
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
|
||||
@@ -1018,3 +1018,32 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
|
||||
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
|
||||
// An empty stream yields zero assistant/chunk events (finish defaults to
|
||||
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
|
||||
// the content-or-usage guard fires and an assistant/message is appended. Its
|
||||
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
|
||||
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
|
||||
const adapter = new MockAdapter([[]])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(Invariants)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text: 'injected' }],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
|
||||
expect(recorded.type).toBe('assistant/message')
|
||||
expect(recorded.surfaceOp).toBe('append')
|
||||
expect(recorded.sourceEventSeqs).toBeUndefined()
|
||||
// The injected content reaches derived history.
|
||||
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dsh-session
|
||||
|
||||
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it.
|
||||
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (a linked list of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
|
||||
|
||||
## Service: `SessionStore` (ctx key: `sessions`)
|
||||
|
||||
@@ -34,14 +34,18 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
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.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from). It is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makes `opts` mandatory when `type` is a specific `SurfaceEventType` literal, AND `append` **throws** at runtime if a surface-eligible event arrives with no `surfaceOp` — covering the case where `type` widens to the `SessionEventType` union (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish from `deriveMessages()`.
|
||||
- `session.deriveMessages(): Message[]` — derive the LLM message history by walking the surface linked list (skipping non-surface events like chunks and boundaries; a `replace` shadows the nodes it covers). The surface is the single source of derived history — there is no raw-log fallback.
|
||||
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change.
|
||||
- `session.events`, `session.seq`, `session.id`
|
||||
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
### Surface types
|
||||
|
||||
- `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).
|
||||
- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
@@ -51,11 +55,21 @@ Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
|
||||
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction marker).
|
||||
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
|
||||
- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Session branching/tree** (pi-style entry tree) — defered unless needed beyond seed-based forking.
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking.
|
||||
|
||||
@@ -10,12 +10,15 @@ import { Context, Service } from 'cordis'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts'
|
||||
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { isJsonValue } from './json.ts'
|
||||
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { isJsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceNode } from './surface.ts'
|
||||
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -76,6 +79,21 @@ export class Session {
|
||||
/** Set by the store so appends are observable; undefined when detached. */
|
||||
onAppend: ((event: SessionEvent) => void) | undefined
|
||||
|
||||
/**
|
||||
* Derived surface — a cached linked list of message-producing events.
|
||||
* Lazily rebuilt from `surfaceOp` markers in the log; processes only new
|
||||
* events (delta) on each access — the log is append-only, so prior events
|
||||
* never change.
|
||||
* `append`. Undefined until first accessed (including after fork/seed).
|
||||
*/
|
||||
private _surface: SurfaceManager | undefined
|
||||
|
||||
/** The surface linked list over this session's event log. */
|
||||
get surface(): SurfaceManager {
|
||||
if (!this._surface) this._surface = new SurfaceManager(this.log)
|
||||
return this._surface
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable creation metadata (format version, cwd, lineage). Supplied by
|
||||
* the store via `ctx.sessions.create()`. When a `Session` is constructed
|
||||
@@ -102,6 +120,16 @@ export class Session {
|
||||
if (!isJsonValue(event.data)) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
|
||||
}
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
|
||||
// the sole source of derived history, so a marker-less message event
|
||||
// would load fine yet vanish from deriveMessages(). `append` enforces
|
||||
// this at compile time via its typed overload; a seed arrives as raw
|
||||
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
|
||||
// runtime here rather than silently resuming with empty history.
|
||||
if (isSurfaceEligibleType(event.type)
|
||||
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
|
||||
}
|
||||
})
|
||||
// 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
|
||||
@@ -129,6 +157,15 @@ export class Session {
|
||||
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
|
||||
* asynchronously.
|
||||
*
|
||||
* @param type - The event type (key of {@link SessionEventMap}).
|
||||
* @param data - The event payload; must be JSON-serializable.
|
||||
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
|
||||
* the surface linked list; `sourceEventSeqs` records provenance (the seq
|
||||
* numbers of events this one derives from). REQUIRED for
|
||||
* {@link SurfaceEventType} events (every message-producing event must
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
@@ -137,10 +174,26 @@ export class Session {
|
||||
* 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> {
|
||||
append<T extends SessionEventType>(
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
|
||||
): SessionEvent<T> {
|
||||
if (!isJsonValue(data)) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
// 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
|
||||
@@ -150,14 +203,38 @@ export class Session {
|
||||
// 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)
|
||||
//
|
||||
// Surface metadata is snapshot separately: sourceEventSeqs (number[] —
|
||||
// primitives, so array spread is a complete copy) and surfaceOp (a string
|
||||
// primitive, or cloned if it's a replace object).
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data was validated above, and
|
||||
// surface metadata was snapshot from primitive/clone-safe values.
|
||||
const event = {
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: structuredClone(data),
|
||||
...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {},
|
||||
...surfaceOpts?.surfaceOp !== undefined ? {
|
||||
surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
|
||||
} : {},
|
||||
} as unknown as SessionEvent<T>
|
||||
this.log.push(event as unknown as SessionEvent)
|
||||
this.onAppend?.(event as unknown as SessionEvent)
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the LLM message history from the event log.
|
||||
* Derive the LLM message history by walking the session surface — the linked
|
||||
* list of message-producing events maintained by `surfaceOp` markers. The
|
||||
* surface is the single source of derived history: every message-producing
|
||||
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
|
||||
* turn boundary) is correctly absent, and a compaction `replace` deletes the
|
||||
* shadowed nodes from the derivation.
|
||||
*
|
||||
* - `user/message` → user message
|
||||
* - `assistant/message` → assistant message (chunks are skipped — they are
|
||||
@@ -179,45 +256,60 @@ export class Session {
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
for (const node of this.surface.nodes) {
|
||||
// Surface nodes are built from this.log — node.seq is always a valid
|
||||
// index by construction. The non-null assertion expresses that invariant.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const msg = this._deriveOneMessage(this.log[node.seq]!)
|
||||
// A surface node is one of the five message-producing types, but an
|
||||
// empty-content assistant/message (a max-tokens step that hosts only
|
||||
// usage) derives to null and must not enter the transcript.
|
||||
if (msg) messages.push(msg)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a single LLM message from one surface event, or null if it produces
|
||||
* no message (an empty-content assistant/message that exists only to host
|
||||
* usage).
|
||||
*/
|
||||
private _deriveOneMessage(event: SessionEvent): Message | null {
|
||||
// Intentionally non-exhaustive: only message-producing events derive
|
||||
// history; turn/step boundaries, chunks, usage, and errors are
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
return { role: 'user', content: structuredClone(event.data.content) }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Skip an empty-content assistant/message: it exists only to host a
|
||||
// max-tokens step's usage and must not inject a content-less assistant
|
||||
// turn into the provider transcript.
|
||||
if (event.data.content.length === 0) return null
|
||||
return { role: 'assistant', content: structuredClone(event.data.content) }
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
|
||||
}
|
||||
/* v8 ignore next 2 -- unreachable: only surface nodes (the 5 message-producing types) reach here */
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,12 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
// 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 }>()
|
||||
// Track pending tool calls with their callSeq (the seq of the `tool/call`
|
||||
// event, captured for surface sourceEventSeqs provenance on the synthetic
|
||||
// result). CallSeq is set from `tool/call` events; the assistant/message
|
||||
// block scan may register a call first (it appears earlier in the log), and
|
||||
// the later `tool/call` event fills in the seq.
|
||||
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
@@ -89,6 +94,18 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
|
||||
}
|
||||
break
|
||||
case 'tool/call':
|
||||
// Capture the tool/call event seq for surface provenance on the
|
||||
// synthesized tool/result. The entry may already exist (registered by
|
||||
// the assistant/message above) or may be new (if the assistant/message
|
||||
// came from a prior step that was already closed).
|
||||
{
|
||||
const entry = pendingCalls.get(event.data.callId)
|
||||
if (entry) {
|
||||
entry.callSeq = event.seq
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool/result':
|
||||
pendingCalls.delete(event.data.callId)
|
||||
break
|
||||
@@ -114,7 +131,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
// 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) {
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
seq: seq++,
|
||||
@@ -127,6 +144,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
159
packages/core/session/src/surface.ts
Normal file
159
packages/core/session/src/surface.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Surface layer on top of the session event log: a derived, cached linked list
|
||||
* of events that produce LLM messages. Rebuilt deterministically from
|
||||
* `surfaceOp` markers in the log — the log is the source of truth; the surface
|
||||
* is a view.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/**
|
||||
* The set of event type strings that are eligible for the surface linked list.
|
||||
* Mirrors the {@link SurfaceEventType} union; kept as a runtime set so the
|
||||
* type guard can check membership without a chain of string comparisons.
|
||||
*/
|
||||
const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
'user/message',
|
||||
'assistant/message',
|
||||
'tool/result',
|
||||
'context/message',
|
||||
'steering/message',
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether an event's `type` is surface-eligible (one of the five
|
||||
* message-producing {@link SurfaceEventType} values). This is the TYPE check
|
||||
* only — it does NOT require `surfaceOp` to be present. Use it to detect a
|
||||
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
|
||||
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
|
||||
* {@link SurfaceEvent} with `surfaceOp` present.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
|
||||
* event's `type` is surface-eligible AND that `surfaceOp` is present.
|
||||
* The narrowed type has mandatory {@link SurfaceOp}.
|
||||
*/
|
||||
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
if (!SURFACE_EVENT_TYPES.has(event.type)) return false
|
||||
// surfaceOp is optional on SessionEvent (even for surface-eligible types)
|
||||
// but mandatory on SurfaceEvent — this check is the narrowing gate.
|
||||
if ((event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** One node in the surface linked list. */
|
||||
export interface SurfaceNode {
|
||||
/** The event seq of this surface node. */
|
||||
seq: number
|
||||
/** The previous surface node's seq, or null if this is the head. */
|
||||
prev: number | null
|
||||
/** The next surface node's seq, or null if this is the tail. */
|
||||
next: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains a cached linked list of surface nodes, rebuilt lazily from
|
||||
* `surfaceOp` markers in the event log. Because the log is append-only, it
|
||||
* processes only the delta since the last rebuild — new events are folded
|
||||
* into the existing surface in O(new events) rather than rescanning the
|
||||
* whole log.
|
||||
*/
|
||||
export class SurfaceManager {
|
||||
/** Surface nodes in linked-list order (head to tail). Empty until first access. */
|
||||
private _nodes: SurfaceNode[] = []
|
||||
/** Map from event seq → node. */
|
||||
private _nodeBySeq = new Map<number, SurfaceNode>()
|
||||
/** The last processed seq. -1 forces a full rebuild on first access. */
|
||||
private _lastProcessedSeq = -1
|
||||
|
||||
constructor(private log: readonly SessionEvent[]) {}
|
||||
|
||||
/**
|
||||
* Reset to unprocessed state. Call after the log has been replaced
|
||||
* wholesale (e.g. after Session seed). Not needed for normal appends —
|
||||
* those are picked up incrementally.
|
||||
*/
|
||||
invalidate(): void {
|
||||
this._lastProcessedSeq = -1
|
||||
this._nodes = []
|
||||
this._nodeBySeq.clear()
|
||||
}
|
||||
|
||||
/** The surface nodes in linked-list order (head to tail). */
|
||||
get nodes(): readonly SurfaceNode[] {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
return this._nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Process events from `_lastProcessedSeq + 1` through the end of the log,
|
||||
* folding new surface markers into the existing linked list.
|
||||
*/
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// Index is bounded by i < this.log.length — never undefined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = this.log[i]!
|
||||
// isSurfaceEvent checks event.type first (is it a surface-eligible type?)
|
||||
// then checks that surfaceOp is present. Only after both pass do we treat
|
||||
// it as a SurfaceEvent with mandatory surfaceOp.
|
||||
if (!isSurfaceEvent(event)) continue
|
||||
|
||||
if (event.surfaceOp === 'append') {
|
||||
const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined
|
||||
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
|
||||
if (tail) tail.next = event.seq
|
||||
this._nodes.push(node)
|
||||
this._nodeBySeq.set(event.seq, node)
|
||||
} else {
|
||||
this._replace(event.seq, event.surfaceOp)
|
||||
}
|
||||
}
|
||||
this._lastProcessedSeq = this.log.length - 1
|
||||
}
|
||||
|
||||
/** Apply a replace operation to the in-progress surface. */
|
||||
private _replace(
|
||||
newSeq: number,
|
||||
op: Extract<SurfaceOp, { op: 'replace' }>,
|
||||
): void {
|
||||
const startNode = this._nodeBySeq.get(op.start)
|
||||
if (!startNode) {
|
||||
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
|
||||
}
|
||||
const endNode = this._nodeBySeq.get(op.end)
|
||||
if (!endNode) {
|
||||
throw new Error(`surface replace: end seq ${op.end} not found in surface`)
|
||||
}
|
||||
const startIdx = this._nodes.indexOf(startNode)
|
||||
const endIdx = this._nodes.indexOf(endNode)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
|
||||
}
|
||||
|
||||
// Remove shadowed nodes from `[startIdx, endIdx]` inclusive.
|
||||
const count = endIdx - startIdx + 1
|
||||
const removed = this._nodes.splice(startIdx, count)
|
||||
for (const r of removed) this._nodeBySeq.delete(r.seq)
|
||||
|
||||
// Insert the new node where the removed range was.
|
||||
const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined
|
||||
const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined
|
||||
|
||||
const newNode: SurfaceNode = {
|
||||
seq: newSeq,
|
||||
prev: prevNode?.seq ?? null,
|
||||
next: nextNode?.seq ?? null,
|
||||
}
|
||||
if (prevNode) prevNode.next = newSeq
|
||||
if (nextNode) nextNode.prev = newSeq
|
||||
this._nodes.splice(startIdx, 0, newNode)
|
||||
this._nodeBySeq.set(newSeq, newNode)
|
||||
}
|
||||
}
|
||||
@@ -198,11 +198,74 @@ export interface SessionEventMap {
|
||||
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/**
|
||||
* The subset of {@link SessionEventType} values whose events produce LLM
|
||||
* messages and are eligible to appear on the surface linked list. Only these
|
||||
* event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
|
||||
*/
|
||||
export type SurfaceEventType =
|
||||
| 'user/message'
|
||||
| 'assistant/message'
|
||||
| 'tool/result'
|
||||
| 'context/message'
|
||||
| 'steering/message'
|
||||
|
||||
/**
|
||||
* A {@link SessionEvent} that is **on** the surface linked list — its
|
||||
* `surfaceOp` is guaranteed present (mandatory), narrowed from a
|
||||
* surface-eligible {@link SessionEvent} by checking both `type` and
|
||||
* `surfaceOp` at runtime.
|
||||
*
|
||||
* Use the `isSurfaceEvent` type guard (in `surface.ts`) to narrow a
|
||||
* `SessionEvent` to this type.
|
||||
*/
|
||||
export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: SurfaceOp }
|
||||
|
||||
/**
|
||||
* How a session event entered the surface linked list. Only valid on
|
||||
* {@link SurfaceEventType} events.
|
||||
*
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
|
||||
* messages.
|
||||
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
|
||||
* (inclusive) through `end` (inclusive) with this node. Both must exist as
|
||||
* surface nodes in the current surface. `start === end` replaces a single
|
||||
* node. The node's {@link SessionEvent.sourceEventSeqs} must include every
|
||||
* shadowed surface node. Used by compaction and possible other manipulations.
|
||||
*/
|
||||
export type SurfaceOp =
|
||||
| 'append'
|
||||
| { op: 'replace'; start: number; end: number }
|
||||
|
||||
/**
|
||||
* Surface metadata passed to {@link Session.append}.
|
||||
* `surfaceOp` controls how the event enters the surface linked list;
|
||||
* `sourceEventSeqs` records the seq numbers of events that are provenance
|
||||
* sources of this one (e.g. the `assistant/chunk` seqs behind an
|
||||
* `assistant/message`, or the shadowed nodes behind a compaction replacement).
|
||||
*
|
||||
* Required for {@link SurfaceEventType} events — every message-producing event
|
||||
* MUST declare how it enters the surface, because the surface is the sole
|
||||
* source of derived history. Non-surface event types (`turn/start`,
|
||||
* `assistant/chunk`, `error`, …) cannot carry surface metadata.
|
||||
*/
|
||||
export interface SurfaceIntent {
|
||||
surfaceOp: SurfaceOp
|
||||
sourceEventSeqs?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One immutable entry in the session log.
|
||||
*
|
||||
* A proper discriminated union over `type` (not independent `type`/`data`
|
||||
* unions), so `switch (event.type)` narrows `event.data` without casts.
|
||||
*
|
||||
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
|
||||
* they only exist on {@link SurfaceEventType} variants (`user/message`,
|
||||
* `assistant/message`, `tool/result`, `context/message`, `steering/message`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
*/
|
||||
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
[K in SessionEventType]: {
|
||||
@@ -212,5 +275,14 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
}
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of events that are provenance sources of this event
|
||||
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
|
||||
* or the surface nodes shadowed by a compaction marker).
|
||||
*/
|
||||
sourceEventSeqs?: number[]
|
||||
/** How this event entered the surface; absent for non-surface events. */
|
||||
surfaceOp?: SurfaceOp
|
||||
} : object)
|
||||
}[T]
|
||||
|
||||
@@ -11,22 +11,29 @@ 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'
|
||||
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
type Appendable = { [T in SessionEventType]: { type: T; data: SessionEventMap[T] } }[SessionEventType]
|
||||
// An appendable event: its type/data plus, for surface-eligible types, the
|
||||
// explicit surface intent the generator declares (mirroring how a real caller
|
||||
// passes it). The intent is part of the generated fixture, NOT synthesized by
|
||||
// `build`, so each arbitrary states the marker it produces.
|
||||
type Appendable = {
|
||||
[T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
|
||||
}[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).
|
||||
// A message-producing event (these DO affect derived history). Each carries an
|
||||
// explicit `surfaceOp: 'append'` intent — the marker the real loop passes.
|
||||
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 } } })),
|
||||
textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } }, intent: { surfaceOp: 'append' } })),
|
||||
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content }, intent: { surfaceOp: 'append' } })),
|
||||
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, usage: { inputTokens: 1, outputTokens: 1 } }, intent: { surfaceOp: 'append' } })),
|
||||
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 } })),
|
||||
.map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError }, intent: { surfaceOp: 'append' } })),
|
||||
)
|
||||
|
||||
// A non-message event (trace/replay data — must NOT affect derived history).
|
||||
@@ -44,7 +51,11 @@ 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)
|
||||
for (const e of events) {
|
||||
// Forward the generated intent verbatim; non-surface events carry none.
|
||||
if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
|
||||
else session.append(e.type, e.data)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { interruptedTurnClosers } from '../src/index.ts'
|
||||
import type { SessionEvent } from '../src/index.ts'
|
||||
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for the crash-recovery closer synthesis. The persistence
|
||||
@@ -137,4 +137,36 @@ describe('interruptedTurnClosers', () => {
|
||||
const result = closers[0]!
|
||||
expect(result.type === 'tool/result' && result.data.callId).toBe('call-b')
|
||||
})
|
||||
|
||||
it('synthesized tool/result carries surfaceOp and sourceEventSeqs when tool/call was logged', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
|
||||
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
|
||||
] } },
|
||||
{ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-1'), name: 'bash', arguments: '{}' } },
|
||||
]
|
||||
const closers = interruptedTurnClosers(events)
|
||||
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
|
||||
const result = closers[0]!
|
||||
expect((result as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3])
|
||||
})
|
||||
|
||||
it('handles tool/call without a matching assistant/message entry gracefully', () => {
|
||||
// A tool/call event exists in the log but no assistant/message registered
|
||||
// the callId in pendingCalls (e.g., a plugin appended it directly, or the
|
||||
// assistant/message from a prior step didn't have this call). The repair
|
||||
// should still close the turn — it just won't synthesize a result for this
|
||||
// call (there's nothing to answer).
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'tool/call', seq: 2, time: 2, data: { turn: 1, step: 1, callId: CallId('orphan'), name: 'bash', arguments: '{}' } },
|
||||
]
|
||||
const closers = interruptedTurnClosers(events)
|
||||
// No pending calls → no synthetic tool/result, just step/end + turn/end.
|
||||
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,13 @@ 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'
|
||||
import type { SessionEventType } 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('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
||||
session.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
@@ -15,8 +16,8 @@ describe('Session', () => {
|
||||
{ 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 })
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const messages = session.deriveMessages()
|
||||
@@ -44,12 +45,12 @@ describe('Session', () => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
})
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 'focus on tests' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const [contextMessage, steeringMessage] = session.deriveMessages()
|
||||
expect(contextMessage!.role).toBe('user')
|
||||
@@ -60,8 +61,8 @@ describe('Session', () => {
|
||||
|
||||
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' }] })
|
||||
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
@@ -70,11 +71,11 @@ describe('Session', () => {
|
||||
|
||||
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('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'tool out' }], isError: false,
|
||||
})
|
||||
}, { surfaceOp: 'append' })
|
||||
const before = structuredClone(session.events)
|
||||
|
||||
// A request middleware / adapter mutates the messages it was handed.
|
||||
@@ -95,7 +96,7 @@ describe('Session', () => {
|
||||
|
||||
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)
|
||||
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
|
||||
expect(bad(1n)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
|
||||
@@ -120,9 +121,24 @@ describe('Session', () => {
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
||||
const session = new Session(SessionId('s5b'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// The typed overload makes surfaceOp mandatory only when the type argument is
|
||||
// a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it
|
||||
// to the SessionEventType union, where the conditional rest collapses to
|
||||
// optional — the exact shape `for (const e of log) append(e.type, e.data)`
|
||||
// produces. Reproduce that here and assert the runtime guard rejects it.
|
||||
const widenedType = 'user/message' as SessionEventType
|
||||
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
.toThrow(/surface-eligible and requires a surfaceOp marker/)
|
||||
// The rejected append never entered the log (only turn/start is present).
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
|
||||
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.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -143,10 +159,23 @@ describe('Session', () => {
|
||||
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
|
||||
// A surface-eligible event (user/message) with no surfaceOp would load fine
|
||||
// but vanish from deriveMessages() (the surface is the sole derivation path),
|
||||
// so a resume/fork would silently lose history. append() forbids this at
|
||||
// compile time; a raw seed must be rejected at runtime to match.
|
||||
const markerlessSeed = [
|
||||
{ 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[]
|
||||
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/surface-eligible but carries no surfaceOp/)
|
||||
})
|
||||
|
||||
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: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } }, surfaceOp: 'append' 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)
|
||||
@@ -156,7 +185,7 @@ describe('Session', () => {
|
||||
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: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }, surfaceOp: 'append' 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)
|
||||
@@ -174,7 +203,7 @@ describe('Session', () => {
|
||||
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)
|
||||
const event = session.append('user/message', data, { surfaceOp: 'append' })
|
||||
// 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'
|
||||
@@ -201,7 +230,7 @@ describe('SessionStore', () => {
|
||||
const session = ctx.sessions.create()
|
||||
expect(created).toEqual([session])
|
||||
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], 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')
|
||||
@@ -216,7 +245,7 @@ describe('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' } })
|
||||
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())
|
||||
})
|
||||
@@ -309,7 +338,7 @@ describe('SessionStore', () => {
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(observed).toBe(0)
|
||||
})
|
||||
|
||||
@@ -332,7 +361,7 @@ 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('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
319
packages/core/session/tests/surface.spec.ts
Normal file
319
packages/core/session/tests/surface.spec.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
function surfaceSession(): Session {
|
||||
const s = new Session(SessionId('ss'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('rebuilds a linked list from surfaceOp: append markers', () => {
|
||||
const s = surfaceSession()
|
||||
const nodes = s.surface.nodes
|
||||
// Only the user/message and assistant/message carry surfaceOp: 'append'.
|
||||
// The turn boundaries do not have surface markers.
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes[0]!.seq).toBe(1) // user/message (turn/start is seq 0)
|
||||
expect(nodes[0]!.prev).toBeNull()
|
||||
expect(nodes[0]!.next).toBe(2) // assistant/message (seq 2)
|
||||
expect(nodes[1]!.seq).toBe(2)
|
||||
expect(nodes[1]!.prev).toBe(1)
|
||||
expect(nodes[1]!.next).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidate resets to full rebuild', () => {
|
||||
const s = surfaceSession()
|
||||
expect(s.surface.nodes.length).toBe(2)
|
||||
// After invalidate, the surface should rebuild from scratch on next access.
|
||||
;(s.surface).invalidate()
|
||||
expect(s.surface.nodes.length).toBe(2) // same result, but rebuilt
|
||||
})
|
||||
|
||||
it('empty surface yields empty nodes', () => {
|
||||
const s = new Session(SessionId('empty'))
|
||||
// Only turn boundaries, no surface nodes.
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(s.surface.nodes.length).toBe(0)
|
||||
// deriveMessages returns empty array
|
||||
expect(s.deriveMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('picks up new events incrementally (delta processing)', () => {
|
||||
const s = surfaceSession()
|
||||
expect(s.surface.nodes.length).toBe(2)
|
||||
// Append another surface node
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
expect(s.surface.nodes.length).toBe(3)
|
||||
expect(s.surface.nodes[2]!.seq).toBe(4) // seq 4: after turn/end at seq 3
|
||||
expect(s.surface.nodes[2]!.prev).toBe(2)
|
||||
expect(s.surface.nodes[1]!.next).toBe(4)
|
||||
})
|
||||
|
||||
it('replays identically from a seeded log with surface markers', () => {
|
||||
const original = surfaceSession()
|
||||
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
const replayed = new Session(SessionId('replay'), [...original.events])
|
||||
// Surface rebuilds from the seeded log's markers.
|
||||
expect(replayed.surface.nodes.map(n => n.seq)).toEqual([1, 2, 4])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
})
|
||||
|
||||
it('rebuild with replace operation splices out shadowed nodes', () => {
|
||||
const s = surfaceSession()
|
||||
// seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end
|
||||
// Surface nodes: seq 1 (user), seq 2 (assistant).
|
||||
// Replace both with a compaction marker. Both 1 and 2 are valid surface seqs.
|
||||
s.append('assistant/message',
|
||||
{ turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
|
||||
)
|
||||
// Now the surface should have just the compaction node.
|
||||
expect(s.surface.nodes.length).toBe(1)
|
||||
expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
expect(s.surface.nodes[0]!.next).toBeNull()
|
||||
})
|
||||
|
||||
it('replace with both ends at real nodes splices only the range', () => {
|
||||
const s = new Session(SessionId('range'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// Replace seq 0 through 1 inclusive: shadow a and b, keep c.
|
||||
s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
|
||||
) // seq 3
|
||||
expect(s.surface.nodes.map(n => n.seq)).toEqual([3, 2])
|
||||
// Links: 3 ↔ 2
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
expect(s.surface.nodes[0]!.next).toBe(2)
|
||||
expect(s.surface.nodes[1]!.prev).toBe(3)
|
||||
expect(s.surface.nodes[1]!.next).toBeNull()
|
||||
})
|
||||
|
||||
it('single-node replacement (start === end)', () => {
|
||||
const s = new Session(SessionId('single'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
// Replace only seq 1 (single node).
|
||||
s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
|
||||
) // seq 2
|
||||
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 2])
|
||||
expect(s.surface.nodes[0]!.next).toBe(2)
|
||||
expect(s.surface.nodes[1]!.prev).toBe(0)
|
||||
})
|
||||
|
||||
it('throws when replace start is not found', () => {
|
||||
const s = new Session(SessionId('bad-start'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
|
||||
})
|
||||
|
||||
it('throws when replace end is not found', () => {
|
||||
const s = new Session(SessionId('bad-end'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
|
||||
})
|
||||
|
||||
it('throws when start is after end', () => {
|
||||
const s = new Session(SessionId('reversed'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
// start=1, end=0 would be reversed order.
|
||||
s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
|
||||
})
|
||||
|
||||
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
|
||||
const s = new Session(SessionId('immutable'))
|
||||
const sources = [10, 20]
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
|
||||
// Mutate caller's array after append.
|
||||
sources.push(30)
|
||||
sources[0] = 99
|
||||
const logged = s.events[0]! as SurfaceEvent
|
||||
expect(logged.sourceEventSeqs).toEqual([10, 20])
|
||||
})
|
||||
|
||||
it('replace starting at non-head position links to previous node correctly', () => {
|
||||
const s = new Session(SessionId('mid-replace'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
|
||||
s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
|
||||
) // seq 3
|
||||
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 3, 2])
|
||||
// Links: 0 → 3 → 2
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
expect(s.surface.nodes[0]!.next).toBe(3)
|
||||
expect(s.surface.nodes[1]!.prev).toBe(0)
|
||||
expect(s.surface.nodes[1]!.next).toBe(2)
|
||||
expect(s.surface.nodes[2]!.prev).toBe(3)
|
||||
expect(s.surface.nodes[2]!.next).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
|
||||
const s = new Session(SessionId('immutable-op'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const op = { op: 'replace' as const, start: 0, end: 0 }
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
|
||||
// Mutate caller's object after append.
|
||||
op.start = 99
|
||||
const logged = s.events[1]! as SurfaceEvent
|
||||
expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveMessages with surface', () => {
|
||||
it('uses the surface path when surface markers are present', () => {
|
||||
const s = surfaceSession()
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.role).toBe('user')
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
|
||||
expect(messages[1]!.role).toBe('assistant')
|
||||
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
|
||||
})
|
||||
|
||||
it('surface path skips non-surface events (chunks, boundaries)', () => {
|
||||
const s = new Session(SessionId('filter'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
||||
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Chunks and boundaries are NOT in the surface, so only 2 messages.
|
||||
expect(s.deriveMessages()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
|
||||
const s = new Session(SessionId('compacted'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
|
||||
// Only the compaction node is visible.
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
|
||||
})
|
||||
|
||||
it('context/message and steering/message appear on surface', () => {
|
||||
const s = new Session(SessionId('ctx'))
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session.append surface opts', () => {
|
||||
it('records sourceEventSeqs and surfaceOp on the event', () => {
|
||||
const s = new Session(SessionId('opts'))
|
||||
const event = s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
|
||||
)
|
||||
expect(event.sourceEventSeqs).toEqual([3, 5, 7])
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
// The logged event matches the returned event.
|
||||
expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
|
||||
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
|
||||
// An empty-content assistant/message is surface-eligible (it can host usage)
|
||||
// but _deriveOneMessage returns null for it, so the surface derivation path's
|
||||
// null-check is exercised — the node is on the surface yet produces no message.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [] }, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const s = new Session(SessionId('nomessage'), seed)
|
||||
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
|
||||
expect(s.deriveMessages()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a non-surface event carries no surface fields', () => {
|
||||
const s = new Session(SessionId('noopts'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
|
||||
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaceOp primitives are not cloned (they are immutable)', () => {
|
||||
const s = new Session(SessionId('prim'))
|
||||
const event = s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
|
||||
// The string 'append' is a primitive — identity-preserving is fine.
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface type guards', () => {
|
||||
it('isSurfaceEligibleType is true only for message-producing types', () => {
|
||||
expect(isSurfaceEligibleType('user/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('tool/result')).toBe(true)
|
||||
expect(isSurfaceEligibleType('context/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('steering/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('turn/start')).toBe(false)
|
||||
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
|
||||
})
|
||||
|
||||
it('isSurfaceEvent narrows a fully-formed surface event', () => {
|
||||
const s = surfaceSession()
|
||||
const userMessage = s.events.find(e => e.type === 'user/message')!
|
||||
expect(isSurfaceEvent(userMessage)).toBe(true)
|
||||
})
|
||||
|
||||
it('isSurfaceEvent rejects a non-surface-eligible type', () => {
|
||||
const s = surfaceSession()
|
||||
const turnStart = s.events.find(e => e.type === 'turn/start')!
|
||||
expect(isSurfaceEvent(turnStart)).toBe(false)
|
||||
})
|
||||
|
||||
it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
|
||||
// A surface-eligible type whose mandatory surfaceOp is absent — the state a
|
||||
// seed/load log can carry before the marker is validated. surfaceOp is
|
||||
// optional on SessionEvent, so this is a representable runtime value.
|
||||
const markerless: SessionEvent = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 0,
|
||||
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
|
||||
}
|
||||
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
|
||||
expect(isSurfaceEvent(markerless)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
let root: string
|
||||
@@ -121,7 +121,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
|
||||
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
|
||||
{ type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] } },
|
||||
{ type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] }, surfaceOp: 'append', sourceEventSeqs: [2, 3] },
|
||||
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
@@ -276,8 +276,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
|
||||
|
||||
const a = ctx.sessions.create(SessionId('sa'))
|
||||
const b = ctx.sessions.create(SessionId('sb'))
|
||||
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } })
|
||||
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } })
|
||||
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', a)
|
||||
@@ -452,7 +452,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
// Session A materializes a log under id "reuse".
|
||||
const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
|
||||
for (const e of oneTurnLog()) a.append(e.type, e.data)
|
||||
appendLog(a, oneTurnLog())
|
||||
}, { inject: ['sessions'] }))
|
||||
// Drain A, then dispose ITS fiber (the live session A is gone) while the
|
||||
// backend stays loaded.
|
||||
@@ -647,7 +647,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
const session = ctx2.sessions.create(SessionId('flush-fail'))
|
||||
// A full turn lands in the write-behind buffer.
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Make the durable materialize fail on the next flush.
|
||||
const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
|
||||
@@ -695,7 +695,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
// can never diverge from the live log. The throw surfaces at the caller's
|
||||
// append site, not asynchronously in a backend flush.
|
||||
expect(() => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
// The bad event was rejected, so the log stayed empty.
|
||||
expect(session.events.length).toBe(0)
|
||||
|
||||
@@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
|
||||
|
||||
## Storage model
|
||||
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed.
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed.
|
||||
|
||||
The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and `journal_mode = WAL`. The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* backend-agnostic: the same append-only / contiguous-seq / lazy-materialization
|
||||
* / interrupted-turn-close-on-load semantics the JSONL backend expresses over
|
||||
* file bytes, expressed here over `node:sqlite` rows. Each `SessionEvent` maps
|
||||
* 1:1 onto a row `(session_id, seq, type, time, data)`.
|
||||
* 1:1 onto a row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`.
|
||||
*
|
||||
* Like the JSONL backend it supplies ONLY the storage primitives (the
|
||||
* {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside
|
||||
@@ -26,13 +26,26 @@ import {
|
||||
SessionPersistence, PersistenceCoordinator,
|
||||
type PersistenceBackend, type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
|
||||
} from './schema.ts'
|
||||
|
||||
export { SCHEMA_VERSION } from './schema.ts'
|
||||
|
||||
/**
|
||||
* Serialize an event's surface-metadata fields for SQL binding. Both fields are
|
||||
* nullable TEXT columns — null when the event has no surface metadata (non-surface
|
||||
* events, events written before surface support).
|
||||
*/
|
||||
function surfaceBindings(event: SessionEvent): [string | null, string | null] {
|
||||
const se = event as SessionEvent<SurfaceEventType>
|
||||
return [
|
||||
se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null,
|
||||
se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
|
||||
]
|
||||
}
|
||||
|
||||
/** Plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -135,7 +148,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
if (row === undefined) return undefined
|
||||
const meta = rowToMeta(row)
|
||||
const eventRows = this.db
|
||||
.prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.all(id) as unknown as EventRow[]
|
||||
const { preserved, tornFrom } = scanRows(eventRows)
|
||||
return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} }
|
||||
@@ -150,13 +163,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
|
||||
await this.ready
|
||||
const insertEvent = this.db.prepare(
|
||||
'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)',
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
this.db.exec('BEGIN')
|
||||
try {
|
||||
if (!isMaterialized) this.writeRow(meta)
|
||||
for (const event of events) {
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data))
|
||||
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
@@ -178,9 +192,12 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(meta.id, tornMarker)
|
||||
}
|
||||
if (closers.length > 0) {
|
||||
const insertEvent = this.db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
||||
const insertEvent = this.db.prepare(
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
for (const event of closers) {
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data))
|
||||
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
|
||||
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
|
||||
}
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The on-disk schema version. Bumped only on a breaking change to the table
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 3
|
||||
export const SCHEMA_VERSION = 4
|
||||
|
||||
/**
|
||||
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
|
||||
@@ -39,6 +39,10 @@ export interface EventRow {
|
||||
type: string
|
||||
time: number
|
||||
data: string
|
||||
/** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
|
||||
source_event_seqs: string | null
|
||||
/** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
|
||||
surface_op: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +56,15 @@ export interface EventRow {
|
||||
* current {@link SCHEMA_VERSION}; an existing database whose version is NOT the
|
||||
* current one (written by a different, incompatible build — older or newer) is
|
||||
* REJECTED rather than opened against a layout this build does not understand.
|
||||
* There are no migrations: an earlier layout (v1's different `sessions` shape,
|
||||
* v2 without the `seed_length` column) is not upgraded in place — it is rejected.
|
||||
* There are no migrations: an earlier layout is not upgraded in place — it is
|
||||
* rejected. v1 had a different `sessions` shape; v2 lacked all of
|
||||
* `seed_length`/`source_event_seqs`/`surface_op`. v3 is SKIPPED: two unmerged
|
||||
* branches each shipped a DISTINCT v3 (one adding only `seed_length`, the other
|
||||
* adding only the surface columns), so an on-disk v3 is ambiguous — it could be
|
||||
* either sibling layout, neither of which has all of this build's columns. v4
|
||||
* is the merged layout carrying every column; bumping past the collided v3
|
||||
* makes the version check reject both sibling v3 databases instead of opening
|
||||
* one against columns it does not have.
|
||||
*/
|
||||
export function openDatabase(path: string): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
@@ -83,11 +94,13 @@ export function openDatabase(path: string): DatabaseSync {
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
time INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
time INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
source_event_seqs TEXT,
|
||||
surface_op TEXT,
|
||||
PRIMARY KEY (session_id, seq)
|
||||
) STRICT
|
||||
`)
|
||||
@@ -108,11 +121,18 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
|
||||
/** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */
|
||||
export function rowToEvent(row: EventRow): SessionEvent {
|
||||
// Surface-metadata fields are conditional on the event type in the type
|
||||
// system; spread them so each variant gets only the fields it declares.
|
||||
const surfaceFields = {
|
||||
...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
|
||||
...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
|
||||
}
|
||||
return {
|
||||
type: row.type,
|
||||
type: row.type as SessionEvent['type'],
|
||||
seq: row.seq,
|
||||
time: row.time,
|
||||
data: JSON.parse(row.data) as SessionEvent['data'],
|
||||
...surfaceFields,
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import { openDatabase, scanRows, type EventRow } from '../src/schema.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -66,9 +66,17 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
|
||||
|
||||
describe('scanRows', () => {
|
||||
// scanRows works off EventRows (data is a JSON string column); build them from
|
||||
// SessionEvents so the unit tests read in terms of the event vocabulary.
|
||||
// SessionEvents so the unit tests read in terms of the event vocabulary. Surface
|
||||
// fields are serialized to their nullable columns so a round trip is faithful.
|
||||
const rows = (events: SessionEvent[]): EventRow[] =>
|
||||
events.map(e => ({ seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data) }))
|
||||
events.map((e) => {
|
||||
const se = e as SessionEvent<SurfaceEventType>
|
||||
return {
|
||||
seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
|
||||
source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
|
||||
surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
|
||||
const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
|
||||
@@ -117,8 +125,8 @@ describe('scanRows', () => {
|
||||
|
||||
it('throws on an unparsable row inside the committed region', () => {
|
||||
const withCorruptCommitted: EventRow[] = [
|
||||
{ seq: 0, type: 'turn/start', time: 1, data: '{not json' }, // corrupt, sits before a turn/end
|
||||
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }) },
|
||||
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
|
||||
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
|
||||
]
|
||||
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
|
||||
})
|
||||
@@ -126,7 +134,7 @@ describe('scanRows', () => {
|
||||
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
|
||||
const withCorruptTail: EventRow[] = [
|
||||
...rows(oneTurnLog()),
|
||||
{ seq: 6, type: 'turn/start', time: 7, data: '{not json' }, // torn fragment, no committed turn/end after
|
||||
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
|
||||
]
|
||||
const { preserved, tornFrom } = scanRows(withCorruptTail)
|
||||
expect(preserved).toEqual(oneTurnLog())
|
||||
@@ -246,6 +254,20 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/)
|
||||
})
|
||||
|
||||
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
|
||||
// Two unmerged branches each shipped a DISTINCT layout under user_version 3
|
||||
// (one added only `seed_length`, the other only the surface columns). The
|
||||
// merged build is v4; an on-disk v3 is ambiguous and is missing at least one
|
||||
// of this build's columns, so it MUST be rejected, not opened. Stamp a v3
|
||||
// database and confirm the version check refuses it.
|
||||
const path = await freshDbPath()
|
||||
openDatabase(path).close() // creates + stamps user_version = SCHEMA_VERSION (4)
|
||||
const db = openDatabase(path)
|
||||
db.exec('PRAGMA user_version = 3')
|
||||
db.close()
|
||||
expect(() => openDatabase(path)).toThrow(/schema version 3, incompatible with this build/)
|
||||
})
|
||||
|
||||
it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
|
||||
const path = await freshDbPath()
|
||||
const m = meta('corrupt-tail')
|
||||
@@ -315,7 +337,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(3)
|
||||
expect(SCHEMA_VERSION).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -353,7 +375,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
// Instance 1 materializes a session and disposes.
|
||||
const b1 = await backend(path)
|
||||
const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
|
||||
for (const e of oneTurnLog()) s1.append(e.type, e.data)
|
||||
appendLog(s1, oneTurnLog())
|
||||
await b1.ctx.parallel('session/flush', s1)
|
||||
await b1.dispose()
|
||||
|
||||
@@ -371,3 +393,81 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface field round-trip', () => {
|
||||
it('rowToEvent parses surface fields from EventRow columns', () => {
|
||||
const row: EventRow = {
|
||||
seq: 0, type: 'assistant/message', time: 1,
|
||||
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
|
||||
source_event_seqs: JSON.stringify([3, 5]),
|
||||
surface_op: JSON.stringify('append'),
|
||||
}
|
||||
const event = rowToEvent(row)
|
||||
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
|
||||
expect((event as SurfaceEvent).surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('rowToEvent handles replace surfaceOp object', () => {
|
||||
const row: EventRow = {
|
||||
seq: 0, type: 'assistant/message', time: 1,
|
||||
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
|
||||
source_event_seqs: JSON.stringify([0, 1]),
|
||||
surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
|
||||
}
|
||||
const event = rowToEvent(row)
|
||||
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
|
||||
expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 })
|
||||
})
|
||||
|
||||
it('scanRows with surface columns reconstructs events with surface fields', () => {
|
||||
const rows: EventRow[] = [
|
||||
{ seq: 0, type: 'user/message', time: 1,
|
||||
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
|
||||
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
|
||||
{ seq: 1, type: 'turn/end', time: 2,
|
||||
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
|
||||
source_event_seqs: null, surface_op: null },
|
||||
]
|
||||
const { preserved } = scanRows(rows)
|
||||
expect(preserved).toHaveLength(2)
|
||||
expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
|
||||
expect((preserved[1] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('append and load round-trips surface fields through SQLite', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
||||
const session = ctx.sessions.create(SessionId('roundtrip-surface'))
|
||||
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('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [0] })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
|
||||
expect(loaded.events).toHaveLength(4)
|
||||
const um = loaded.events[1]!
|
||||
expect((um as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined()
|
||||
const am = loaded.events[2]!
|
||||
expect((am as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((am as SurfaceEvent).sourceEventSeqs).toEqual([0])
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
||||
const session = ctx.sessions.create(SessionId('surface-noseq'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('steering/message', { turn: 1, content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
|
||||
expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
|
||||
@@ -34,14 +34,40 @@ export function meta(id: string, cwd?: string): SessionHeader {
|
||||
export function oneTurnLog(): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 3, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] } },
|
||||
{ type: 'assistant/message', seq: 3, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] }, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a whole event log to a LIVE session, event by event, forwarding the
|
||||
* surface metadata each event already carries. A bare `append(e.type, e.data)`
|
||||
* over a `SessionEvent[]` widens the type argument to the union, where the
|
||||
* typed overload's mandatory-marker rule collapses to optional — and `append`'s
|
||||
* runtime guard then rejects a surface-eligible event with no marker. This
|
||||
* helper forwards the `surfaceOp`/`sourceEventSeqs` VERBATIM from the source
|
||||
* event (it does not synthesize a default), so a well-formed recorded log
|
||||
* round-trips through a live session intact and a fixture that forgot a marker
|
||||
* still trips the guard.
|
||||
*/
|
||||
export function appendLog(session: Session, events: readonly SessionEvent[]): void {
|
||||
for (const e of events) {
|
||||
const se = e as SessionEvent<SurfaceEventType>
|
||||
if (se.surfaceOp !== undefined) {
|
||||
const intent: SurfaceIntent = {
|
||||
surfaceOp: se.surfaceOp,
|
||||
...se.sourceEventSeqs !== undefined ? { sourceEventSeqs: se.sourceEventSeqs } : {},
|
||||
}
|
||||
session.append(e.type, e.data, intent)
|
||||
} else {
|
||||
session.append(e.type, e.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the backend-agnostic contract suite. `make()` MUST return a fresh, empty
|
||||
* backend each call.
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
import { meta, oneTurnLog } from './contract.ts'
|
||||
import { meta, oneTurnLog, appendLog } from './contract.ts'
|
||||
|
||||
/**
|
||||
* The backend-specific capabilities the orchestration suite needs beyond the
|
||||
@@ -76,7 +76,7 @@ function inits(persistence: SessionPersistence): Map<Session, Promise<void>> {
|
||||
|
||||
/** Append a whole event log to a live session, event by event (drives session/event). */
|
||||
function send(session: Session, events: readonly SessionEvent[]): void {
|
||||
for (const e of events) session.append(e.type, e.data)
|
||||
appendLog(session, events)
|
||||
}
|
||||
|
||||
/** A live session created inside its OWN fiber, so it survives a backend reload. */
|
||||
@@ -148,7 +148,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
|
||||
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
|
||||
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// Mutate the live event object AFTER it was buffered by session/event.
|
||||
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -252,7 +252,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await ctx.plugin(SessionStore)
|
||||
// A session exists BEFORE the persistence plugin is applied.
|
||||
const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const fiber = await fix.mount(ctx)
|
||||
@@ -273,7 +273,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await fix.mount(ctx)
|
||||
const session = await liveSessionInFiber(ctx, 'drain', WORK)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// No explicit flush — dispose must drain.
|
||||
await fiber.dispose()
|
||||
@@ -299,7 +299,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// Backend instance 1 materializes the session.
|
||||
const backend1 = await fix.mount(ctx)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
|
||||
@@ -310,7 +310,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await backend1.dispose()
|
||||
await fix.mount(ctx)
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow()
|
||||
|
||||
@@ -472,7 +472,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
// Re-emit session/created for the SAME live session (idempotent initFor).
|
||||
@@ -724,7 +724,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// async onCreated init has necessarily set state (exercises the
|
||||
// state-undefined cursor path).
|
||||
const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export const name = 'invariants'
|
||||
export const inject = ['sessions']
|
||||
@@ -67,6 +67,15 @@ interface SessionTrace {
|
||||
* `step/end` — a result must arrive in the same step as its call.
|
||||
*/
|
||||
pendingCalls: Set<CallId>
|
||||
/** Every seq seen so far — validates `sourceEventSeqs` references. */
|
||||
knownSeqs: Set<number>
|
||||
/**
|
||||
* The seqs currently on the surface linked list, in linked-list order
|
||||
* (head to tail). A replace reorders this relative to seq order (the new
|
||||
* node takes the replaced range's position), so range validation is
|
||||
* positional, not by seq comparison.
|
||||
*/
|
||||
surface: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +119,77 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
}
|
||||
trace.lastSeq = event.seq
|
||||
|
||||
// --- Surface invariants ---
|
||||
// Surface metadata (sourceEventSeqs, surfaceOp) is only valid on
|
||||
// surface-eligible event types. The compiler enforces this at append()
|
||||
// call sites; this runtime check catches casts and persisted data.
|
||||
const SURFACE_TYPES = new Set<string>(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'])
|
||||
// Cast to surface-eligible event type so we can access surfaceOp and
|
||||
// sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent).
|
||||
// SurfaceEvent's mandatory surfaceOp is too strict here — we need to
|
||||
// CHECK whether surface metadata is present, not assume it.
|
||||
const se = event as SessionEvent<SurfaceEventType>
|
||||
if (!SURFACE_TYPES.has(event.type)) {
|
||||
if (se.sourceEventSeqs !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`)
|
||||
}
|
||||
if (se.surfaceOp !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`)
|
||||
}
|
||||
}
|
||||
if (se.sourceEventSeqs !== undefined) {
|
||||
if (se.sourceEventSeqs.length === 0) {
|
||||
throw new InvariantError('sourceEventSeqs must not be empty when present')
|
||||
}
|
||||
const unique = new Set(se.sourceEventSeqs)
|
||||
if (unique.size !== se.sourceEventSeqs.length) {
|
||||
throw new InvariantError('sourceEventSeqs must not contain duplicates')
|
||||
}
|
||||
for (const ref of se.sourceEventSeqs) {
|
||||
if (ref >= event.seq) {
|
||||
throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`)
|
||||
}
|
||||
if (!trace.knownSeqs.has(ref)) {
|
||||
throw new InvariantError(`sourceEventSeqs references unknown seq ${ref}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fold this event into the tracked surface linked list, validating the
|
||||
// replace contract as we go. `append` adds a tail node; `replace` shadows a
|
||||
// positional range — every shadowed node must appear in sourceEventSeqs.
|
||||
if (se.surfaceOp !== undefined) {
|
||||
if (se.surfaceOp === 'append') {
|
||||
trace.surface.push(event.seq)
|
||||
} else {
|
||||
const { start, end } = se.surfaceOp
|
||||
if (start > end) {
|
||||
throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`)
|
||||
}
|
||||
const startIdx = trace.surface.indexOf(start)
|
||||
if (startIdx === -1) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
|
||||
}
|
||||
const endIdx = trace.surface.indexOf(end)
|
||||
if (endIdx === -1) {
|
||||
throw new InvariantError(`surface replace: end seq ${end} is not on the surface`)
|
||||
}
|
||||
if (startIdx > endIdx) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`)
|
||||
}
|
||||
// Every node the replace shadows (surface positions [startIdx, endIdx]
|
||||
// inclusive) must appear in sourceEventSeqs — the provenance contract.
|
||||
const shadowed = trace.surface.slice(startIdx, endIdx + 1)
|
||||
const recorded = new Set(se.sourceEventSeqs ?? [])
|
||||
const missing = shadowed.filter(seq => !recorded.has(seq))
|
||||
if (missing.length > 0) {
|
||||
throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
|
||||
}
|
||||
// Apply the replace to the tracked surface: the new node takes the
|
||||
// range's position so order stays in sync for later replaces.
|
||||
trace.surface.splice(startIdx, shadowed.length, event.seq)
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary/step-scoped events have explicit cases; every OTHER event type —
|
||||
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
|
||||
// by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an
|
||||
@@ -204,6 +284,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Track every seq seen — used above to validate sourceEventSeqs references.
|
||||
trace.knownSeqs.add(event.seq)
|
||||
}
|
||||
|
||||
/** Legal agent status transitions (the only state machine the loop guarantees). */
|
||||
@@ -242,6 +324,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
nextTurn: 1,
|
||||
nextStep: 1,
|
||||
pendingCalls: new Set(),
|
||||
knownSeqs: new Set(),
|
||||
surface: [],
|
||||
})
|
||||
|
||||
/** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */
|
||||
|
||||
@@ -25,12 +25,12 @@ describe('session-log invariants', () => {
|
||||
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' } })
|
||||
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', { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] })
|
||||
session.append('assistant/message', { 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: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
@@ -89,9 +89,9 @@ describe('session-log invariants', () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }))
|
||||
expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
@@ -100,10 +100,14 @@ describe('session-log invariants', () => {
|
||||
const session = ctx.sessions.create()
|
||||
// steering/message is turn-scoped: outside a turn it would land past the
|
||||
// commit boundary and be dropped on resume (the turn-enclosure RFC).
|
||||
expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
// A PLUGIN-added (merge-extensible) event type is caught by the default too.
|
||||
expect(() => session.append('compaction/marker' as never, { foo: 'bar' } as never))
|
||||
// Cast through `any`: 'compaction/marker' is not in SessionEventType (it's
|
||||
// merge-extensible), so the typed append() won't accept it. The test verifies
|
||||
// the runtime default-branch turn-enclosure check.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
|
||||
expect(() => (session.append as any)('compaction/marker', { foo: 'bar' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
@@ -111,7 +115,7 @@ describe('session-log invariants', () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
@@ -120,7 +124,7 @@ describe('session-log invariants', () => {
|
||||
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 })
|
||||
expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }))
|
||||
expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }, { surfaceOp: 'append' }))
|
||||
.toThrow(/no prior tool\/call/)
|
||||
})
|
||||
|
||||
@@ -132,7 +136,7 @@ describe('session-log invariants', () => {
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [
|
||||
{ type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' },
|
||||
] })
|
||||
] }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -140,7 +144,7 @@ describe('session-log invariants', () => {
|
||||
content: [{ type: 'text', text: 'interrupted' }],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
})
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
}).not.toThrow()
|
||||
@@ -186,10 +190,10 @@ describe('session-log invariants', () => {
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', { turn: 1, step: 2, content: [] })
|
||||
session.append('assistant/message', { turn: 1, step: 2, content: [] }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -242,7 +246,7 @@ describe('session-log invariants', () => {
|
||||
// step ends with the call unresolved — pendingCalls is cleared.
|
||||
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 }))
|
||||
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/)
|
||||
})
|
||||
|
||||
@@ -251,7 +255,7 @@ describe('session-log invariants', () => {
|
||||
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 })
|
||||
expect(() => session.append('assistant/message', { turn: 1, step: 2, content: [] }))
|
||||
expect(() => session.append('assistant/message', { turn: 1, step: 2, content: [] }, { surfaceOp: 'append' }))
|
||||
.toThrow(/open is turn 1\/step 1/)
|
||||
})
|
||||
})
|
||||
@@ -283,7 +287,7 @@ describe('dev-freeze', () => {
|
||||
const { ctx } = await setup() // freeze defaults true
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(Object.isFrozen(event)).toBe(true)
|
||||
expect(Object.isFrozen(event.data)).toBe(true)
|
||||
expect(Object.isFrozen(event.data.content)).toBe(true)
|
||||
@@ -294,7 +298,7 @@ describe('dev-freeze', () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(Object.isFrozen(event)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -302,7 +306,7 @@ describe('dev-freeze', () => {
|
||||
const { ctx } = await setup()
|
||||
const seed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
||||
]
|
||||
const session = ctx.sessions.create(undefined, { seed })
|
||||
expect(Object.isFrozen(session.events[0])).toBe(true)
|
||||
@@ -320,7 +324,7 @@ describe('dev-freeze', () => {
|
||||
// the caller's input — read the event back and assert on its data.
|
||||
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]
|
||||
const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false })
|
||||
const event = session.append('user/message', { content: [block], source: { kind: 'user' } })
|
||||
const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] }
|
||||
expect(Object.isFrozen(logged.content)).toBe(true)
|
||||
expect(Object.isFrozen(logged.content[0])).toBe(true)
|
||||
@@ -420,9 +424,231 @@ describe('HMR safety', () => {
|
||||
const spy = vi.fn()
|
||||
ctx.on('session/event', spy)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// our own spy fires, proving events still flow — but the plugin's frozen.
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
expect(Object.isFrozen(session.events[0])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface invariants', () => {
|
||||
it('accepts well-formed surface metadata', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
// Events must be turn-enclosed and step-scoped events need an open step.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts replace surface op', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] })
|
||||
// no throw — well-formed replace op
|
||||
})
|
||||
|
||||
it('rejects empty sourceEventSeqs', 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('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
}).toThrow(InvariantError)
|
||||
})
|
||||
|
||||
it('rejects duplicate sourceEventSeqs', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1, 1] })
|
||||
}).toThrow(/must not contain duplicates/)
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing the event itself (self-reference)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) // seq 0
|
||||
// The next event is seq 1. Referencing its own seq fails on "must reference
|
||||
// earlier events" (the check order is: earlier first, then unknown).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] })
|
||||
}).toThrow(/must reference earlier/)
|
||||
})
|
||||
|
||||
it('accepts sourceEventSeqs referencing a valid earlier event', async () => {
|
||||
// Positive test: ref < current seq and ref is in knownSeqs → passes.
|
||||
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 })
|
||||
// seqs so far: 0, 1. The next event at seq 2 references seq 1 → valid.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing a far-future seq', 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('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [99] })
|
||||
}).toThrow(/must reference earlier/)
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => {
|
||||
// The unknown-seq check fires when a ref passes the "earlier" test but is
|
||||
// not in knownSeqs — only possible with a gap in seqs. We create a gap by
|
||||
// directly manipulating the private log array to skip a seq.
|
||||
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 })
|
||||
// Push a fake event at seq 3 into the internal log, creating a gap at seq 2.
|
||||
// The invariants plugin replays session.events on every append, so it sees
|
||||
// this gap during trace reconstruction.
|
||||
;(session as unknown as { log: unknown[] }).log.push({
|
||||
type: 'assistant/chunk',
|
||||
seq: 3,
|
||||
time: Date.now(),
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } },
|
||||
})
|
||||
// Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes
|
||||
// is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not
|
||||
// in knownSeqs ({0, 1, 3} — gap at 2).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] })
|
||||
}).toThrow(/unknown seq 2/)
|
||||
})
|
||||
|
||||
it('rejects replace op with start > end', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// start > end is invalid (reversed order).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/must be <= end/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Replace shadows surface nodes [2, 3] but records provenance for only [2].
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/must include every shadowed surface node; missing 3/)
|
||||
})
|
||||
|
||||
it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a replace naming a start seq that is not on the surface', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// seq 1 (step/start) is a real earlier event but never entered the surface.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
|
||||
}).toThrow(/start seq 1 is not on the surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace naming an end seq that is not on the surface', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// start (2) is on the surface but end (99) never entered it.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/end seq 99 is not on the surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4
|
||||
// precedes seq 3 in linked-list order even though 4 > 3 numerically.
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
|
||||
// A replace with start=3, end=4 passes the seq check (3 <= 4) but is
|
||||
// reversed positionally (3 is at pos 1, 4 is at pos 0).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5
|
||||
}).toThrow(/is after end seq 4 .* on the surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace that omits sourceEventSeqs entirely', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// A replace with no sourceEventSeqs records no provenance for the node it shadows.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } })
|
||||
}).toThrow(/must include every shadowed surface node; missing 2/)
|
||||
})
|
||||
|
||||
it('catches an incomplete-provenance replace on the load/seed path', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
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: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } },
|
||||
{ type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
||||
{ type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
||||
{ type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }] }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] },
|
||||
]
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/)
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs on a non-surface event', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// Type system prevents surface metadata on non-surface events; this test
|
||||
// exercises the runtime guard against casts or persisted-data bypass.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
|
||||
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] }))
|
||||
.toThrow(/cannot carry sourceEventSeqs/)
|
||||
})
|
||||
|
||||
it('rejects surfaceOp on a non-surface event', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
|
||||
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/cannot carry surfaceOp/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user