Merge remote-tracking branch 'upstream/master' into fix/workspace-instruction-frame-metadata

# Conflicts:
#	packages/support/acp-snapshot/README.i18n.yaml
#	packages/support/acp-snapshot/README.md
#	packages/support/acp-snapshot/README.zh.md
This commit is contained in:
ZiyaZhang
2026-07-28 08:59:32 -07:00
122 changed files with 3370 additions and 2451 deletions

View File

@@ -309,20 +309,31 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
if (titleEvent !== undefined) {
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
}
const todos = backscanTodos(log)
if (todos !== undefined) values['todos'] = todos
// Always present (tool-todo unit composed): null when no plan stands.
values['todos'] = backscanTodos(log) ?? null
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
const key = type === 'session/title' ? 'title' : type === 'todo/write' ? 'todos' : undefined
if (key === undefined) return []
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing event is in the log, so its key always has a value. */
if (!Object.hasOwn(values, key)) return []
return [{ type: 'session/projection', sessionId: id, key, value: values[key], seq: event.seq }]
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
if (!Object.hasOwn(values, 'title')) return []
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
return [{
type: 'session/projection',
sessionId: id,
key: 'todos',
value: backscanTodos(log) ?? null,
seq: event.seq,
}]
}
return []
}
/**
@@ -356,11 +367,16 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
*/
function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
if (event === undefined) continue
if (event.type === 'turn/start') return undefined
if (event.type === 'todo/write') return event.data.todos
}
return undefined
}

View File

@@ -69,7 +69,10 @@ describe('createFixtureApi', () => {
// tail block still rides it — empty-log cut at -1, the host convention.
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
expect(empty.result.value).toEqual({ events: [], hasMore: false, projections: { asOfSeq: -1, values: {} } })
// Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write.
expect(empty.result.value).toEqual({
events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } },
})
})
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {

View File

@@ -2,5 +2,5 @@
# 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/client/runtime/README.md
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表

View File

@@ -212,6 +212,19 @@ export class SessionManager {
session.handleBlank(s.blank)
session.handleRunning(s.running)
}
// Seed each row's projection baseline into the per-session value
// store (cold titles surface without opening the session). Per-key
// apply, not seed(): the list block is a partial baseline — the
// cold cache serves only version-matching keys — so an absent key
// must not clear; higher-seq-wins still keeps a stale list block
// from overwriting a newer push frame or tail baseline.
for (const s of result.value.items) {
const block = s.projections
if (block === undefined) continue
const store = this.projectionStore(s.sessionId)
const values = block.values as Record<string, unknown>
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
}
} else {
this.listState = 'error'
this.listError = result.error

View File

@@ -160,6 +160,28 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// A push frame landed before the list (S2's title is newer than the block's cut).
manager.handleMuxEnvelope({
rpcId: 'push-newer' as never,
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
})
api.onList = () => Promise.resolve(ok({
items: [
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
] as never[],
}))
await manager.refreshList()
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))

View File

@@ -2,5 +2,5 @@
# 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/client/ui-conversation/README.md
README.md: a04c20f225c731581accbe8c12c52a5e7597029a
README.zh.md: f9e6a635ea6090c87a66f029785af214025b9bda
README.md: 51ddecf93240c2196483d3fb2bcfaca4104da31a
README.zh.md: d98cbcc69b875d2f426d9bdd9f2fa81874ec614a

View File

@@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -12,7 +12,7 @@
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是计划条`useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。

View File

@@ -1,9 +1,9 @@
// TodoPanel: persistent plan strip above the composer (the web counterpart
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
// the next turn/start) — no data of its own, hidden while the list is empty.
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
// dock adapter does the selecting, so the panel takes the plain list and stays
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'

View File

@@ -518,11 +518,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen; malformed identified messages reject before any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */',
jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen. Coordinator-backed implementations upgrade supported pre-identity\n * message events before validation; other malformed messages reject before\n * any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */',
},
{
signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with deeply frozen identified messages, so observers cannot mutate message\n * identity/content or backend-owned state. Malformed identified messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */',
jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */',
},
{
signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */',
},
{
signature: 'abstract list(signal?: AbortSignal): Promise<SessionHeader[]>',
@@ -534,6 +538,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'sessionProjectionCache',
summary: 'The persisted projection cache service.',
methods: [
{
signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined',
jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), each cut carried with its watermark\n * so a client value store can seed under its higher-seq-wins rule — as\n * stale as the last durable checkpoint but never wrong, and never from an\n * unrelated log (the caller\'s header is the identity witness). Fresher\n * paths (the history tail baseline, {@link coldSnapshot}) supersede these\n * values whenever a session is actually opened.\n * @param meta - the listed session\'s header (identity witness; no log read).\n * @returns the cut (`asOfSeq` = lowest served-row watermark), or\n * `undefined` when no usable row exists for this lifecycle.\n */',
},
{
signature: 'async write(session: Session): Promise<void>',
jsDoc: '/**\n * Durably checkpoint one live session NOW (both mandatory points call\n * this; tests and carriers may too). The registry cut is snapshotted at\n * this boundary (states are live references), then the whole record is\n * replaced. NOT fail-soft — callers on the fail-soft paths contain it.\n * @param session - the live session to checkpoint.\n * @returns resolution after durability and event emission.\n */',
},
{
signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>',
jsDoc: '/**\n * Cold-read one persisted session\'s projections with zero full-log load:\n * cached rows + a persistence `readFrom` tail from the registry\'s restore\n * floor, refolded by the registry and written back (fail-soft) so the next\n * cold read starts closer. A cache row invalidated by a shrunk log\n * (crash-repair truncation) triggers one full re-read from seq 0 — the\n * ladder\'s slow rung, still no crash. Rejects when the session has no\n * persisted log (`not found` from the persistence seam).\n * @param id - the persisted session to read.\n * @param signal - optional cancellation for the persistence reads.\n * @returns the snapshot cut at the stored log end.\n */',
},
],
},
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
@@ -550,6 +572,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'snapshot(session: Session): ProjectionSnapshot',
jsDoc: '/**\n * One consistent cut over every registered unit for one session, read from\n * the watermark cache (missing cells fold lazily over the in-memory log).\n * Fully synchronous — every value and `asOfSeq` reflect the same log\n * position. Each value passes its unit\'s schema before leaving.\n * @param session - the session whose projection values are read.\n * @returns the snapshot; `values` is empty when no unit is registered.\n */',
},
{
signature: 'checkpoint(session: Session): ProjectionCheckpoint',
jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {ver, seq, val})` part of the durable\n * `(sessionId, key, ver, seq, val)`\n * rows. Every `val` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */',
},
{
signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined',
jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */',
},
{
signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>',
jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `ver` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */',
},
{
signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq`\n * (`seq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`seq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */',
},
],
},
{
@@ -1855,6 +1893,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ProjectionChangeListener',
declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: number) => void;',
},
{
name: 'ProjectionCheckpoint',
declaration: 'export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>;',
},
{
name: 'ProjectionCheckpointRow',
declaration: 'export interface ProjectionCheckpointRow {\n ver: number;\n seq: number;\n val: unknown;\n}',
},
{
name: 'ProjectionDefinition',
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {\n key: K;\n schema: ZodType<SessionProjectionMap[K]>;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}',

View File

@@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -77,6 +77,65 @@ function throwUnknown(value: unknown): never {
}
describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
it('resumes a session persisted before messages gained identities', async () => {
const sessionId = SessionId('pre-identity-resume')
const first = await persistentHarness(new MockAdapter([]))
await first.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
})
await first.ctx.sessionPersistence.append(sessionId, [
{ 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: 'old question' }], 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: 'old answer' }],
provenance: { provider: 'mock', model: 'mock' },
},
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' } } },
] as unknown as SessionEvent[])
await first.ctx.fiber.dispose()
const ctx = await mountPersistentHarness(first.root, new MockAdapter([textResponse('new answer')]))
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(handle.agent.session.deriveMessages()).toMatchObject([
{ id: `legacy-message:${sessionId}:1`, role: 'user' },
{ id: `legacy-message:${sessionId}:3`, role: 'assistant' },
])
handle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'new question' }],
source: { kind: 'user' },
}))
await waitForIdle(ctx, handle.agent)
expect(handle.agent.session.deriveMessages()).toHaveLength(4)
expect(handle.agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
})
await handle.dispose()
await ctx.fiber.dispose()
})
it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
const sessionId = SessionId('unknown-resume-failure-s')
const root = await persistSession(sessionId)

View File

@@ -2,5 +2,5 @@
# 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/core/session/README.md
README.md: 40516d12180de9c30efd40fdffa873da20ddacb3
README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7
README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412
README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989

View File

@@ -142,5 +142,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.

View File

@@ -142,5 +142,5 @@
- **会话分支/树**pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
- **`TurnEndReasonMap` 不含 ACPAgent Client Protocol命名的 `refusal``max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。

View File

@@ -2,5 +2,5 @@
# 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/host/apiproxy/README.md
README.md: 6b4691164ceb82a68c94e5a71d2f44fcfa1634af
README.zh.md: b28b57097a251ee18295a3f9a1ae41b00efe2db2
README.md: c20887b73b9b9deb278db30d34d84df07257d664
README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341

View File

@@ -22,8 +22,6 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)

View File

@@ -22,8 +22,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)

View File

@@ -47,6 +47,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",

View File

@@ -30,6 +30,8 @@ import type {
} from './api/index.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
import type {} from '@deepseek-ai/dsh-session-projection-cache'
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill'
@@ -297,6 +299,28 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u
return registry.snapshot(agent.session)
}
/**
* The projection baseline of one session.list row, fail-soft: attached
* sessions cut the registry's live watermark cache; cold sessions view the
* persisted projection cache's identity-checked stored rows (zero log loads
* either way — the listing use case the cache exists for). The block shape
* (values + asOfSeq) matches the history tail's, so a client seeds its
* value store under the same higher-seq-wins rule. Any failure — and an
* empty value set — yields an absent block: a listing without projections
* is degraded, never broken.
*/
function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
try {
const block = session !== undefined
? ctx.get('sessionProjections')?.snapshot(session)
: ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
} catch (error) {
ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
return undefined
}
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
@@ -654,13 +678,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async list(request) {
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
return summarize(session, agent?.status === 'running')
const projections = listProjectionsFor(ctx, session.header, session)
return {
...summarize(session, agent?.status === 'running'),
...projections === undefined ? {} : { projections },
}
})
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
items.push(...await Promise.all(cold.map(async (meta) => {
// Cold rows read the persisted projection cache only — never a
// log load; a session without a cache row simply has no column.
const projections = listProjectionsFor(ctx, meta, undefined)
return {
...await summarizeCold(persistence, meta),
...projections === undefined ? {} : { projections },
}
})))
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return ok(request, { items })

View File

@@ -37,7 +37,7 @@ export const sessionEventSchema = z.object({
surfaceOp: z.unknown().optional(),
}) as unknown as z.ZodType<SessionEvent>
/** SessionSummary row of session.list. */
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
export const sessionSummarySchema = z.object({
sessionId: sessionIdSchema,
updatedAt: z.number(),
@@ -45,7 +45,8 @@ export const sessionSummarySchema = z.object({
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
cwd: z.string().optional(),
}) satisfies z.ZodType<Wire<SessionSummary>>
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
}) as unknown as z.ZodType<Wire<SessionSummary>>
/** session.list request payload (cursor is a reserved seat, unimplemented in v1). */
export const sessionListRequestSchema = z.object({
@@ -53,9 +54,9 @@ export const sessionListRequestSchema = z.object({
}) satisfies z.ZodType<Wire<RequestPayload<'session.list'>>>
/** session.list response value. */
export const sessionListValueSchema = z.object({
export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'>>> = z.object({
items: z.array(sessionSummarySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
})
/** session.create request payload (at most one of workspaceId / cwd). */
export const sessionCreateRequestSchema = z.object({

View File

@@ -143,6 +143,18 @@ export interface SessionSummary {
parentSessionId?: SessionId
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
cwd?: string
/**
* Projection baseline for this row, with zero log loads: attached sessions
* read the registry's live watermark cut; cold sessions read the persisted
* projection cache's stored rows — as stale as that session's last durable
* checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly
* seedable into the client's per-session value store under its
* higher-seq-wins rule (a list baseline can never overwrite a newer push
* frame). Absent when no value is available (no registry, no cache row for
* a cold session, or a fail-soft cache read miss); a listing client treats
* absence as "no title yet", exactly like a blank session.
*/
projections?: SessionProjectionsBlock
}
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */

View File

@@ -13,7 +13,7 @@ import { z } from 'zod'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
@@ -125,6 +125,82 @@ describe('session.history projections block', () => {
})
})
describe('session.list projections column', () => {
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 1)
const response = await api(ctx).sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === session.id)
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
})
it('omits the column entirely when no registry is mounted', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 1)
const response = await api(ctx).sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('serves cold rows from the persisted projection cache with zero log loads', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('session-cold-listing')
const load = () => { throw new Error('list must not load event logs') }
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
load,
inspect: load,
readFrom: load,
} as never)
ctx.provide('sessionProjectionCache', {
// The carrier hands the listed header through as the identity witness.
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
(meta.id === coldId && meta.createdAt === 5
? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
: undefined),
} as never)
const response = await api(ctx).sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === coldId)
expect(row?.running).toBe(false)
expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
})
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('session-cold-uncached')
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
} as never)
const response = await api(ctx).sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === coldId)
expect(row).toBeDefined()
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('a throwing column read degrades that row, never the listing', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register({
...lastUserUnit(),
view: () => { throw new Error('unit exploded') },
})
seedMessages(session, 1)
const response = await api(ctx).sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
expect(row !== undefined && 'projections' in row).toBe(false)
})
})
describe('session/projection push frame', () => {
/** Drain frames until `count` session/projection frames arrived. */
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {

View File

@@ -35,6 +35,9 @@
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../skill/skill"
},

View File

@@ -22,6 +22,9 @@ class TestPersistence extends SessionPersistence {
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([]) }
}

View File

@@ -134,6 +134,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.coordinator.inspect(id, signal)
}
// JSONL is sequential media: no loadStoredFrom hook, so the coordinator
// parses the stored prefix (both encodings) and skips forward to fromSeq.
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.readFrom(id, fromSeq, signal)
}
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.

