refactor(session): fold the session family into packages/session/

git mv the 12 packages from session-persistence/, session-projection/,
session-title/, and telemetry/ into one session/ group per the
regrouping RFC; merge the four group READMEs into one bilingual
triplet; rewrite the group segment in tsconfig references (intra-group
references shorten to ../<pkg>), tsconfig.base.json paths/globs,
knip.json keys, vitest include, gate scripts, and authored doc/note
citations; regenerate module graph, doc graphs, catalogs, and the
lockfile importer keys. No npm names change.

Full unit suite: 8779 passed; the 18 reported failures reproduce as
env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify
timeouts under parallel load) — each passes in isolation with
NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
Tianyi Cui
2026-07-30 01:52:06 +08:00
parent 645fcf5713
commit 7e445c3a67
220 changed files with 258 additions and 286 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-checkpoint-policy/README.md
README.md: 01ed3c694967b1a86245d01b9d9f7eecd1193348
README.zh.md: e2c197c0cc1d942a045c1ea22c1fb257f6e4059d

View File

@@ -0,0 +1,45 @@
# dsh-session-checkpoint-policy
English | [中文](README.zh.md)
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/pre-step` boundary so the preceding response and ordered tool results are durable before the next request.
## Plugin (namespace: `session-checkpoint-policy`)
This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`, and the presence of `ctx.sessionPersistence`. Load it beside one persistence backend:
```yaml
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
- id: session-checkpoints
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend starts bounded background batches for `session/event` appends and makes each requested `session/flush` an immediate quiescence barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose events still inside the configured batching window or an outstanding write. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/pre-step` persists the preceding response/result batch before request derivation.
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A step-boundary rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
## Model Experience
### Interrupted calls
#### What the model sees
The plugin adds no prompt or tool schema. A hard crash after a tool checkpoint but before its result leaves a durable unmatched call; session recovery supplies the model-visible `TOOL_OUTCOME_UNKNOWN` result owned by `dsh-session`. The message permits retry for read-only or idempotent work and requires state verification or user confirmation for calls that may have side effects.
#### Token effect
Successful checkpoints add no tokens and do not change the request. Recovery adds one short tool-result message to balance the interrupted transcript.
#### KV Cache effect
The repair result is appended after the reusable prefix, so it does not invalidate earlier cache entries.
## Known Limitations and Deferred Work
- The policy durably records execution intent, not generic exactly-once effects. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one.
- Streaming `assistant/chunk` events have no per-chunk checkpoint. Bounded background batches normally persist them before the next semantic checkpoint, but a hard crash may lose the current in-memory batch or outstanding write.
- A persisted call without a result cannot prove whether its external effect completed. Recovery therefore records an unknown outcome instead of retrying automatically.

View File

@@ -0,0 +1,45 @@
# dsh-session-checkpoint-policy
[English](README.md) | 中文
已持久化的 agent(智能体)的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及每个 `agent/pre-step` 边界为事件溯源会话创建检查点,使前一响应与有序工具结果在下一个请求前已持久化。
## 插件(命名空间:`session-checkpoint-policy`)
该零配置函数插件消费 `ctx.sessions`、`ctx.llm`、`ctx.tools` 以及 `ctx.sessionPersistence` 的存在性。将其与一个持久化后端一起加载:
```yaml
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
- id: session-checkpoints
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端会为追加的 `session/event` 启动有界后台批次,并把每个已请求的 `session/flush` 变成即时完全停稳屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失仍位于已配置批处理窗口内的事件,或尚未完成的写入。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。
策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/pre-step` 在派生请求前持久化前一响应/结果批次。
在模型和工具边界,检查点被拒绝时会按失败即阻止原则处理:适配器和顶层工具正文都不运行。步骤边界处的检查点被拒绝会在另一个请求开始前使轮次失败。并发工具检查点共享会话存储的串行持久化排空流程,不会产生重复的序列号。
## 模型体验
### 中断调用
#### 模型看到的内容
插件不添加提示词或工具 schema。工具检查点后、结果前的硬崩溃会留下持久的未匹配调用;会话恢复会提供模型可见的 `TOOL_OUTCOME_UNKNOWN` 结果,该结果由 `dsh-session` 负责。该消息允许重试只读或幂等工作,并要求对可能有副作用的调用验证状态或请求用户确认。
#### Token 影响
成功检查点不添加 token,也不改变请求。恢复会添加一条短工具结果消息,以平衡中断的 transcript(文本记录)。
#### KV Cache 影响
修复结果追加在可重用前缀之后,因此不会使较早的缓存条目失效。
## 已知限制与暂缓事项
- 该策略以持久方式记录执行意图,而非为通用副作用提供恰好一次保证。当提供方支持时,有副作用的工具应将 `exec.callId` 作为幂等键转发。
- 流式 `assistant/chunk` 事件没有逐分片检查点。有界后台批次通常会在下一个语义检查点之前将其持久化,但硬崩溃可能丢失当前内存批次或尚未完成的写入。
- 已持久化的调用没有结果时,无法证明其外部副作用是否完成。因此,恢复会记录未知结果,而不是自动重试。