View File

@@ -16,7 +16,7 @@ import { dirname, resolve } from 'node:path'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type StoredPrefix,
type StoredPrefix, type StoredSuffix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -161,6 +161,10 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.coordinator.inspect(id, signal)
}
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.readFrom(id, fromSeq, signal)
}
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.
@@ -171,6 +175,26 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.readPrefix(id, signal)
}
/**
* Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the
* read scales with the suffix, not the log. Torn rows past the preserved
* region are dropped, never repaired (non-mutating read).
*/
async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined> {
signal?.throwIfAborted()
await this.ready
signal?.throwIfAborted()
const row = this.rowFor(id)
if (row === undefined) return undefined
const meta = rowToMeta(row)
const eventRows = this.db
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
.all(id, fromSeq) as unknown as EventRow[]
signal?.throwIfAborted()
const { preserved } = scanRows(eventRows, fromSeq)
return { meta, events: preserved }
}
/**
* Read a session's row + ordered events into a {@link StoredPrefix}. The
* torn-tail marker is the seq from which a never-committed tail must be deleted

View File

@@ -213,10 +213,12 @@ export function rowToEvent(row: EventRow): SessionEvent {
* the committed region rejects.
*
* @param rows - one session's event rows, ordered by seq ascending.
* @param base - the seq the first row is expected to carry; `0` for a whole
* log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
* @returns the preserved event prefix, plus `tornFrom` — the seq the physical
* delete starts at — when a torn tail exists.
*/
export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } {
// Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
// (The seq/type COLUMNS are always present even when `data` is corrupt.)
interface Parsed { ok: boolean; event?: SessionEvent }
@@ -244,8 +246,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
break // torn tail fragment after the last turn/end — stop, tolerate
}
if (p.event.seq !== i) {
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`)
if (p.event.seq !== base + i) {
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`)
break // gap after the last turn/end — torn tail, stop
}
preserved.push(p.event)
@@ -253,5 +255,5 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
// Any rows past the preserved prefix are a never-committed torn tail; their
// first seq is the deletion point for load's physical repair.
return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved }
return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved }
}

View File

@@ -2,5 +2,5 @@
# 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-persistence/README.md
README.md: 08d8adac8040747a6dac01dbc41525073f17060c
README.zh.md: 7676f27a1aa934eb3472e1b32b9ecd55d460fb63
README.md: a8a4f14c8613a7e51bcf467e816b7f7bdb7ea80b
README.zh.md: 369e8a01b8ac411ed9acfbac86b9b34db8037c1f

View File

@@ -13,8 +13,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed messages, and unknown `version` reject. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix; sequential backends (JSONL) still parse the whole artifact and skip forward — the primitive bounds what is returned and refolded, not every backend's physical read. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. |
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
@@ -33,6 +34,8 @@ Each `session/event` copies its event into the session controller and starts an
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
Backend reads normalize pre-identity `user/message`, `assistant/message`, `tool/result`, and `steering/message` payloads before current-shape validation. Each imported message receives the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. The coordinator uses the same normalized view for `load`, `inspect`, ownerless-state claims, and HMR prefix adoption, so resumed sessions can append current events without a false prefix collision. Storage remains append-only: the read does not rewrite old records, and every later append uses the current shape. This is the narrow import exception from the [pre-identity message recovery decision](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md), not a general v0 migration promise.
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it.
@@ -43,6 +46,7 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
|---|---|
| `name` | Backend label for the dispose-failure `AggregateError`. |
| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
| `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. |
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
| `list(signal?)` | List all stored metadata, observing optional cancellation. |

View File

@@ -13,8 +13,9 @@
| `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 |
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的消息和未知 `version` 会被拒绝。 |
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 |
| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 |
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端SQLite只读后缀顺序后端JSONL仍解析整个产物并向前跳过——原语约束的是返回和重折叠的量不是每个后端的物理读取。用于从水位续折尾部的 checkpoint 消费者(例如持久投影缓存)。 |
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和不透明品牌化每日志修订不加载事件日志。日志及其后端存储不变时修订保持相等append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 |
@@ -33,6 +34,8 @@
崩溃修复只适用于冷状态。对于实时 id`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
后端读取会在当前形状验证前,规范化消息标识机制引入前的 `user/message``assistant/message``tool/result` 以及 steering中途引导对应的 `steering/message` 载荷。每条导入消息都会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load``inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
实时会话发出 `session/disposed` 时,协调器等待其 controller串行化最终 drain然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中使后端拆卸可重试。后端拆卸先停止事件接纳flush 每个剩余 controller等待每 id 操作,最后才关闭存储句柄。
无副作用 `locate` 和轻量 `listSnapshots` 查询仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方的精确信号传入后端发现,使观察者可在不脱离该工作的情况下取消。
@@ -43,6 +46,7 @@
|---|---|
| `name` | dispose 失败 `AggregateError` 的后端标签。 |
| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于 resume/load、非变更 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 |
| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 |
| `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 |
| `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load截断 + closer和实时接管仅截断使用。 |
| `list(signal?)` | 列出全部已存储元数据,观察可选取消。 |

View File

@@ -25,6 +25,17 @@ export interface StoredPrefix<TornMarker = unknown> {
tornMarker?: TornMarker
}
/**
* A stored session's header plus the events at or past a requested seq — the
* return shape of the optional seek-capable
* {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no
* torn marker: there is nothing to repair.
*/
export interface StoredSuffix {
meta: SessionHeader
events: SessionEvent[]
}
/**
* The storage seam between {@link PersistenceCoordinator} and a concrete
* backend: the minimal set of durable primitives the orchestration calls. A
@@ -50,6 +61,22 @@ export interface PersistenceBackend<TornMarker = unknown> {
*/
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>
/**
* Optional seek-capable suffix read behind the service's `readFrom`: return
* the header plus the stored events with `seq >= fromSeq` without reading
* the whole log. A backend whose medium can address events by seq (SQLite)
* implements this so `readFrom` scales with the suffix; sequential backends
* omit it and the coordinator falls back to {@link loadStored} plus a
* forward skip. Non-mutating (no truncation, no closers). Validation of the
* region strictly below `fromSeq` is limited to seq contiguity — the
* service contract scopes this read to the suffix.
* @param id - persisted session id to resolve.
* @param fromSeq - first event seq to include (non-negative safe integer,
* validated by the coordinator before this hook runs).
* @param signal - optional cancellation for backend read work.
*/
loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined>
/**
* Durably append a CONTIGUOUS batch, lazily materializing the session first
* when `!isMaterialized`. The materialize-write and the first event batch MUST
@@ -146,10 +173,142 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId):
}
}
/** Materialize stored events as validated snapshots with immutable messages. */
/** Return an object record without widening arrays into message payloads. */
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
type PersistedMessageId = SessionEvent<'user/message'>['data']['id']
/** Mint the stable import identity for a message persisted before identities existed. */
function legacyMessageId(id: SessionId, seq: number): PersistedMessageId {
return `legacy-message:${id}:${seq}` as PersistedMessageId
}
/** Read a replacement target while leaving malformed surface metadata to the session validator. */
function replacementStart(event: SessionEvent): number | undefined {
const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp)
return op?.['op'] === 'replace' && typeof op['start'] === 'number'
? op['start']
: undefined
}
/**
* Upgrade one pre-identity message event into the current wrapper shape.
* Current-looking malformed events remain untouched so validation rejects them
* instead of disguising corruption as legacy data.
*/
function migrateLegacyMessageEvent(
event: SessionEvent,
id: SessionId,
messageIds: ReadonlyMap<number, PersistedMessageId>,
): SessionEvent {
const data = asRecord(event.data)
if (data === undefined) return event
switch (event.type) {
case 'user/message': {
if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role')
|| Object.hasOwn(data, 'message')
|| !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event
return {
...event,
data: {
...data,
id: legacyMessageId(id, event.seq),
role: 'user',
},
} as SessionEvent
}
case 'assistant/message': {
if (Object.hasOwn(data, 'message')
|| !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event
const { content, provenance, ...eventData } = data
return {
...event,
data: {
...eventData,
message: {
id: legacyMessageId(id, event.seq),
role: 'assistant',
content,
source: {
...asRecord(provenance),
kind: 'model',
},
},
},
} as SessionEvent
}
case 'tool/result': {
if (Object.hasOwn(data, 'message')
|| !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content')
|| !Object.hasOwn(data, 'isError')) return event
const { callId, content, isError, ...eventData } = data
const inheritedId = replacementStart(event)
return {
...event,
data: {
...eventData,
message: {
id: inheritedId === undefined
? legacyMessageId(id, event.seq)
: messageIds.get(inheritedId),
role: 'user',
content: [{
type: 'tool-result',
toolCallId: callId,
content,
isError,
}],
source: {
kind: 'tool',
callId,
},
},
},
} as SessionEvent
}
case 'steering/message': {
if (Object.hasOwn(data, 'message')
|| !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event
const { content, source, ...eventData } = data
return {
...event,
data: {
...eventData,
message: {
id: legacyMessageId(id, event.seq),
role: 'user',
content,
source,
},
},
} as SessionEvent
}
default:
return event
}
}
/** Read the identified message carried by one validated current event. */
function eventMessageId(event: SessionEvent): PersistedMessageId | undefined {
const data = asRecord(event.data)
const message = event.type === 'user/message' ? data : asRecord(data?.['message'])
return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined
}
/** Materialize stored events as upgraded, validated snapshots with immutable messages. */
function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] {
assertSupportedEvents(events, id)
return events.map(snapshotSessionEvent)
const messageIds = new Map<number, PersistedMessageId>()
return events.map((event) => {
const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(event, id, messageIds))
const messageId = eventMessageId(snapshot)
if (messageId !== undefined) messageIds.set(snapshot.seq, messageId)
return snapshot
})
}
/**
@@ -325,6 +484,52 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
}
/**
* Read the stored events from `fromSeq` onward, detached and non-mutating
* (the read-from-seq primitive behind the service's `readFrom`). Runs on
* the same per-id chain as writes; a backend with the seek-capable
* {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix,
* every other backend reads its stored prefix and skips forward here.
* @param id - persisted session to read.
* @param fromSeq - first event seq to include; a non-negative safe integer.
* @param signal - optional cancellation for queued and backend read work.
* @returns stored header and the valid stored events with `seq >= fromSeq`.
*/
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) {
return Promise.reject(new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`))
}
const retired = Promise.resolve(this.retirements.get(id))
const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false)
return waited.then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal))
}
private async readFromCore(
id: SessionId,
fromSeq: number,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
signal?.throwIfAborted()
if (this.backend.loadStoredFrom !== undefined) {
let suffix: StoredSuffix | undefined
try {
suffix = await this.backend.loadStoredFrom(id, fromSeq, signal)
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
throw error
}
signal?.throwIfAborted()
if (suffix === undefined) throw new Error(`session "${id}" not found`)
this.assertStoredId(id, suffix.meta)
this.assertVersion(suffix.meta)
assertSupportedEvents(suffix.events, id)
return { meta: structuredClone(suffix.meta), events: structuredClone(suffix.events) }
}
const whole = await this.inspectCore(id, signal)
// Sequential fallback: contiguous seqs from 0 make the suffix an index slice.
return { meta: whole.meta, events: whole.events.slice(fromSeq) }
}
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const stored = await this.backend.loadStored(id)
if (stored === undefined) throw new Error(`session "${id}" not found`)
@@ -526,7 +731,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */
if (stored === undefined) return false
this.assertStoredId(id, stored.meta)
return seedCoversPrefix(seed, stored.events.slice(0, cursor))
return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor))
}
/**
@@ -614,19 +819,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
this.assertVersion(meta)
assertSupportedEvents(events, session.header.id)
if (!seedCoversPrefix(seed, events)) {
const storedEvents = snapshotStoredEvents(events, session.header.id)
if (!seedCoversPrefix(seed, storedEvents)) {
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
}
// Truncate-only repair (no closers): the open turn is NOT closed here.
if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, [])
this.states.set(session.header.id, {
meta: { ...meta },
cursor: events.length,
cursor: storedEvents.length,
materialized: true,
owner: session,
})
const suffix = seed.slice(events.length)
const suffix = seed.slice(storedEvents.length)
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
}

View File

@@ -23,7 +23,7 @@ export interface SessionPersistenceSnapshot {
// The backend-agnostic write-path orchestration first-party backends compose.
export { PersistenceCoordinator } from './coordinator.ts'
export type { PersistenceBackend, StoredPrefix } from './coordinator.ts'
export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts'
declare module 'cordis' {
interface Context {
@@ -93,7 +93,9 @@ export abstract class SessionPersistence extends Service {
* A coordinator-backed cold load reserves the identity across storage awaits,
* so concurrent publication of a same-id live Session rejects.
* Returned events are detached, and every identified message is deeply
* frozen; malformed identified messages reject before any stored event is returned.
* frozen. Coordinator-backed implementations upgrade supported pre-identity
* message events before validation; other malformed messages reject before
* any stored event is returned.
* @param id - the persisted session to reload.
* @returns the header and a log ending on a balanced `turn/end`.
*/
@@ -103,14 +105,35 @@ export abstract class SessionPersistence extends Service {
* Inspect a header and its valid contiguous stored prefix without repairing
* a torn tail, closing an interrupted turn, or publishing coordinator state.
* This read is serialized with writes for the same id and returns detached
* values with deeply frozen identified messages, so observers cannot mutate message
* identity/content or backend-owned state. Malformed identified messages reject.
* values with upgraded, deeply frozen identified messages, so observers
* cannot mutate message identity/content or backend-owned state. Other
* malformed messages reject.
* @param id - the persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns the header and valid stored event prefix exactly as observed.
*/
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
/**
* Read the stored events from `fromSeq` onward — the read-from-seq
* primitive for read models that resume from a watermark (e.g. a persisted
* projection cache folding only the tail past its checkpoint). Like
* {@link inspect} it is non-mutating and detached: no torn-tail truncation,
* no synthetic closers, no coordinator-state publication; only events from
* the valid contiguous stored prefix are returned, so a torn fragment never
* reaches the caller. `fromSeq` at or beyond the stored prefix returns an
* empty event list (never an error). Backends whose medium can seek by seq
* (SQLite) read only the suffix; sequential media (JSONL, both encodings)
* still parse the whole artifact and skip forward — the primitive bounds
* what is RETURNED and refolded, not every backend's physical read.
* @param id - the persisted session to read.
* @param fromSeq - first event seq to include; a non-negative safe integer.
* @param signal - optional cancellation for queued and backend read work.
* @returns the header and the stored events with `seq >= fromSeq`.
*/
abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal):
Promise<{ meta: SessionHeader; events: SessionEvent[] }>
/**
* Lightweight listing from metadata, without a full-log parse.
* @param signal - optional cancellation for backend listing work.

View File

@@ -289,6 +289,43 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
.rejects.toBe(reason)
await expect(persistence.readFrom(SessionId('cancelled-read-from'), 0, controller.signal))
.rejects.toBe(reason)
} finally {
await dispose()
}
})
it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('read-from', '/work')
const log = oneTurnLog()
await persistence.create(m)
await persistence.append(m.id, log)
const whole = await persistence.readFrom(m.id, 0)
expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' })
expect(whole.events).toEqual(log)
const suffix = await persistence.readFrom(m.id, 3)
expect(suffix.events).toEqual(log.slice(3))
expect(suffix.events[0]?.seq).toBe(3)
// At/past the stored end: an empty tail, never an error.
await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] })
await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] })
// Non-mutating: an interrupted-turn log is served as stored, no closers.
await persistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
])
const tail = await persistence.readFrom(m.id, 6)
expect(tail.events.map(event => event.type)).toEqual(['turn/start'])
await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found')
await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer')
await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer')
} finally {
await dispose()
}

View File

@@ -13,8 +13,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { meta, oneTurnLog, appendLog } from './contract.ts'
/**
@@ -45,6 +45,80 @@ function send(session: Session, events: readonly SessionEvent[]): void {
appendLog(session, events)
}
/** A valid persisted log from immediately before messages gained wrappers and identities. */
function legacyMessageLog(): 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' } },
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: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
},
surfaceOp: 'append',
},
{
type: 'tool/call',
seq: 4,
time: 5,
data: { turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}' },
},
{
type: 'tool/result',
seq: 5,
time: 6,
data: {
turn: 1,
step: 1,
callId: 'call-1',
content: [{ type: 'text', text: 'full result' }],
isError: false,
},
sourceEventSeqs: [4],
surfaceOp: 'append',
},
{
type: 'steering/message',
seq: 6,
time: 7,
data: {
turn: 1,
content: [{ type: 'text', text: 'continue' }],
source: { kind: 'plugin', plugin: 'test' },
},
surfaceOp: 'append',
},
{
type: 'tool/result',
seq: 7,
time: 8,
data: {
turn: 1,
step: 1,
callId: 'call-1',
content: [{ type: 'text', text: 'pruned' }],
isError: false,
},
sourceEventSeqs: [5],
surfaceOp: { op: 'replace', start: 5, end: 5 },
},
{ type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
] as unknown as SessionEvent[]
}
/** A live session created inside its OWN fiber, so it survives a backend reload. */
async function liveSessionInFiber(
ctx: Context, id: string, cwd: string | undefined,
@@ -269,6 +343,48 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('loads pre-identity message logs into resumable current sessions', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const id = SessionId('legacy-message-load')
await ctx.sessionPersistence.create(meta(id, WORK))
await ctx.sessionPersistence.append(id, legacyMessageLog())
for (const snapshot of [
await ctx.sessionPersistence.inspect(id),
await ctx.sessionPersistence.load(id),
]) {
const messages = snapshot.events.flatMap((event) => {
if (event.type === 'user/message') return [event.data]
if (event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message') return [event.data.message]
return []
})
expect(messages.map(message => message.id)).toEqual([
`legacy-message:${id}:1`,
`legacy-message:${id}:3`,
`legacy-message:${id}:5`,
`legacy-message:${id}:6`,
`legacy-message:${id}:5`,
])
expect(messages.every(message => Object.isFrozen(message))).toBe(true)
const resumed = new Session(id, snapshot.events, snapshot.meta)
expect(resumed.deriveMessages().map(message => message.id)).toEqual([
`legacy-message:${id}:1`,
`legacy-message:${id}:3`,
`legacy-message:${id}:5`,
`legacy-message:${id}:6`,
])
}
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('rejects malformed persisted message events before returning them', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
@@ -292,6 +408,31 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
.rejects.toThrow('message must have role "user"')
await expect(ctx.sessionPersistence.load(id))
.rejects.toThrow('message must have role "user"')
for (const type of ['tool/result', 'steering/message'] as const) {
const malformedId = SessionId(`invalid-${type}`)
await ctx.sessionPersistence.create(meta(malformedId, WORK))
await ctx.sessionPersistence.append(malformedId, [{
type,
seq: 0,
time: 1,
surfaceOp: 'append',
data: { message: null },
} as unknown as SessionEvent])
await expect(ctx.sessionPersistence.inspect(malformedId))
.rejects.toThrow('lacks an identified message')
}
const pluginId = SessionId('non-object-plugin-event')
await ctx.sessionPersistence.create(meta(pluginId, WORK))
await ctx.sessionPersistence.append(pluginId, [{
type: 'plugin/test',
seq: 0,
time: 1,
data: null,
} as unknown as SessionEvent])
await expect(ctx.sessionPersistence.inspect(pluginId))
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] })
} finally {
await fiber.dispose()
await fix.cleanup()

View File

@@ -99,6 +99,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
return this.coordinator.inspect(id, signal)
}
readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.readFrom(id, fromSeq, signal)
}
// --- PersistenceBackend hooks (the Map storage primitives) ---
// A Map-backed store has no torn tails, so `tornMarker` is never set.
@@ -157,6 +161,13 @@ class ControlledBackend implements PersistenceBackend<never> {
repairAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
/** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */
seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredPrefix<never> | undefined>
loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
if (this.seekHook === undefined) throw new Error('seekHook not configured for this test')
return this.seekHook(id, fromSeq, signal)
}
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts, signal)
@@ -453,6 +464,58 @@ describe('PersistenceCoordinator observation cancellation', () => {
}
})
it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('seek-read-from')
const log = oneTurnLog()
backend.store.set(id, { meta: meta(id), events: log })
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
// Happy path through the hook: only the suffix comes back, detached.
backend.seekHook = async (hookId, fromSeq) => {
const entry = backend.store.get(hookId)
if (entry === undefined) return undefined
return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) }
}
const suffix = await coordinator.readFrom(id, 3)
expect(suffix.events).toEqual(log.slice(3))
// The hook's undefined is the seam's not-found.
await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found')
// A hook failure with no cancellation in play propagates as-is.
const hookFailure = new Error('seek backend exploded')
backend.seekHook = () => Promise.reject(hookFailure)
await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure)
// A hook failure after cancellation surfaces the caller's abort reason,
// not the backend's internal teardown error. The abort fires only once
// the hook is provably entered, so the failure exercises the catch (not
// the pre-invocation throwIfAborted).
const controller = new AbortController()
const reason = new Error('read-from cancelled mid-hook')
let hookEntered = false
backend.seekHook = async (_hookId, _fromSeq, signal) => {
hookEntered = true
await new Promise<void>((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) })
throw new Error('backend teardown after abort')
}
const pending = coordinator.readFrom(id, 0, controller.signal)
const observed = pending.catch((error: unknown) => error)
await vi.waitFor(() => { expect(hookEntered).toBe(true) })
controller.abort(reason)
expect(await observed).toBe(reason)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('rejects a cancelled inspect while an in-flight retirement drain is still pending', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -537,6 +600,66 @@ describe('PersistenceCoordinator retirement', () => {
}
})
it('a superseded retirement leaves the successor lifecycle\'s pending drain in place', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
const readGate = Promise.withResolvers<boolean>()
try {
const id = SessionId('superseded-retirement')
// First lifecycle: unmaterialized (zero events), so a same-id successor
// may legally reclaim the abandoned id later.
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await ctx.sessions.flush(first)
// Occupy the per-id serialize chain with a gated read: everything the
// two retirements queue stays pending behind it. (Attempt counting
// starts here — an absent beforeLoadStored short-circuits the optional
// call without evaluating its ++ argument.)
backend.beforeLoadStored = async (attempt) => {
if (attempt === 1) await readGate.promise
}
const parked = coordinator.inspect(id).catch((error: unknown) => error)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
// First retirement queues behind the gate and stays pending.
await firstFiber.dispose()
await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) })
const firstRetirement = internals.retirements.get(id)
// Successor lifecycle retires while the first drain is still in flight:
// retire() replaces the map entry synchronously.
const secondFiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.sessions.create(id)
}, { inject: ['sessions'] }))
await secondFiber.dispose()
await vi.waitFor(() => {
expect(internals.retirements.get(id)).not.toBe(firstRetirement)
})
// Release the chain: the first drain settles and its forget() must not
// delete the successor's entry (exact-entry guard); the successor's own
// forget() then clears the map.
readGate.resolve(true)
expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed
await firstRetirement
await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) })
} finally {
readGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('a replacement queued before retirement cleanup still collides with the live owner', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -2,5 +2,5 @@
# 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-projection/README.md
README.md: 81c67d56e136ba4853e86d889b485d4df80ac1fe
README.zh.md: 72e23b78a48a989f355f9be3d34d81a440ca1d04
README.md: ae80a905705d205adb4a1ee66c72fa28d0d8b6d6
README.zh.md: 97e25dd16caeeb444f5f5309eed3341d422fa1b1

View File

@@ -7,3 +7,4 @@ Session-projection capability family: the seam through which domain host plugins
| Package | ctx key | Role |
|---|---|---|
| [`session-projection`](session-projection/README.md) | `sessionProjections` | The interface package: the merge-extensible `SessionProjectionMap` type table, the `ProjectionDefinition` unit contract, and the eagerly driven registry carriers read synchronously |
| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | Persisted projection cache: durable per-session unit checkpoints over the domain data form, throttled write-behind with mandatory turn/end + detach points, and the cold-read ladder (cache row + persistence tail replay) |

View File

@@ -7,3 +7,4 @@
| 包 | ctx 键 | 职责 |
|---|---|---|
| [`session-projection`](session-projection/README.md) | `sessionProjections` | 接口包packagemerge-extensible 的 `SessionProjectionMap` 类型表、`ProjectionDefinition` 单元契约,以及供载体同步读取的正向驱动注册表 |
| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | 持久投影缓存:基于域数据形态的按会话单元 checkpoint 持久化、带 turn/end + detach 两个必写点的节流后写,以及冷读阶梯(缓存行 + 持久化尾部重放) |

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-projection/session-projection-cache/README.md
README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4

View File

@@ -0,0 +1,62 @@
# @deepseek-ai/dsh-session-projection-cache
English | [中文](README.zh.md)
The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
- **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
## Write policy
Two mandatory points, throttled in between:
| Trigger | Nature |
|---|---|
| `turn/end` | Mandatory — the turn-final value is what cold reads want. |
| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. |
| `writeEveryEvents` committed events | Config throttle (count). |
| `writeIntervalMs` since the first dirty event | Config throttle (interval). |
Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
## Listing read (`cachedSnapshot(meta)`)
The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column.
## Cold read (`coldSnapshot(id, signal?)`)
The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)``sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`.
`write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
## Composition
```yaml
- id: session-projection-cache
name: '@deepseek-ai/dsh-session-projection-cache'
config:
writeEveryEvents: 200
writeIntervalMs: 5000
```
Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
## Model Experience
None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
#### KV Cache effect
None; the cache never assembles or sends provider requests.
## Known Limitations and Deferred Work
- **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
- **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates.

View File

@@ -0,0 +1,62 @@
# @deepseek-ai/dsh-session-projection-cache
[English](README.md) | 中文
持久投影缓存(`ctx.sessionProjectionCache`把每个已注册投影单元的状态持久化为检查点checkpoint基于域数据形态domain data form每会话一条记录`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)persisted projection cache 一节)。
一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。
- **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt``cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。
## 写策略
两个必写点,其间节流:
| 触发 | 性质 |
|---|---|
| `turn/end` | 必写——冷读要的正是轮次终值。 |
| 会话销毁detach | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 |
| 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |
| 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 |
两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
## 列表读(`cachedSnapshot(meta)`
零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key,以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值仓时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行时返回 `undefined`api-proxy 列表载体将其转为列缺席。
## 冷读(`coldSnapshot(id, signal?)`
读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)``sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。
`write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。
## 组合
```yaml
- id: session-projection-cache
name: '@deepseek-ai/dsh-session-projection-cache'
config:
writeEveryEvents: 200
writeIntervalMs: 5000
```
注入 `storageDomain``sessionProjections``sessionPersistence``sessions`。没有这一行时,投影系统只跑 live水位缓存冷读在实现了它的载体处退回全量日志加载
## 模型体验
无,因为缓存只持久化并恢复 host 侧的、由已入日志会话状态派生的读模型不触碰任何提示词、消息、schema、流或工具结果。
#### KV 缓存影响
无;缓存从不组装或发送提供方请求。
## 已知局限与延后工作
- **没有淘汰或保留面**——记录按会话累积;清理存储的检查点是带外维护,与会话持久化本身同一立场。
- **间隔节流按会话粗粒度**——计时器在一次干净写入后的首个脏事件时武装;持续的低于阈值的涓流每个间隔写一次,不是滑动窗口。
- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。

View File

@@ -0,0 +1,50 @@
{
"name": "@deepseek-ai/dsh-session-projection-cache",
"description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
"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",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-storage-domain": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,300 @@
/**
* Persisted projection cache (`ctx.sessionProjectionCache`): durable
* checkpoints of every registered projection unit's state, one record per
* session on the domain data form (`session_projcache` domain — the shipped
* json backend lands it beside `workspace.json`). The cache is a fold
* shortcut, never an authority: a row is possibly stale (its `seq`
* says how stale) but never wrong, so every write path is fail-soft (a lost
* write costs a longer tail replay on the next cold read) and a
* `ver` mismatch discards the row instead of migrating it. Design
* authority: the session-projection RFC
* (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
* @module @deepseek-ai/dsh-session-projection-cache
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
// Empty type import: applies the package's cordis Context merge
// (`ctx.sessionPersistence`), which this service reads on the cold path.
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
import { projectionCacheDomainSpec } from './spec.ts'
import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts'
export type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
declare module 'cordis' {
interface Context {
sessionProjectionCache: SessionProjectionCache
}
}
/**
* Plugin config. Both throttle triggers are deployment choices with no
* universally correct value, so the composition states them explicitly
* (cordis.yml); the two mandatory write points (`turn/end` and session
* disposal) are policy, not tunables, and always fire.
*/
export interface Config {
/** Committed events per session that force a durable checkpoint write between mandatory points. */
writeEveryEvents: number
/** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
writeIntervalMs: number
}
export const Config: z<Config> = z.object({
writeEveryEvents: z.natural().min(1).required(),
writeIntervalMs: z.natural().min(1).required(),
})
/** Per-session write-behind bookkeeping (live sessions only; dropped at retire). */
interface DirtyState {
/** Committed events since the last durable write. */
pending: number
/** Interval trigger armed at the first dirty event after a clean write. */
timer: ReturnType<typeof setTimeout> | undefined
}
/**
* The persisted projection cache service. Opens the `session_projcache`
* domain at init, checkpoints live sessions on a throttled write-behind
* (count/interval triggers from {@link Config}) plus two mandatory points —
* `turn/end` and session disposal (the live-to-cold moment) — and serves the
* cold-read ladder: cached row, persistence `readFrom` tail, registry
* `restore`, durable write-back. Every durable write is fail-soft: failures
* log a warning and the cache self-heals on the next write or cold read.
*/
export class SessionProjectionCache extends Service {
static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions']
static Config: z<Config> = Config
private table?: KvTable<SessionId, CheckpointRecord>
private readonly dirty = new Map<Session, DirtyState>()
constructor(ctx: Context, public config: Config) {
super(ctx, 'sessionProjectionCache')
}
/** Open the domain and install the write-behind listeners. */
protected async [Service.init](): Promise<void> {
const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec)
this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose')
this.table = domain.table('sessions')
this.installWritePath()
}
/**
* The stored record for one session, accepted only when its bound log
* identity matches `expected`. A session id names a slot, not a lifecycle:
* a recreated id or a persistence store swapped under a surviving cache
* must not let an old record seed state folded from an unrelated log.
* Synchronous from the domain's in-memory state.
* @param id - the session whose record is read.
* @param expected - the log identity the caller holds (live or stored header).
* @returns the identity-matching record, or `undefined` (absent or unrelated).
*/
private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined {
const record = this.requireTable().get(id)
if (record === undefined) return undefined
return identityMatches(record.identity, expected) ? record : undefined
}
/**
* The zero-I/O listing read: whole values viewed straight from the stored
* rows (version-matching keys only), each cut carried with its watermark
* so a client value store can seed under its higher-seq-wins rule — as
* stale as the last durable checkpoint but never wrong, and never from an
* unrelated log (the caller's header is the identity witness). Fresher
* paths (the history tail baseline, {@link coldSnapshot}) supersede these
* values whenever a session is actually opened.
* @param meta - the listed session's header (identity witness; no log read).
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
* `undefined` when no usable row exists for this lifecycle.
*/
cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined {
const record = this.recordFor(meta.id, identityOf(meta))
if (record === undefined) return undefined
const values = this.ctx.sessionProjections.viewCheckpoint(record.rows)
const keys = Object.keys(values)
if (keys.length === 0) return undefined
// The block carries ONE cut: the lowest served watermark is the seq every
// value is at least current as of (under-claiming is safe under
// higher-seq-wins; over-claiming would let a stale value outrank pushes).
const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq))
return { asOfSeq, values }
}
/**
* Durably checkpoint one live session NOW (both mandatory points call
* this; tests and carriers may too). The registry cut is snapshotted at
* this boundary (states are live references), then the whole record is
* replaced. NOT fail-soft — callers on the fail-soft paths contain it.
* @param session - the live session to checkpoint.
* @returns resolution after durability and event emission.
*/
async write(session: Session): Promise<void> {
const rows = this.ctx.sessionProjections.checkpoint(session)
this.markClean(session)
// Durability barrier: the checkpoint cut was taken above, so flushing
// AFTER it guarantees every event inside the cut is durably logged
// before the cache row lands — a crash can leave the cache behind the
// log (longer tail replay) but never ahead of it (phantom values folded
// from events no stored log contains). At detach the store entry is
// already gone; persistence's own retirement drain covers that path and
// any residual overreach is caught by the cold read's anchored floor.
if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session)
await this.put(session.id, identityOf(session.header), rows)
}
/**
* Cold-read one persisted session's projections with zero full-log load:
* cached rows + a persistence `readFrom` tail from the registry's restore
* floor, refolded by the registry and written back (fail-soft) so the next
* cold read starts closer. A cache row invalidated by a shrunk log
* (crash-repair truncation) triggers one full re-read from seq 0 — the
* ladder's slow rung, still no crash. Rejects when the session has no
* persisted log (`not found` from the persistence seam).
* @param id - the persisted session to read.
* @param signal - optional cancellation for the persistence reads.
* @returns the snapshot cut at the stored log end.
*/
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
const record = this.requireTable().get(id)
const cached = record?.rows ?? {}
const floor = this.ctx.sessionProjections.restoreFloor(cached)
const persistence = this.ctx.sessionPersistence
if (floor === undefined) {
// No unit registered: nothing to fold, but the not-found contract must
// hold in this topology too — the probe read rejects for an absent log
// and dates the empty cut for a present one.
const probe = await persistence.readFrom(id, 0, signal)
return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
}
let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
const tail = await persistence.readFrom(id, floor, signal)
// The tail's stored header is the identity witness: a record bound to a
// different lifecycle (recreated id, swapped store) is discarded whole
// before any of its rows can seed a fold.
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
try {
if (!related) throw new Error('unrelated log identity')
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
} catch {
// The recoverable restore failures: an unrelated record, or a row
// overreaching the stored log end (or predating the floor). Both imply
// floor > 0 (baseSeq-0 restores never throw and an unrelated record
// still carried a usable watermark), so the full log is a fresh read.
const whole = await persistence.readFrom(id, 0, signal)
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
}
await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
return restored.snapshot
}
// --- write-behind (throttle + mandatory points) ---
private installWritePath(): void {
// Every committed event advances the dirty counter; turn/end is a
// mandatory point (the durable value most reads want is the turn-final
// one), count/interval throttle the in-turn stream.
this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type === 'turn/end') {
void this.flushSoft(session, 'turn/end')
return
}
const state = this.dirty.get(session) ?? { pending: 0, timer: undefined }
this.dirty.set(session, state)
state.pending += 1
if (state.pending >= this.config.writeEveryEvents) {
void this.flushSoft(session, 'count threshold')
return
}
state.timer ??= setTimeout(() => {
void this.flushSoft(session, 'interval')
}, this.config.writeIntervalMs)
})
// Detach (the live-to-cold moment): the second mandatory point. After
// this write the cold-read ladder serves the session from the cache.
// flushSoft's synchronous prefix reads and resets the dirty state, so
// dropping it (timer already cleared by markClean) right after is safe.
this.ctx.on('session/disposed', (session: Session) => {
void this.flushSoft(session, 'detach')
this.markClean(session)
this.dirty.delete(session)
})
// Clear pending timers with the plugin (their sessions outlive the cache).
this.ctx.effect(() => () => {
for (const state of this.dirty.values()) {
if (state.timer !== undefined) clearTimeout(state.timer)
}
this.dirty.clear()
}, 'sessionProjectionCache.timers')
}
/**
* One fail-soft durable checkpoint. Every caller has work by construction:
* the throttle triggers only fire dirty (markClean clears the timer with
* the counter) and the two mandatory points write unconditionally.
*/
private async flushSoft(session: Session, trigger: string): Promise<void> {
try {
await this.write(session)
} catch (error) {
this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`)
}
}
/** Reset one session's dirty bookkeeping (its checkpoint is being written). */
private markClean(session: Session): void {
const state = this.dirty.get(session)
if (state === undefined) return
state.pending = 0
if (state.timer !== undefined) {
clearTimeout(state.timer)
state.timer = undefined
}
}
/** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise<void> {
const detached = snapshotJsonValue(rows)
if (detached === undefined) {
throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)')
}
await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
}
/** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise<void> {
try {
await this.put(id, identity, rows)
} catch (error) {
this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`)
}
}
private requireTable(): KvTable<SessionId, CheckpointRecord> {
/* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
if (this.table === undefined) throw new Error('session projection cache is not initialized')
return this.table
}
}
/** Project a header onto the identity fields a record is bound to. */
function identityOf(header: SessionHeader): CheckpointIdentity {
return { createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd } }
}
/** Whether a stored record's bound identity names the caller's lifecycle. */
function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean {
return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd
}
export default SessionProjectionCache