View File

@@ -0,0 +1,50 @@
{
"name": "@deepseek-ai/dsh-session-checkpoint-policy",
"description": "Semantic session durability checkpoints before model requests and tool side effects",
"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"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,83 @@
/**
* Semantic durability checkpoints for model requests, top-level tool dispatch,
* and completed agent steps.
* @module @deepseek-ai/dsh-session-checkpoint-policy
*/
import type { Context } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { PreStepDecision } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
/** Cordis plugin name used by Loader diagnostics. */
export const name = 'session-checkpoint-policy'
/** Services whose request, tool, session, and persistence boundaries this policy joins. */
export const inject = ['llm', 'sessionPersistence', 'sessions', 'tools']
/**
* Delay construction of the downstream model stream until the complete logged
* request prefix is durable. A checkpoint rejection prevents adapter dispatch.
*
* @param ctx - plugin context that owns the session store.
* @param session - live session named by the model request.
* @param next - downstream `llm/stream` chain.
* @returns a stream that checkpoints before requesting its first chunk.
*/
function afterCheckpoint(
ctx: Context,
session: Session,
next: () => AsyncIterable<StreamChunk>,
): AsyncIterable<StreamChunk> {
return (async function* (): AsyncIterable<StreamChunk> {
await ctx.sessions.flush(session)
yield* next()
})()
}
/** Materialize the canonical result for a call cancelled before tool dispatch. */
function abortedBeforeDispatchResult(): ToolExecutionResult {
return {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: {
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
},
}
}
/**
* Install semantic checkpoint listeners. Loop-built model calls checkpoint the
* logged request before adapter dispatch; top-level tool calls checkpoint their
* recorded call before the tool body; the next request boundary checkpoints
* the preceding response/result batch. Nested tool dispatches reuse the durable outer call.
*
* Checkpoint failures are fail-closed at the model and tool side-effect
* boundaries: the downstream adapter or tool body is not invoked.
*
* @param ctx - plugin context that owns the listeners.
*/
export function apply(ctx: Context): void {
ctx.on('llm/stream', (options, next): AsyncIterable<StreamChunk> => {
if (options.sessionId === undefined) return next()
const session = ctx.sessions.get(options.sessionId)
return session === undefined ? next() : afterCheckpoint(ctx, session, next)
})
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
if (exec.agent === undefined || exec.parent !== undefined) return next()
await ctx.sessions.flush(exec.agent.session)
if (exec.signal.aborted) return abortedBeforeDispatchResult()
return next()
})
// Before each request, persist everything committed by the preceding step;
// the first step's call is an intentional no-op beyond any prompt intake.
ctx.on('agent/pre-step', async ({ agent }, next): Promise<PreStepDecision> => {
await ctx.sessions.flush(agent.session)
return next()
})
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-checkpoint-policy`.
* @module @deepseek-ai/dsh-session-checkpoint-policy/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy'
/** Cordis companion plugin name. */
export const name = 'session-checkpoint-policy-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: checkpoint ordering is enforced at the intercepted waterfall and
* persistence seams; this stateless policy owns no independent mutable relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,111 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import SessionStore, {
SessionId, TOOL_OUTCOME_UNKNOWN,
type SessionEvent,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const sessionId = SessionId('semantic-checkpoint-crash')
const roots: string[] = []
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
async function waitForMarker(path: string, expected: string): Promise<string> {
// vi.waitFor retries every callback throw, so terminal states RESOLVE out
// of the retry loop (complete marker, or content that can no longer become
// the expected marker) and only the still-in-progress states throw-to-retry.
const content = await vi.waitFor(async () => {
const current = await readFile(path, 'utf8').catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
})
if (current === expected || !expected.startsWith(current)) return current
throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
if (content !== expected) {
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
}
return content
}
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`))
roots.push(root)
const marker = join(root, 'failpoint')
// Keep the open-before-write window deterministic: readiness is marker content, not path existence.
await writeFile(marker, '')
const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
// The SIGKILL-at-failpoint choreography stays custom: the child must die
// mid-write, so no timeout or graceful termination may reach it first.
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
cwd: repoRoot,
env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
stdin: 'ignore',
stdout: 'ignore',
reject: false,
})
try {
const markerText = await waitForMarker(marker, expectedMarker)
child.kill('SIGKILL')
const exit = await child
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
return { root, markerText }
} catch (error: unknown) {
child.kill('SIGKILL')
throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
}
}
async function load(root: string): Promise<SessionEvent[]> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
try {
return [...(await ctx.sessionPersistence.load(sessionId)).events]
} finally {
await ctx.fiber.dispose()
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash recovery', () => {
it('persists the complete request before model dispatch', async () => {
const crashed = await crashAt('request')
expect(crashed.markerText).toBe('request-dispatched')
const events = await load(crashed.root)
expect(events.map(event => event.type)).toEqual([
'agent/inbox/spliced', 'turn/start', 'agent/inbox/spliced',
'step/start', 'user/message', 'request/header', 'request/context', 'step/end', 'turn/end',
])
expect(events.at(-1)).toMatchObject({
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
})
})
it('persists tool intent before a side effect and repairs its missing result as unknown', async () => {
const crashed = await crashAt('tool')
expect(crashed.markerText).toBe('tool-side-effect')
const events = await load(crashed.root)
expect(events.some(event => event.type === 'assistant/message')).toBe(true)
expect(events.some(event => event.type === 'tool/call')).toBe(true)
const result = events.find(event => event.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (result?.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(result.data.message.content[0].content[0].text).toContain('Do not retry blindly.')
})
})

View File

@@ -0,0 +1,60 @@
import { writeFile } from 'node:fs/promises'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as checkpointPolicy from '../../src/index.ts'
function waitForCrash(): Promise<never> {
return new Promise(() => { setInterval(() => {}, 60_000) })
}
const [mode, root, marker] = process.argv.slice(2)
if ((mode !== 'request' && mode !== 'tool') || root === undefined || marker === undefined) {
throw new Error('usage: crash-child.ts <request|tool> <persistence-root> <marker>')
}
const persistenceRoot = root
const failpoint = marker
class CrashAdapter extends LlmAdapter {
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
if (mode === 'request') {
await writeFile(failpoint, 'request-dispatched')
await waitForCrash()
return
}
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield {
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: CallId('crash-call'), name: 'crash_tool', arguments: '{}' },
}
yield { type: 'finish', reason: { kind: 'tool-calls' } }
}
}
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, compression: 'none' })
await ctx.plugin(checkpointPolicy)
ctx.llm.registerAdapter(['crash'], new CrashAdapter())
ctx.tools.register({
name: 'crash_tool',
description: 'records an external effect and never returns',
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
async execute() {
await writeFile(failpoint, 'tool-side-effect')
return waitForCrash()
},
})
const handle = await ctx.agents.create({
sessionId: SessionId('semantic-checkpoint-crash'),
agentOptions: { provider: 'crash', model: 'crash' },
})
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'exercise the crash boundary' }], source: { kind: 'user' } }))
await waitForCrash()