View File

@@ -0,0 +1,35 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`.
* @module @deepseek-ai/dsh-session-projection-cache/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection-cache'
/** Cordis companion plugin name. */
export const name = 'session-projection-cache-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the cache's correctness relation (a stored row equals
* the registry fold at its `seq` watermark) is only checkable by re-running the
* fold over the persisted log — duplicating the implementation rather than
* detecting drift — and its staleness is by design (fail-soft writes). The
* durable boundary is already schema-validated by the storage-domain layer
* on every reopen, and the read ladder's version/watermark guards are proven
* by the package spec.
*/
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,71 @@
/**
* The session-projcache domain declaration: one `sessions` table keyed by
* {@link SessionId}, each record the full projection checkpoint for one
* session (`key → {ver, seq, val}` rows). The spec object
* is the single source of the domain's identity, version, and record schema;
* the storage-domain routing decides the medium (the shipped composition's
* json backend lands it at `<root>/session_projcache.json`, beside
* `workspace.json`).
* @module @deepseek-ai/dsh-session-projection-cache/src/spec
*/
import { z } from 'zod'
import { SessionId } from '@deepseek-ai/dsh-session'
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
/**
* One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
* minus the two record keys). `val` is the unit's internal state — plain
* JSON by the unit contract; `z.json()` enforces that at the durable
* boundary. A row is never wrong, only possibly stale: `seq` says exactly
* how stale, and a `ver` mismatch against the live unit's `stateVersion`
* discards it at read time (never a migration).
*/
export const checkpointRow = z.object({
ver: z.number().int().nonnegative(),
seq: z.number().int().gte(-1),
val: z.json(),
})
/**
* The stored-log identity a record is bound to: the immutable header fields
* that distinguish one session lifecycle from another under the same id. A
* session id names a slot, not a lifecycle — a deleted-then-recreated id, or
* a persistence root swapped under a surviving cache, would otherwise let an
* old row pass every watermark check and seed state folded from an unrelated
* log. Reads validate this against the live header (listing) or the stored
* header (cold read) before accepting any row.
*/
export const checkpointIdentity = z.object({
createdAt: z.number().int().nonnegative(),
cwd: z.string().optional(),
})
/** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */
export type CheckpointIdentity = z.infer<typeof checkpointIdentity>
/**
* One session's stored record: the log identity it was folded from plus its
* checkpoint rows keyed by projection key. The whole record is replaced on
* every write (whole-value discipline — the registry checkpoint is always
* the complete per-session cut).
*/
export const checkpointRecord = z.object({
identity: checkpointIdentity,
rows: z.record(z.string(), checkpointRow),
})
/** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */
export type CheckpointRecord = z.infer<typeof checkpointRecord>
/**
* The session-projcache domain spec. Version bumps discard the whole medium
* (cache semantics: a stale or unreadable cache costs a longer tail replay,
* never a wrong value). v2 added the record's log-identity binding; v3
* renamed the row fields to `ver`/`seq`/`val`.
*/
export const projectionCacheDomainSpec = defineDomain({
name: 'session_projcache',
version: 3,
tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
})

View File

@@ -0,0 +1,387 @@
/**
* SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
* count/interval throttling between them, fail-soft durability (a failed
* write logs and stays stale, never throws into the event path), and the
* cold-read ladder (cached row + readFrom tail + registry restore +
* write-back; version bump and shrunk-log rows degrade to a full re-read).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
import SessionProjectionCache from '../src/index.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'cache-test/marks': { marks: string[] }
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'cache-test/mark': { marks: string[] }
}
interface OutOfBandSessionEventMap {
'cache-test/mark': true
}
}
type MarksState = { marks: string[] } | null
const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({
key: 'cache-test/marks',
schema: z.object({ marks: z.array(z.string()) }),
init: () => null,
apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
view: state => state ?? { marks: [] },
stateVersion,
})
/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
function fakePersistence(logs: Map<string, SessionEvent[]>) {
const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
const events = logs.get(String(id))
if (events === undefined) throw new Error(`session "${id}" not found`)
return {
meta: { version: 0, id, createdAt: 0 },
events: events.filter(event => event.seq >= fromSeq),
}
})
return { readFrom }
}
/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
interface HarnessOptions {
pool?: MemoryMediaPool
config?: { writeEveryEvents: number; writeIntervalMs: number }
stateVersion?: number
logs?: Map<string, SessionEvent[]>
}
const contexts: Context[] = []
async function harness(options: HarnessOptions = {}) {
const pool = options.pool ?? new MemoryMediaPool()
const logs = options.logs ?? new Map<string, SessionEvent[]>()
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', facility)
ctx.provide('storageDomain', facility)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(marksUnit(options.stateVersion))
const persistence = fakePersistence(logs)
ctx.provide('sessionPersistence', persistence as never)
const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
}
const mark = (session: Session, marks: string[]): SessionEvent =>
session.append('cache-test/mark', { marks })
const endTurn = (session: Session): SessionEvent =>
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
/** The stored medium record for one session id (undefined = never written). */
function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as
{
identity: { createdAt: number; cwd?: string }
rows: Record<string, { ver: number; seq: number; val: unknown }>
} | undefined
}
/** The stored medium rows for one session id (undefined = never written). */
function storedRows(pool: MemoryMediaPool, id: Session['id']) {
return storedRecord(pool, id)?.rows
}
/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */
const settle = () => new Promise(resolve => setTimeout(resolve, 0))
afterEach(async () => {
vi.useRealTimers()
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('SessionProjectionCache write policy', () => {
it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
const { ctx, pool } = await harness()
const session = ctx.sessions.create(SessionId('turn-end'))
mark(session, ['a'])
expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet
const end = endTurn(session)
await settle()
const rows = storedRows(pool, session.id)
expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
})
it('writes at session disposal (detach, the live-to-cold moment)', async () => {
const { ctx, pool } = await harness()
// Sessions dispose with their owning fiber: create in a child plugin.
let session: Session | undefined
const owner = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('detach'))
}, { inject: ['sessions'] }))
if (session === undefined) throw new Error('session was not created')
mark(session, ['live'])
await owner.dispose()
await settle()
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
})
it('flushes when the in-turn event count reaches the configured threshold', async () => {
const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
const session = ctx.sessions.create(SessionId('count'))
mark(session, ['1'])
mark(session, ['2'])
await settle()
expect(storedRows(pool, session.id)).toBeUndefined()
mark(session, ['3'])
await settle()
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
})
it('flushes on the configured interval when the count threshold is not reached', async () => {
vi.useFakeTimers()
const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } })
const session = ctx.sessions.create(SessionId('interval'))
mark(session, ['slow'])
await vi.advanceTimersByTimeAsync(249)
expect(storedRows(pool, session.id)).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(0)
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
})
it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
const { ctx, pool } = await harness()
// Never dirtied: no events — write() still lands the init-derived cut.
const clean = ctx.sessions.create(SessionId('clean-write'))
await ctx.sessionProjectionCache.write(clean)
expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
// A unit whose state violates the plain-JSON contract fails the write loud.
ctx.sessionProjections.register({
key: 'cache-test/marks2' as never,
schema: { parse: (value: unknown) => value } as never,
init: () => new Map<string, string>(),
apply: (state: unknown) => state,
view: () => null as never,
stateVersion: 1,
})
await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
})
it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
vi.useFakeTimers()
const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
const armed = ctx.sessions.create(SessionId('armed'))
const cleaned = ctx.sessions.create(SessionId('cleaned'))
mark(armed, ['pending']) // timer armed, no write yet
mark(cleaned, ['done'])
endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
await vi.advanceTimersByTimeAsync(0)
await fiber.dispose()
// The armed timer died with the plugin: advancing time writes nothing.
await vi.advanceTimersByTimeAsync(10_000)
expect(storedRows(pool, armed.id)).toBeUndefined()
})
it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
const { ctx, pool } = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const session = ctx.sessions.create(SessionId('fail-soft'))
mark(session, ['x'])
pool.failNextWrites = 1
endTurn(session)
await settle()
expect(storedRows(pool, session.id)).toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
// Self-heal: the next mandatory point writes the current cut.
mark(session, ['y'])
endTurn(session)
await settle()
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
})
})
describe('SessionProjectionCache cold read', () => {
const storedLog = (marks: string[][]): SessionEvent[] => {
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
]
for (const m of marks) {
events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
}
events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
return events
}
/** Pre-seed the medium with one stored checkpoint record (before the domain opens). */
function seedRow(
pool: MemoryMediaPool,
id: string,
row: { ver: number; seq: number; val: unknown },
identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
): void {
pool.versions.set('session_projcache', 3)
pool.media.set('session_projcache', {
tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]),
global: null,
})
}
it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
// A warm-era checkpoint at watermark 1 (only ['a'] folded).
seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
const { cache, persistence, pool: samePool } = await harness({ pool, logs })
const id = SessionId('cold')
const snapshot = await cache.coldSnapshot(id)
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] })
expect(snapshot.asOfSeq).toBe(3)
// The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0.
expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
// Write-back: the stored row advanced to the served cut.
expect(storedRows(samePool, id)?.['cache-test/marks'])
.toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
})
it('discards a version-mismatched row and refolds the full log', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['bumped', storedLog([['a']])]])
seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
const snapshot = await cache.coldSnapshot(SessionId('bumped'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
// Mismatch pulls the floor to 0: one full read, no second pass needed.
expect(persistence.readFrom).toHaveBeenCalledTimes(1)
expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined)
})
it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
const { cache, persistence } = await harness({ pool, logs })
const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
expect(snapshot.asOfSeq).toBe(2)
// Anchored tail read (floor 9) came back empty -> full re-read from 0.
expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined)
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
})
it('write-back failure is contained: the snapshot is still served', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['soft', storedLog([['a']])]])
const { ctx, cache } = await harness({ pool, logs })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
pool.failNextWrites = 1
const snapshot = await cache.coldSnapshot(SessionId('soft'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed'))
})
it('rejects for a session with no persisted log', async () => {
const { cache } = await harness()
await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
})
it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0
// A checkpoint from a PRIOR lifecycle of the same id (different createdAt):
// its rows pass every watermark check, but the identity does not match.
seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
const { cache, pool: samePool } = await harness({ pool, logs })
const snapshot = await cache.coldSnapshot(SessionId('reborn'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
// The write-back rebinds the record to the actual log's identity.
expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 })
})
it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
const pool = new MemoryMediaPool()
seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
const { cache } = await harness({ pool })
expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
})
it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
const pool = new MemoryMediaPool()
seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
const { cache } = await harness({ pool })
const id = SessionId('homed')
expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
})
it('dates an empty stored log at -1 in the zero-units topology', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['empty', [] as SessionEvent[]]])
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', facility)
ctx.provide('storageDomain', facility)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.provide('sessionPersistence', fakePersistence(logs) as never)
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty')))
.resolves.toEqual({ asOfSeq: -1, values: {} })
})
it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
const pool = new MemoryMediaPool()
seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
const { cache } = await harness({ pool })
const id = SessionId('listed')
// Matching header: values plus the watermark the client seeds under.
expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
// A recreated id (different createdAt): the record is unrelated — no block.
expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
// Unknown id: no block.
expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
})
it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
// Same composition minus any registered unit: restoreFloor is undefined,
// yet coldSnapshot must still reject for an absent log (probe read) and
// serve an empty cut at the stored end for a present one.
const pool = new MemoryMediaPool()
const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', facility)
ctx.provide('storageDomain', facility)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.provide('sessionPersistence', fakePersistence(logs) as never)
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
.resolves.toEqual({ asOfSeq: 2, values: {} })
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../session-projection"
},
{
"path": "../../storage/storage"
},
{
"path": "../../storage/storage-domain"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# 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-projection/session-projection/README.md
README.md: 2e026aab55933c96ba961481f9597bc18cbbe910
README.zh.md: a3e0b0f46466d19321b0950dc41d06473a54a1ce
README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e
README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025

View File

@@ -23,7 +23,7 @@ Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRI
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
- **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly.
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache (a later phase) stores `(sessionId, key, stateVersion, observedSeq, stateJson)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
- **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent.
@@ -43,5 +43,5 @@ None; projections never assemble or send provider requests.
- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
- **The persisted projection cache is a later phase** — cells live in memory only; a restart rebuilds by folding the in-memory log on first touch. The `stateVersion` field is the forward-declared invalidation anchor for that phase.
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
- **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.

View File

@@ -23,7 +23,7 @@
- **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins
- **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()``asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise让边界的 `schema.parse` 当场大声失败。
- **状态是纯 JSON`stateVersion` 是其失效锚点。** 持久投影缓存persisted projection cache,后续阶段)存储 `(sessionId, key, stateVersion, observedSeq, stateJson)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
- **状态是纯 JSON`stateVersion` 是其失效锚点。** 持久投影缓存persisted projection cache存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面载体api-proxy据此自铸各自的帧`session/projection`)与块。
- **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
@@ -43,5 +43,5 @@
- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
- **正向驱动eager drive逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。
- **持久投影缓存属于后续阶段**——cell 目前只活在内存里重启后首次触达时靠折叠内存日志重建`stateVersion` 字段是为该阶段预先声明的失效锚点
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠
- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`只能靠评审把关invariant 配套记载了为何不存在运行时检查。

View File

@@ -66,9 +66,9 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
view(state: S): SessionProjectionMap[K]
/**
* Persisted-cache invalidation anchor: bump whenever the state shape or the
* fold semantics change, so persisted `(sessionId, key, stateVersion,
* observedSeq, state)` rows from an older unit are discarded instead of
* being forward-applied into garbage. Non-negative integer.
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
* rows from an older unit are discarded instead of being forward-applied
* into garbage. Non-negative integer.
*/
stateVersion: number
}
@@ -97,6 +97,26 @@ export interface ProjectionSnapshot {
values: Partial<SessionProjectionMap>
}
/**
* One unit's checkpoint: its internal state (plain JSON by the unit
* contract), the seq of the last event folded into it, and the unit
* `stateVersion` that produced it — the persisted projection-cache row
* `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is
* never authoritative, only a fold shortcut: `restore` discards it on a
* version mismatch or when it claims events past the stored log end.
*/
export interface ProjectionCheckpointRow {
/** The registering unit's `stateVersion` at fold time. */
ver: number
/** Seq of the last event folded into `val`; -1 for the empty log. */
seq: number
/** The unit's internal state — plain JSON per the unit contract. */
val: unknown
}
/** Checkpoint rows keyed by projection key (one session's persisted cache value). */
export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>
/** Type-erased unit view the drive machinery works with (the register seam already proved the typed contract). */
interface ErasedDefinition {
key: string
@@ -206,6 +226,136 @@ export class SessionProjectionRegistry extends Service {
return { asOfSeq: session.seq - 1, values: values }
}
/**
* State-level checkpoint of every registered unit for one session, read
* from the watermark cache (missing cells fold lazily over the in-memory
* log). This is the write side of the persisted projection cache: the
* returned rows are the `(key → {ver, seq, val})` part of the durable
* `(sessionId, key, ver, seq, val)`
* rows. Every `val` is a DETACHED structured clone — never the live
* cell reference: the watermark cache is this registry's authoritative
* mutable state, and a caller reaching the live reference could corrupt
* every subsequent snapshot and frame through it (plain JSON by the unit
* contract, so the clone is total).
* @param session - the session whose unit states are checkpointed.
* @returns one row per registered key; empty when no unit is registered.
*/
checkpoint(session: Session): ProjectionCheckpoint {
const rows: ProjectionCheckpoint = {}
for (const registration of this.registrations.values()) {
const cell = this.cellFor(registration, session)
rows[registration.def.key] = {
ver: registration.def.stateVersion,
seq: cell.observedSeq,
val: structuredClone(cell.state),
}
}
return rows
}
/**
* The stored seq a {@link restore} tail read over `checkpoint` must start
* at: one event BELOW the lowest usable watermark (a row is usable when
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
* pulls the floor to `0` — that key must refold the full log). The
* one-below anchor is load-bearing: the tail then proves how far the
* stored log still extends, so {@link restore} can detect a log that
* shrank below a row's watermark (crash-repair truncation) instead of
* serving the stale row as current — an empty tail read from the anchor
* yields an end below every watermark and the restore rejects for a full
* re-read.
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @returns the seq to hand the persistence `readFrom`, or `undefined`
* when no unit is registered (no read needed — {@link restore} would
* serve empty values regardless).
*/
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined {
let floor: number | undefined
for (const registration of this.registrations.values()) {
const row = checkpoint[registration.def.key]
const need = row !== undefined && row.ver === registration.def.stateVersion
? Math.max(row.seq + 1, 0)
: 0
floor = floor === undefined ? need : Math.min(floor, need)
}
return floor === undefined ? undefined : Math.max(floor - 1, 0)
}
/**
* View a checkpoint's rows without any log read: for every registered
* unit whose row's `ver` matches, serve the schema-validated
* `view` of the stored state; mismatched or absent rows leave their key
* absent (a cold or listing consumer treats it as not-yet-available and a
* fuller read path refolds it). The zero-I/O rung of the read ladder —
* values are as stale as their rows, never wrong.
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @returns whole values per key with a usable row; empty when none.
*/
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap> {
const values: Record<string, unknown> = {}
for (const registration of this.registrations.values()) {
const def = registration.def
const row = checkpoint[def.key]
if (row === undefined || row.ver !== def.stateVersion) continue
values[def.key] = def.schema.parse(def.view(row.val))
}
return values
}
/**
* Cold read: fold every registered unit over a stored log suffix, seeding
* each from its checkpoint row when usable — the one read recipe (cached
* state + forward tail replay + `view`) applied without a live `Session`.
* Call with the events returned by a persistence
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
* so a shrunk log is detected here. A row is usable iff its
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
* (`seq >= baseSeq - 1`), and it does not claim events past the
* supplied end (`seq <= endSeq`); an unusable row is discarded
* and its key refolds from `init` — which is only sound over the full
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
* from seq 0, e.g. after a crash-repair truncation shrank the log below
* a row's watermark).
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @param events - the stored events with `seq >= baseSeq`, in seq order.
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
* refreshed checkpoint rows at that cut, ready for a durable write-back.
*/
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number):
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
const values: Record<string, unknown> = {}
const refreshed: ProjectionCheckpoint = {}
for (const registration of this.registrations.values()) {
const def = registration.def
const row = checkpoint[def.key]
const usable = row !== undefined
&& row.ver === def.stateVersion
&& row.seq >= baseSeq - 1
&& row.seq <= endSeq
if (!usable && baseSeq > 0) {
throw new Error(
`session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: `
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
)
}
let state = usable ? row.val : def.init()
const from = usable ? row.seq : baseSeq - 1
for (const event of events) {
if (event.seq > from) state = def.apply(state, event)
}
values[def.key] = def.schema.parse(def.view(state))
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
}
return {
snapshot: { asOfSeq: endSeq, values: values },
checkpoint: refreshed,
}
}
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
let state = def.init()