View File

@@ -0,0 +1,268 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import * as checkpointPolicy from '../src/index.ts'
const contexts: Context[] = []
class TestPersistence extends SessionPersistence {
locate(_meta: SessionHeader): undefined { return undefined }
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return Promise.reject(new Error('not used'))
}
inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return Promise.reject(new Error('not used'))
}
readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return Promise.reject(new Error('not used'))
}
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
listSnapshots(): Promise<never[]> { return Promise.resolve([]) }
}
class RecordingAdapter extends LlmAdapter {
constructor(private readonly order: string[]) { super() }
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
this.order.push('adapter')
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
async function setup(): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TestPersistence)
await ctx.plugin(checkpointPolicy)
return ctx
}
async function drain(stream: AsyncIterable<StreamChunk>): Promise<void> {
for await (const _chunk of stream) { /* drain */ }
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('session-checkpoint-policy request boundary', () => {
it('awaits the live session checkpoint before constructing the downstream model stream', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('request-checkpoint'))
session.append('turn/start', { turn: 1 })
const gate = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/flush', async () => {
order.push('flush:start')
await gate.promise
order.push('flush:end')
})
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
const pending = drain(ctx.llm.stream({
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
}))
await Promise.resolve()
expect(order).toEqual(['flush:start'])
gate.resolve(undefined)
await pending
expect(order).toEqual(['flush:start', 'flush:end', 'adapter'])
})
it('delegates a request without a live session without checkpointing', async () => {
const ctx = await setup()
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [] }))
expect(order).toEqual(['adapter'])
})
it('delegates an already-detached session id without checkpointing', async () => {
const ctx = await setup()
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
await drain(ctx.llm.stream({
provider: 'mock', model: 'mock', messages: [], sessionId: SessionId('detached'),
}))
expect(order).toEqual(['adapter'])
})
it('does not dispatch the adapter when the checkpoint rejects', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('request-failure'))
const order: string[] = []
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
await expect(drain(ctx.llm.stream({
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
}))).rejects.toThrow('disk unavailable')
expect(order).toEqual([])
})
})
describe('session-checkpoint-policy tool and step boundaries', () => {
it('awaits the checkpoint before a top-level tool body', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('tool-checkpoint'))
const agent = { session } as Agent
const gate = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/flush', async () => {
order.push('flush:start')
await gate.promise
order.push('flush:end')
})
ctx.tools.register({
name: 'write', description: 'side effect', parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => { order.push('tool'); return null },
})
const pending = ctx.tools.execute({
callId: CallId('write-1'), name: 'write', arguments: {}, agent,
signal: new AbortController().signal,
})
await Promise.resolve()
expect(order).toEqual(['flush:start'])
gate.resolve(undefined)
await expect(pending).resolves.toMatchObject({ isError: false })
expect(order).toEqual(['flush:start', 'flush:end', 'tool'])
})
it('does not dispatch when cancellation lands during the tool checkpoint', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel'))
const agent = { session } as Agent
const controller = new AbortController()
const gate = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/flush', async () => {
order.push('flush:start')
await gate.promise
order.push('flush:end')
})
ctx.tools.register({
name: 'write', description: 'side effect', parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => { order.push('tool'); return null },
})
const pending = ctx.tools.execute({
callId: CallId('write-cancelled'), name: 'write', arguments: {}, agent,
signal: controller.signal,
})
await Promise.resolve()
expect(order).toEqual(['flush:start'])
controller.abort('cancelled during checkpoint')
gate.resolve(undefined)
await expect(pending).resolves.toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: {
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
},
})
expect(order).toEqual(['flush:start', 'flush:end'])
})
it('turns a rejected checkpoint into an error result without running the tool body', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('tool-failure'))
const agent = { session } as Agent
let ran = false
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
ctx.tools.register({
name: 'write', description: 'side effect', parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => { ran = true; return null },
})
const result = await ctx.tools.execute({
callId: CallId('write-2'), name: 'write', arguments: {}, agent,
signal: new AbortController().signal,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: disk unavailable' }])
expect(ran).toBe(false)
})
it('reuses the outer checkpoint for a nested tool dispatch', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('nested-tool'))
const agent = { session } as Agent
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
ctx.tools.register({
name: 'nested', description: 'nested', parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
await ctx.tools.execute({
callId: CallId('nested-1'), name: 'nested', arguments: {}, agent,
parent: Symbol('outer') as never,
signal: new AbortController().signal,
})
expect(flushes).toBe(0)
})
it('checkpoints during pre-step processing', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('post-step'))
const agent = { session } as Agent
const flushed: string[] = []
ctx.on('session/flush', (current) => { flushed.push(current.id) })
const signal = new AbortController().signal
await agentEvents(ctx, agent).waterfall(
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }),
)
expect(flushed).toEqual([session.id])
})
})
describe('session-checkpoint-policy lifecycle', () => {
it('removes its wrappers when the owning fiber is disposed', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TestPersistence)
const session = ctx.sessions.create(SessionId('disposed-policy'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
ctx.llm.registerAdapter(['mock'], new RecordingAdapter([]))
const fiber = await ctx.plugin(checkpointPolicy)
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
expect(flushes).toBe(1)
await fiber.dispose()
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
expect(flushes).toBe(1)
})
it('keeps the Loader-safe namespace plugin shape', () => {
expect('default' in checkpointPolicy).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(checkpointPolicy) as Record<string, unknown>
expect(unwrapped).toBe(checkpointPolicy)
expect(unwrapped.name).toBe('session-checkpoint-policy')
expect(unwrapped.inject).toEqual(['llm', 'sessionPersistence', 'sessions', 'tools'])
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../session-persistence"
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/tools"
}
]
}