View File

@@ -169,6 +169,154 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
})
it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 })
const markEvent = mark(session, ['a'])
const rows = ctx.sessionProjections.checkpoint(session)
expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } })
expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 })
// Empty log: init-derived state at watermark -1.
const fresh = ctx.sessions.create()
expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null })
})
it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register(marksUnit())
mark(session, ['a'])
const rows = ctx.sessionProjections.checkpoint(session)
// Hostile (or merely careless) consumer mutates the handed-out state.
;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED')
// The registry's authoritative cell is untouched: snapshot and a fresh
// checkpoint both still serve the committed value.
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] })
expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] })
})
it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => {
const { ctx } = await harness()
expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
expect(ctx.sessionProjections.restoreFloor({})).toBe(0)
// Lowest usable watermark is count's 5 → the anchored tail starts AT 5
// (one below the first needed seq 6), so the read proves seq 5 still exists.
expect(ctx.sessionProjections.restoreFloor({
'test/marks': { ver: 1, seq: 10, val: { marks: [] } },
'test/count': { ver: 1, seq: 5, val: 6 },
})).toBe(5)
// A version-mismatched row forces that key back to a full refold.
expect(ctx.sessionProjections.restoreFloor({
'test/marks': { ver: 2, seq: 10, val: { marks: [] } },
'test/count': { ver: 1, seq: 5, val: 6 },
})).toBe(0)
// A fresh (-1) row still needs the whole tail from 0.
expect(ctx.sessionProjections.restoreFloor({
'test/marks': { ver: 1, seq: -1, val: null },
'test/count': { ver: 1, seq: -1, val: 0 },
})).toBe(0)
})
it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
const tail: SessionEvent[] = [
{ type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } },
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
]
// marks row usable (watermark 2, tail starts at 3); count row mismatched — but
// a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
expect(() => ctx.sessionProjections.restore({
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
'test/count': { ver: 99, seq: 2, val: 3 },
}, tail, 3)).toThrow(/re-read from seq 0/)
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
const full: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } },
{ type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } },
...tail,
]
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
'test/count': { ver: 99, seq: 2, val: 3 },
}, full, 0)
expect(snapshot.asOfSeq).toBe(4)
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
// The refreshed rows sit at the served cut, ready for a durable write-back.
expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
})
it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
const rows = {
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
'test/count': { ver: 1, seq: 2, val: 3 },
}
const tail: SessionEvent[] = [
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
]
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
expect(snapshot.asOfSeq).toBe(4)
// marks already covers the tail (watermark 4): nothing re-applied.
expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
// count folds exactly seqs 3 and 4 on top of its checkpoint.
expect(snapshot.values['test/count']).toBe(5)
// Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
const { snapshot: current } = ctx.sessionProjections.restore({
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
'test/count': { ver: 1, seq: 4, val: 5 },
}, [], 5)
expect(current.asOfSeq).toBe(4)
expect(current.values['test/count']).toBe(5)
})
it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
const values = ctx.sessionProjections.viewCheckpoint({
'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } },
'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent
})
expect(values['test/marks']).toEqual({ marks: ['stored'] })
expect('test/count' in values).toBe(false)
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
})
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(countUnit())
const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } }
// The anchored floor sits ON the watermark, so the tail read must return
// at least seq 9 from an intact log…
const floor = ctx.sessionProjections.restoreFloor(rows)
expect(floor).toBe(9)
// …an intact log serves the anchor event and the checkpoint stands as-is.
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
// …while a log crash-repaired down to fewer events returns an empty tail:
// the row overreaches the proven end and a tail read cannot fix this key.
expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
// The full re-read discards the overreaching row and refolds from init.
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
expect(snapshot.asOfSeq).toBe(1)
expect(snapshot.values['test/count']).toBe(2)
})
it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register({

View File

@@ -149,6 +149,11 @@ class TestPersistence extends SessionPersistence {
return structuredClone(entry)
}
async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const whole = await this.inspect(id, signal)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
async list(): Promise<SessionHeader[]> {
TestPersistence.listStarted?.()
await TestPersistence.listGate

View File

@@ -96,6 +96,11 @@ class TestPersistence extends SessionPersistence {
return Promise.resolve(result)
}
async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const whole = await this.inspect(id, signal)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
list(signal?: AbortSignal): Promise<SessionHeader[]> {
TestPersistence.listCalls += 1
TestPersistence.listSignals.push(signal)

View File

@@ -76,6 +76,11 @@ class TracePersistence extends SessionPersistence {
return Promise.resolve(structuredClone(entry))
}
async readFrom(id: SessionIdType, fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const whole = await this.inspect(id)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
list(): Promise<SessionHeader[]> {
TracePersistence.listCalls += 1
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)

View File

@@ -2,5 +2,5 @@
# 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/support/acp-snapshot/README.md
README.md: 93998c20bed7a2542c23932bd659a64aec63a585
README.zh.md: a355cf0f35b5e7bec41ab0d9063c932211a7200b
README.md: 371ede587b84ba96770d4a2b1ee89b029d92dd25
README.zh.md: f596f9021ae9b8c5973efafae7f7d695293b96e1

View File

@@ -9,7 +9,7 @@ Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge.
@@ -51,11 +51,13 @@ defineAcpSnapshotSuite({
})
```
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences.
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows.
A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes.
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere.
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.

View File

@@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[
- **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent或在普通 Node 下启动已构建 `lib` agent通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。
- **`runScenario`harness**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript``configPath``tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。
- **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`JSON-RPC id → 首次出现序列UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token按最长优先根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin`system-prompt.expected.md` `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别一个 token 化 pin由可独立共享的 `system-prompt.expected.md` `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。
@@ -51,11 +51,13 @@ defineAcpSnapshotSuite({
})
```
启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域harness 仍只拥有并移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json``session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。
启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域harness 仍只拥有并移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`
每个 pin 默认拥有其生成的 `system-prompt.expected.md``tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource``toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。
每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。
示例还发布 `cordis.snapshot.yml` 回放 overlay位于 `cordis.yml` 旁边bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM并重写已记录场景的模型 fixture`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay并从已提交模型脚本重写 stdout、可比较会话日志预期输出以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
示例还发布 `cordis.snapshot.yml` 回放 overlay位于 `cordis.yml` 旁边bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM并重写已记录场景的模型 fixture`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay并从已提交模型脚本重写 stdout、可比较会话日志预期输出以及 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
约束:`suite.ts``harness.ts` 导入 vitestharness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。

View File

@@ -8,10 +8,12 @@
* cwd and persistence roots and reads only committed fixtures. Record and
* refresh stay serial while writing.
*
* Exactly one scenario per header-composition class pins the full prompt and
* tool-schema sequences in dedicated sidecars. Every live header is checked
* against that pin, so session-dependent composition must declare a separate
* class instead of escaping coverage.
* Exactly one scenario per header-composition class pins the tokenized header
* sequence. Its prompt and tool-schema sequences live in independent
* sidecars, each of which may be shared with another class pin when the bytes
* are identical. Every live header is checked against the composed pin, so
* session-dependent composition must declare a separate class instead of
* escaping coverage.
* @module @deepseek-ai/dsh-acp-snapshot/suite
*/
@@ -31,10 +33,10 @@ import {
scrubToolSchemas,
} from './normalize.ts'
/** The readable system-prompt snapshot beside each header-pinning fixture. */
/** The readable system-prompt snapshot beside its owning header pin. */
const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md'
/** The structured tool-schema snapshot beside each header-pinning fixture. */
/** The structured tool-schema snapshot beside its owning header pin. */
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json'
/** The optional full Windows-native stdout transcript. */
@@ -80,10 +82,23 @@ export interface Scenario {
*/
overridden?: boolean
/**
* Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own
* the prompt and tool schemas, while every classmate is checked for equality.
* Whether this scenario is its header class's sole tokenized request-header
* pin. Prompt and tool-schema sidecars are selected independently, while
* every classmate is checked for equality with the reconstructed header.
*/
pinsHeader?: boolean
/**
* Header-pinning scenario whose `system-prompt.expected.md` this pin reuses.
* Defaults to this scenario. The source must own its prompt sidecar and
* declare the same {@link expectedHeaderChanges}; meaningless off a pin.
*/
systemPromptSource?: string
/**
* Header-pinning scenario whose `tool-schemas.expected.json` this pin reuses.
* Defaults to this scenario. The source must own its schema sidecar and
* declare the same {@link expectedHeaderChanges}; meaningless off a pin.
*/
toolSchemasSource?: string
/**
* How many changed `request/header` snapshots this PINNING scenario's primary
* fixture legitimately carries (default 0). Their full prompt text is kept in
@@ -195,6 +210,71 @@ export interface SnapshotSuiteOptions {
mode: 'replay' | 'record' | 'refresh'
}
/** One scenario's generated claim on a shared snapshot file. */
export interface SharedSnapshotClaim {
/** Scenario that first generated the snapshot in this suite run. */
scenario: string
/** Complete generated file content. */
content: string
}
/** One committed snapshot file and its complete content. */
export interface NamedSnapshotContent {
/** Diagnostic path of the committed file. */
path: string
/** Complete committed file content. */
content: string
}
/**
* Record one scenario's generated content for a shared snapshot source.
* A later claimant must generate identical bytes; otherwise record/refresh
* would make the final file depend on scenario order.
*
* @param claims Claims already made in this suite run, keyed by source path.
* @param source The shared snapshot path being claimed.
* @param scenario The scenario generating the content.
* @param content The complete content the scenario generated.
* @returns Nothing.
*/
export function claimSharedSnapshot(
claims: Map<string, SharedSnapshotClaim>,
source: string,
scenario: string,
content: string,
): void {
const previous = claims.get(source)
if (previous !== undefined && previous.content !== content) {
throw new Error(
`acp-snapshot: shared snapshot ${source} diverged between ${previous.scenario} and ${scenario}`,
)
}
if (previous === undefined) claims.set(source, { scenario, content })
}
/**
* Reject byte-identical committed snapshots stored under different paths.
*
* @param kind Human-readable snapshot kind for the diagnostic.
* @param snapshots The committed files to compare.
* @returns Nothing.
*/
export function assertUniqueSnapshotContents(
kind: string,
snapshots: readonly NamedSnapshotContent[],
): void {
const firstPathByContent = new Map<string, string>()
for (const snapshot of snapshots) {
const firstPath = firstPathByContent.get(snapshot.content)
if (firstPath !== undefined) {
throw new Error(
`acp-snapshot: identical ${kind} snapshots appear in ${firstPath} and ${snapshot.path}; reuse one source`,
)
}
firstPathByContent.set(snapshot.content, snapshot.path)
}
}
/**
* Validate and order a scenario directory's session-fixture filenames.
*
@@ -795,8 +875,8 @@ export function stabilizeRefreshLog(
* Register the suite: one test per scenario (the expected-output and log comparisons and
* the header-uniformity guard) plus the fixture guard block (no orphan
* scenario dirs, required files present, exactly one pin per header class,
* pinning fixtures well-formed, every JSONL prompt-scrubbed, non-pinning
* fixtures fully header-scrubbed). Must
* shared sidecars unique and well-formed, every JSONL prompt-scrubbed,
* non-pinning fixtures fully header-scrubbed). Must
* run at vitest collection time — it calls `describe`/`it`. Throws
* immediately if any header class lacks a pinning scenario or carries two
* (the uniformity guard needs exactly one comparison anchor per class).
@@ -813,6 +893,19 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
/** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */
const classOf = (scenario: Scenario): string => scenario.headerClass ?? 'default'
const scenariosByName = new Map<string, Scenario>()
for (const scenario of scenarios) {
if (scenariosByName.has(scenario.name)) {
throw new Error(`acp-snapshot: duplicate scenario name "${scenario.name}"`)
}
scenariosByName.set(scenario.name, scenario)
for (const field of ['systemPromptSource', 'toolSchemasSource'] as const) {
if (scenario[field] !== undefined && scenario.pinsHeader !== true) {
throw new Error(`acp-snapshot: ${scenario.name}.${field} is only valid on a header-pinning scenario`)
}
}
}
/** Each header class's single pinning scenario. Guarded here (and by meta-tests) so a pin cannot silently vanish or split. */
const pinningByClass = new Map<string, Scenario>()
for (const scenario of scenarios) {
@@ -828,6 +921,43 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
}
const sourceFor = (
pinningScenario: Scenario,
field: 'systemPromptSource' | 'toolSchemasSource',
label: string,
): Scenario => {
const sourceName = pinningScenario[field] ?? pinningScenario.name
const source = scenariosByName.get(sourceName)
if (source === undefined) {
throw new Error(`acp-snapshot: ${pinningScenario.name} names unknown ${label} source "${sourceName}"`)
}
if (source.pinsHeader !== true) {
throw new Error(`acp-snapshot: ${pinningScenario.name} names non-pinning ${label} source "${sourceName}"`)
}
if (source[field] !== undefined && source[field] !== source.name) {
throw new Error(`acp-snapshot: ${pinningScenario.name} names ${label} source "${sourceName}", which does not own its sidecar`)
}
const expectedChanges = pinningScenario.expectedHeaderChanges ?? 0
const sourceChanges = source.expectedHeaderChanges ?? 0
if (sourceChanges !== expectedChanges) {
throw new Error(
`acp-snapshot: ${pinningScenario.name} and ${sourceName} declare different header-change counts for shared ${label}`,
)
}
return source
}
const promptSourceByClass = new Map<string, Scenario>()
const schemaSourceByClass = new Map<string, Scenario>()
for (const [cls, pinningScenario] of pinningByClass) {
promptSourceByClass.set(cls, sourceFor(pinningScenario, 'systemPromptSource', 'system-prompt snapshot'))
schemaSourceByClass.set(cls, sourceFor(pinningScenario, 'toolSchemasSource', 'tool-schema snapshot'))
}
const promptOwners = new Set([...promptSourceByClass.values()].map(source => source.name))
const schemaOwners = new Set([...schemaSourceByClass.values()].map(source => source.name))
const promptClaims = new Map<string, SharedSnapshotClaim>()
const schemaClaims = new Map<string, SharedSnapshotClaim>()
scenarioSuite('snapshot scenarios', () => {
for (const scenario of scenarios) {
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones
@@ -927,17 +1057,26 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const primary = result.sessionLogs[0] as HarvestedLog
const prompts = normalizedSystemPrompts(primary.content, ctx)
expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0)
const snapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1))
await writeFile(join(dir, SYSTEM_PROMPT_SNAPSHOT), snapshot)
const promptSnapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1))
/* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario
const promptPath = join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT)
claimSharedSnapshot(promptClaims, promptPath, scenario.name, promptSnapshot)
await writeFile(promptPath, promptSnapshot)
const schemaSets = normalizedToolSchemas(primary.content, ctx)
expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0)
expect(schemaSets.length, `${mode} produced a tool-schema sequence that differs from its prompt sequence`)
.toBe(prompts.length)
await writeFile(join(dir, TOOL_SCHEMAS_SNAPSHOT), formatToolSchemasSnapshot(
const toolSchemasSnapshot = formatToolSchemasSnapshot(
schemaSets[0] as unknown[],
schemaSets.slice(1),
))
)
/* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario
const schemaPath = join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT)
claimSharedSnapshot(schemaClaims, schemaPath, scenario.name, toolSchemasSnapshot)
await writeFile(schemaPath, toolSchemasSnapshot)
}
}
@@ -966,17 +1105,27 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// tokenized JSONL plus readable prompt and structured schema sidecars.
/* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario
/* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
const promptSource = promptSourceByClass.get(classOf(scenario)) ?? pinningScenario
/* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? pinningScenario
const pinningDir = join(snapshotsDir, pinningScenario.name)
const pinnedFixture = await readFile(join(pinningDir, 'session.jsonl'), 'utf8')
const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture))
const promptSnapshot = await readFile(join(pinningDir, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
const promptSnapshot = await readFile(
join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT),
'utf8',
)
const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot)
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) has an unexpected request/header count`)
.toBe(1 + (pinningScenario.expectedHeaderChanges ?? 0))
const toolSchemasSnapshot = await readFile(join(pinningDir, TOOL_SCHEMAS_SNAPSHOT), 'utf8')
const toolSchemasSnapshot = await readFile(
join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT),
'utf8',
)
const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot)
const pinnedSchemaSets = [toolSchemas.initial, ...toolSchemas.changes]
expect(pinnedSchemaSets.length, `the pinning fixture (${pinningScenario.name}) has an unexpected tool-schema count`)
expect(pinnedSchemaSets.length, `the schema source (${schemaSource.name}) has an unexpected tool-schema count`)
.toBe(pinned.length)
const pinnedHeaders = pinned.map((header, index) => restorePinnedToolSchemas(
header,
@@ -1000,7 +1149,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(expected)
if (expectedChanges === 0) {
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(initialPromptSnapshot)
}
}
@@ -1008,12 +1157,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
expect(formatSystemPromptSnapshot(
prompts[0] as string,
prompts.slice(1),
), `session ${log.id}: changed system prompts diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
), `session ${log.id}: changed system prompts diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(promptSnapshot)
expect(formatToolSchemasSnapshot(
schemaSets[0] as unknown[],
schemaSets.slice(1),
), `session ${log.id}: changed tool schemas diverged from ${pinningScenario.name}/${TOOL_SCHEMAS_SNAPSHOT}`)
), `session ${log.id}: changed tool schemas diverged from ${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT}`)
.toEqual(toolSchemasSnapshot)
}
}
@@ -1034,7 +1183,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
it('every registered scenario has its required fixture files', async () => {
// Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars.
for (const { name, overridden, pinsHeader, pinsNativeWindowsStdout } of scenarios) {
for (const { name, overridden, pinsNativeWindowsStdout } of scenarios) {
const dir = join(snapshotsDir, name)
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
@@ -1045,17 +1194,17 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``)
.toBe(overridden === true)
expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``)
.toBe(pinsHeader === true)
expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``)
.toBe(pinsHeader === true)
expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match snapshot-source ownership`)
.toBe(promptOwners.has(name))
expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match snapshot-source ownership`)
.toBe(schemaOwners.has(name))
await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined()
}
})
it('exactly one scenario pins the request-header content of each header class', () => {
// Zero pins would drop a class's prompt/schema surface from the suite entirely; two would
// split it.
// Zero pins would drop a class's structural header surface from the suite entirely; two
// would split it.
const pins = new Map<string, string[]>()
for (const scenario of scenarios.filter(s => s.pinsHeader === true)) {
const cls = classOf(scenario)
@@ -1068,33 +1217,56 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
})
it('every pinning fixture carries one tokenized header sequence and two sidecars', async () => {
it('every pinning fixture composes one tokenized header sequence with its referenced sidecars', async () => {
// Assert the committed pin directly because a class containing only its
// pinning scenario has no non-pinning live run to catch undeclared changes.
for (const scenario of pinningByClass.values()) {
/* v8 ignore next -- registration guarantees every pin has resolved sources. */
const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario
/* v8 ignore next -- registration guarantees every pin has resolved sources. */
const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario
const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8')
const headers = normalizedHeaders(fixture, fixtureContext(fixture))
const promptSnapshot = await readFile(join(snapshotsDir, scenario.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
const promptSnapshot = await readFile(
join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT),
'utf8',
)
expect(headers.length, `${scenario.name}: unexpected request/header count`)
.toBe(1 + (scenario.expectedHeaderChanges ?? 0))
const toolSchemasSnapshot = await readFile(join(snapshotsDir, scenario.name, TOOL_SCHEMAS_SNAPSHOT), 'utf8')
const toolSchemasSnapshot = await readFile(
join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT),
'utf8',
)
const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot)
const schemaSets = [toolSchemas.initial, ...toolSchemas.changes]
expect(schemaSets.length, `${scenario.name}: tool-schema sequence must match the header sequence`)
expect(schemaSets.length, `${schemaSource.name}: tool-schema sequence must match ${scenario.name}'s header sequence`)
.toBe(headers.length)
for (const [index, header] of headers.entries()) {
expect(() => restorePinnedToolSchemas(header, schemaSets[index] as unknown[]), `${scenario.name}: tools must use the sidecar token`)
.not.toThrow()
}
expect(promptSnapshot.length, `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0)
expect(promptSnapshot.endsWith('\n'), `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true)
expect(toolSchemasSnapshot, `${scenario.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`)
expect(promptSnapshot.length, `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0)
expect(promptSnapshot.endsWith('\n'), `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true)
expect(toolSchemasSnapshot, `${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`)
.toBe(formatToolSchemasSnapshot(toolSchemas.initial, toolSchemas.changes))
expect(headerChangeCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared changed headers`)
.toBe(scenario.expectedHeaderChanges ?? 0)
}
})
it('stores each distinct prompt and tool-schema snapshot once', async () => {
const prompts = await Promise.all([...promptOwners].map(async (owner): Promise<NamedSnapshotContent> => ({
path: `${owner}/${SYSTEM_PROMPT_SNAPSHOT}`,
content: await readFile(join(snapshotsDir, owner, SYSTEM_PROMPT_SNAPSHOT), 'utf8'),
})))
const schemas = await Promise.all([...schemaOwners].map(async (owner): Promise<NamedSnapshotContent> => ({
path: `${owner}/${TOOL_SCHEMAS_SNAPSHOT}`,
content: await readFile(join(snapshotsDir, owner, TOOL_SCHEMAS_SNAPSHOT), 'utf8'),
})))
assertUniqueSnapshotContents('system-prompt', prompts)
assertUniqueSnapshotContents('tool-schema', schemas)
})
it('every committed JSONL has valid tool results and canonical header storage', async () => {
// Prompts and schemas always leave JSONL. Header pins retain prefixes;
// every other fixture tokenizes those too. Fixed-point checks make both

View File

@@ -0,0 +1,12 @@
{
"prompt": "respond",
"logs": [{
"file": "b/main/session.jsonl",
"lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 },
{ "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
{ "type": "request/header", "seq": 1, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "change" } },
{ "type": "turn/start", "seq": 2, "time": 100, "data": { "turn": 1 } }
]
}]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "pin" }] }

View File

@@ -0,0 +1,4 @@
{"type":"session","id":"13131313-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/shared-pin-cwd","delegationDepth":0}
{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":1,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
{"type":"turn/start","seq":2,"time":7,"data":{"turn":1}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts'
import {
assertUniqueSnapshotContents,
claimSharedSnapshot,
fixtureContext,
formatSystemPromptSnapshot,
headerChangeCount,
@@ -18,6 +20,7 @@ import {
scenarioSkipped,
sessionFixtureNames,
restorePinnedToolSchemas,
type SharedSnapshotClaim,
stabilizeRefreshLog,
stdoutExpectedVariants,
unknownToolCallIds,
@@ -57,6 +60,16 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.
// Replay pins explicit header classes; recording covers the default fallback.
const REPLAY_SCENARIOS: Scenario[] = [
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' },
{
name: 'shared-pin',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
expectedHeaderChanges: 1,
headerClass: 'shared',
systemPromptSource: 'pin-turn',
toolSchemasSource: 'pin-turn',
},
{
name: 'plain-turn',
hasModelTurn: true,
@@ -214,6 +227,158 @@ describe('defineAcpSnapshotSuite: registration contract', () => {
})
}).toThrow(/header class "default" pinned by both first-pin and second-pin/)
})
it('throws when scenario names are duplicated', () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [
{ name: 'duplicate', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'duplicate', hasModelTurn: true, recorded: true },
],
mode: 'replay',
})
}).toThrow(/duplicate scenario name "duplicate"/)
})
it.each(['systemPromptSource', 'toolSchemasSource'] as const)(
'rejects %s away from a header pin',
(field) => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [{
name: 'plain',
hasModelTurn: true,
recorded: true,
[field]: 'owner',
}],
mode: 'replay',
})
}).toThrow(new RegExp(`plain\\.${field} is only valid on a header-pinning scenario`))
},
)
it('rejects an unknown or non-pinning sidecar source', () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [{
name: 'pin',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
systemPromptSource: 'missing',
}],
mode: 'replay',
})
}).toThrow(/pin names unknown system-prompt snapshot source "missing"/)
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [
{
name: 'pin',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
toolSchemasSource: 'plain',
},
{ name: 'plain', hasModelTurn: true, recorded: true },
],
mode: 'replay',
})
}).toThrow(/pin names non-pinning tool-schema snapshot source "plain"/)
})
it('rejects a sidecar source that redirects the same artifact', () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [
{ name: 'owner', hasModelTurn: true, recorded: true, pinsHeader: true },
{
name: 'redirect',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
headerClass: 'redirect',
systemPromptSource: 'owner',
},
{
name: 'consumer',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
headerClass: 'consumer',
systemPromptSource: 'redirect',
},
],
mode: 'replay',
})
}).toThrow(/consumer names system-prompt snapshot source "redirect", which does not own its sidecar/)
})
it('rejects shared sidecars with different header-change counts', () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [
{
name: 'owner',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
expectedHeaderChanges: 1,
},
{
name: 'consumer',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
headerClass: 'consumer',
toolSchemasSource: 'owner',
},
],
mode: 'replay',
})
}).toThrow(/consumer and owner declare different header-change counts for shared tool-schema snapshot/)
})
})
describe('shared snapshot content', () => {
it('accepts identical claims and rejects order-dependent shared output', () => {
const claims = new Map<string, SharedSnapshotClaim>()
claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'first', 'prompt\n')
claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'second', 'prompt\n')
expect(claims.get('shared/system-prompt.expected.md')).toEqual({
scenario: 'first',
content: 'prompt\n',
})
expect(() => {
claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'third', 'different\n')
}).toThrow(/diverged between first and third/)
})
it('rejects identical committed content under different paths', () => {
assertUniqueSnapshotContents('prompt', [
{ path: 'one/system-prompt.expected.md', content: 'one\n' },
{ path: 'two/system-prompt.expected.md', content: 'two\n' },
])
expect(() => {
assertUniqueSnapshotContents('prompt', [
{ path: 'one/system-prompt.expected.md', content: 'same\n' },
{ path: 'two/system-prompt.expected.md', content: 'same\n' },
])
}).toThrow(/identical prompt snapshots appear in one\/system-prompt\.expected\.md and two\/system-prompt\.expected\.md/)
})
})
describe('sessionFixtureNames', () => {

View File

@@ -2,5 +2,5 @@
# 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/todo/tool-todo/README.md
README.md: 5d748e981e8cc75a189916ab87e5d486ba916603
README.zh.md: ddd22eb13fde81ddd05465ca789b302bb33bb8d9
README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef
README.zh.md: 5bc54c7cdf04f9bd77b385119bb88b2bb6ca43a3

View File

@@ -20,11 +20,11 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
## Rendering
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows it as a persistent plan, and the [web client](../../client/ui-conversation) renders a plan strip plus a dedicated tool row off `ConversationSnapshot.todos` ([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)).
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
## Session projection
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `todos` projection unit under an injected child: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` (last-wins; every other event returns the same state reference), `view` = identity, `stateVersion` = 1. The key merges into `SessionProjectionMap` here (via the interface package's `/types` outlet); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `todos` projection unit under an injected child: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), `view` = identity, `stateVersion` = 2. The key merges into `SessionProjectionMap` here (via the interface package's `/types` outlet); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md).
## Export shape

View File

@@ -20,11 +20,11 @@
## 渲染
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 应用](../../examples/tui-demo)将其显示为持久计划,[web 客户端](../../client/ui-conversation)基于 `ConversationSnapshot.todos` 渲染计划横条与专属工具行([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md))。
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 应用](../../examples/tui-demo)[web 客户端](../../client/ui-conversation)基于站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`显示计划条web 另有专属工具行[展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
## 会话投影
当组合挂载了 `ctx.sessionProjections`[`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入式子插件下注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表last-wins;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 1。key 在本包合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体在历史尾页与 `session/projection` 推送帧上供给该值。未装注册表的组合不受影响。
当组合挂载了 `ctx.sessionProjections`[`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入式子插件下注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(站立计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。key 在本包合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体在历史尾页与 `session/projection` 推送帧上供给该值。未装注册表的组合不受影响。生命周期理由见 [下一轮清空 todo 计划条](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。
## 导出形状

View File

@@ -78,17 +78,23 @@ const todosProjectionSchema: ZodType<TodoItem[] | null> = z.union([
/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` unit. */
export function apply(ctx: Context): void {
// The unit child activates only when a projection registry is composed
// (headless assemblies without the seam stay unaffected). Pure last-wins
// fold: state is the latest whole todo/write list, null before the first
// write; every other event returns the same reference (no downstream work).
// (headless assemblies without the seam stay unaffected). Standing-plan fold:
// latest whole todo/write list, cleared by the next turn/start (turn/end keeps
// the finished checklist visible); null before the first write or after a
// later turn begins; every other event returns the same state reference.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
schema: todosProjectionSchema,
init: () => null,
apply: (state, event) => (event.type === 'todo/write' ? event.data.todos : state),
apply: (state, event) => {
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},
view: state => state,
stateVersion: 1,
// Fold semantics changed: turn/start clears the standing plan (was last-write-wins only).
stateVersion: 2,
})
})
ctx.tools.register(defineTool({

View File

@@ -91,6 +91,20 @@ describe('todos projection provider', () => {
expect(projections?.asOfSeq).toBe(session.seq - 1)
})
it('clears the standing plan on the next turn/start (turn/end keeps it)', async () => {
const bench = await harness(true)
const session = bench.session
seedMessage(session)
const list: TodoItem[] = [{ content: 'done', status: 'completed' }]
session.append('todo/write', { todos: list })
session.append('turn/end', { turn: 0, reason: { kind: 'completed' } })
expect((await bench.tailProjections())?.values.todos).toEqual(list)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const cleared = await bench.tailProjections()
expect(cleared?.values.todos).toBeNull()
expect(cleared?.asOfSeq).toBe(session.seq - 1)
})
it('has no todos key when tool-todo is not composed', async () => {
const bench = await harness(false)
seedMessage(bench.session)

View File

@@ -2,5 +2,5 @@
# 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/ui/tui/README.md
README.md: 528daef773635451ceb198ab3231c2dd87cb9413
README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2
README.md: 5aafd6f5207320bf273c96a04f2d606577ca2da0
README.zh.md: 1901faeb26c65126bc5475a991fedecd39a88ba5

View File

@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.

View File

@@ -12,7 +12,7 @@ DeepSeek Harness agent智能体的交互式终端入口基于 [`@earend
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect因此卸载会移除排队工作或在清理结算前关闭可见工作终端关闭会先卸载依赖项再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新`todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立`todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现。
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`

View File

@@ -703,6 +703,10 @@ export function createTuiChat(
case 'todo/write':
todo.update(event.data.todos)
break
case 'turn/start':
// Plan strip is turn-scoped: keep it after turn/end for reading, clear on the next turn.
todo.update([])
break
case 'session/title':
sessionTitle = event.data.title
header.invalidate()
@@ -759,6 +763,7 @@ export function createTuiChat(
toolCards.clear()
allToolCards.clear()
streaming = undefined
todo.update([])
const active = activeSurfaceSeqs(agent.session)
const activeCalls = activeToolCallIds(agent.session, active)
for (const event of agent.session.events) {

View File

@@ -0,0 +1,38 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=15 bufferRow=15
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Tracking the steps. "
6| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
style 0-46 dim
7| <blank>
8| "You "
style 0-2 fg=bright-blue bold underline
9| "Plan the work. "
10| <blank>
11| "You "
style 0-2 fg=bright-blue bold underline
12| "Next question. "
13| <blank>
14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
15| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
16-35| <blank>

View File

@@ -57,6 +57,7 @@ const CHECKPOINTS = [
'resume-sessions',
'status-diagnostics',
'status-diagnostics-narrow',
'todo-plan-cleared',
] as const
// Real-loop scenarios own their assertions in separate snapshot suites but
@@ -301,6 +302,34 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('clears the plan strip when the next turn starts', async () => {
// Freeze Completed-at formatting: the first turn ends before the next starts,
// so the assistant timing line still appears without a Plan strip below it.
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 45, 0).getTime())
const harness = await setupSnapshot({
beforeMount(session) {
appendUser(session, 'Plan the work.')
appendAssistant(session, [{ type: 'text', text: 'Tracking the steps.' }])
session.append('todo/write', {
todos: [
{ content: 'read code', status: 'completed' },
{ content: 'write tests', status: 'in_progress' },
],
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
appendUser(session, 'Next question.')
},
})
await checkpoint('todo-plan-cleared', harness.terminal)
nowSpy.mockRestore()
await disposeSnapshot(harness)
})
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {