Merge remote-tracking branch 'origin/master' into xtr/identified-immutable-messages

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	packages/core/session/README.i18n.yaml
#	packages/session-title/session-title/tests/persistence.spec.ts
This commit is contained in:
_Kerman
2026-07-28 15:45:53 +08:00
174 changed files with 1994 additions and 1274 deletions

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: 39e239181bda751aca6bc2316474dc960f652b35
README.zh.md: 6d62d56d499f5cd3ad5d6450b6efff83a9988b51
README.md: 40516d12180de9c30efd40fdffa873da20ddacb3
README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7

View File

@@ -14,9 +14,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -74,7 +73,7 @@ A `user/message` stores the complete `UserMessage` directly, including the ident
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
@@ -142,6 +141,6 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
## Known Limitations and Deferred Work
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event 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).
- **`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)).
- **`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

@@ -14,9 +14,8 @@
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt``seedLength``delegationDepth`
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求边界为 `turn/end`,再创建带谱系元数据的实时子会话。
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -74,7 +73,7 @@
生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook钩子桥接层的 `hook/*`);合并成员会出现在同一目录中。`OutOfBandSessionEventMap` 是独立、默认为空的标记映射:事件所有方必须在其中合并相同键,`appendOutOfBand()` 才接受该仅日志类型surface 和生命周期类型仍被排除
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook钩子桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次
此包还定义 `TurnTriggerMap``TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。
@@ -142,6 +141,6 @@
## 已知限制与暂缓工作
- **会话分支/树**pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
- **`fork()` 仅在实时会话已关闭轮次的边界处切分**边界必须是 `turn/end` 事件,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`fork()` 仅在实时会话的稳定边界处切分**所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。
- **`TurnEndReasonMap` 不含 ACPAgent Client Protocol命名的 `refusal``max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -31,8 +31,8 @@ export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
* Find the latest closed message-triggered turn, ignoring other triggers and
* between-turn events.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
@@ -341,7 +341,6 @@ interface SessionEntry {
announced: boolean
announcing: boolean
appending: boolean
outOfBand: boolean
detachRequested: boolean
detach(): void
}
@@ -530,7 +529,7 @@ export class Session {
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
if (entry.detachRequested && !entry.announcing) entry.detach()
}
}
}
@@ -668,8 +667,8 @@ export type SessionForkSource = Session | SessionId
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
* existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
* `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
* existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
* open turn (`OPEN_TURN`).
*/
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
@@ -810,7 +809,6 @@ export class SessionStore extends Service {
announced: false,
announcing: false,
appending: false,
outOfBand: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
@@ -823,7 +821,7 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending || entry.outOfBand) {
if (entry.announcing || entry.appending) {
entry.detachRequested = true
return
}
@@ -877,7 +875,7 @@ export class SessionStore extends Service {
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
if (entry.detachRequested && !entry.appending) entry.detach()
}
}
@@ -921,87 +919,6 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/**
* Append one plugin-declared log-only event without borrowing the agent
* loop's lifecycle. An open turn receives the event directly and remains
* responsible for its ordinary checkpoint. A closed log receives one
* zero-step turn around the event, followed by an awaited flush.
*
* Once the synthetic `turn/start` commits, this method always attempts its
* matching `turn/end` and flush, including when the target append fails.
* Detachment requested by an event or flush listener is deferred until that
* sequence settles, so publication cannot switch from a live scoped session
* to an unobserved bare `Session` halfway through the update.
*
* @param session - exact live session that owns the target log.
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
* @param data - typed JSON payload for the target event.
* @param trigger - plugin-owned turn trigger used only when the log is closed.
* @returns the accepted target event with its assigned sequence and timestamp.
* @throws when the session is detached, another out-of-band append is active,
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
*/
async appendOutOfBand<T extends OutOfBandSessionEventType>(
session: Session,
type: T,
data: SessionEventMap[T],
trigger: TurnTrigger,
): Promise<SessionEvent<T>> {
const entry = this.liveEntryFor(session)
if (entry.outOfBand) {
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
}
entry.outOfBand = true
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
// TypeScript does not reduce Session.append's conditional rest parameter
// through a generic intersection. Preserve that proven two-argument call
// shape without widening the public Session.append overload.
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
eventType: K,
eventData: SessionEventMap[K],
) => SessionEvent<K>
try {
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastBoundary?.type === 'turn/start') {
return appendLogOnly(type, data)
}
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
let accepted: SessionEvent<T> | undefined
let failure: unknown
let opened = false
try {
session.append('turn/start', { turn, trigger })
opened = true
accepted = appendLogOnly(type, data)
} catch (error: unknown) {
failure = error
} finally {
if (opened) {
// The only target types admitted by OutOfBandSessionEventMap are
// log-only plugin events, so the synthetic turn remains open here.
session.append('turn/end', { turn, reason: { kind: 'completed' } })
try {
await this.flush(session)
} catch (error: unknown) {
if (failure === undefined) failure = error
}
}
}
if (failure !== undefined) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
throw failure
}
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
return accepted
} finally {
entry.outOfBand = false
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
}
}
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)
@@ -1029,9 +946,10 @@ export class SessionStore extends Service {
}
/**
* Create a live child session from a turn-enclosed prefix of a live source.
* Create a live child session from a stable prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. A non-empty selected slice must end at `turn/end`.
* current last event. The selected slice may end with a between-turn event
* but must not end inside an open turn.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1088,9 +1006,11 @@ export class SessionStore extends Service {
'INVALID_BOUNDARY',
)
}
if (boundaryEvent.type !== 'turn/end') {
const lastTurnBoundary = events.slice(0, boundary + 1)
.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastTurnBoundary?.type === 'turn/start') {
throw new SessionForkError(
`fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
'OPEN_TURN',
)
}

View File

@@ -66,8 +66,8 @@ function validateEvent(
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// Model input may be appended between turns without running the model.
// Merge-extensible package events remain turn-enclosed by default.
// Context and plugin-owned log-only events may be appended between model
// executions. Core execution events retain their explicit turn relations.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -144,12 +144,17 @@ function validateEvent(
}
case 'user/message':
break
default: {
case 'steering/message':
case 'todo/write':
case 'request/header': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}
break
}
default:
// Merge-extensible event relations belong to their owning plugin.
break
}
return {
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },

View File

@@ -252,23 +252,9 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
}
/**
* Marker map for plugin-owned log-only events accepted by
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
* ineligible unless their owner explicitly opts them into this narrow seam.
*/
export interface OutOfBandSessionEventMap {}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
export type OutOfBandSessionEventType = Exclude<
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
SurfaceEventType
>
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these

View File

@@ -4,6 +4,12 @@ import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
}
async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -83,6 +89,21 @@ describe('SessionStore.fork', () => {
})
})
it('includes stable log-only events appended after a closed turn', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('log-only-parent'))
appendClosedTurn(source, 1, 'hello')
source.append('test/log-only', { value: 'after execution' })
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
expect(child.events).toEqual(source.events)
expect(child.events.at(-1)).toMatchObject({
type: 'test/log-only',
data: { value: 'after execution' },
})
})
it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
@@ -241,7 +262,7 @@ describe('SessionStore.fork', () => {
const boundary = build(source)
expect(() => sessions.fork(source, boundary))
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN'))
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN'))
}
})

View File

@@ -117,7 +117,7 @@ describe('session-log invariants', () => {
} as never) }).toThrow(/seq must strictly increase/)
})
it('enforces turn numbering and encloses events other than idle context', async () => {
it('enforces turn numbering and core execution enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -144,9 +144,13 @@ describe('session-log invariants', () => {
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Merge-extensible session events use the same default enclosure branch.
// The owning plugin decides whether a merge-extensible event is log-only.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow()
expect(() => outside.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
it('enforces open-step identity and numbering', async () => {

View File

@@ -1,226 +0,0 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
interface OutOfBandSessionEventMap {
'test/log-only': true
}
}
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
describe('SessionStore.appendOutOfBand', () => {
it('joins an open turn without adding a boundary or flushing it', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('open'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const event = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'inside' },
updateTrigger,
)
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
expect(flushes).toBe(0)
})
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('closed'))
const flushedTypes: string[][] = []
ctx.on('session/flush', (flushed) => {
flushedTypes.push(flushed.events.map(event => event.type))
})
const first = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
const second = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'second' },
updateTrigger,
)
expect(first.seq).toBe(1)
expect(second.seq).toBe(4)
expect(session.events).toMatchObject([
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
])
expect(flushedTypes).toEqual([
['turn/start', 'test/log-only', 'turn/end'],
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
])
})
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('rejected'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toMatchObject([
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
])
expect(flushes).toBe(1)
})
it('does not flush when the synthetic turn cannot open', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('start-failure'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'unreachable' },
{ ...updateTrigger, invalid: 1n } as never,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toEqual([])
expect(flushes).toBe(0)
})
it('preserves a target rejection when the balancing flush also rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'turn/end',
])
})
it('keeps the session attached through publication and its flush', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('dispose'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
let liveDuringFlush = false
ctx.on('session/event', (_observed, event) => {
if (event.type === 'turn/start') detach()
})
ctx.on('session/flush', () => {
liveDuringFlush = ctx.sessions.get(session.id) === session
})
await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'last' },
updateTrigger,
)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
expect(liveDuringFlush).toBe(true)
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('rejects detached sessions before opening a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('detached'))
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'nope' },
updateTrigger,
)).rejects.toThrow('session "detached" is not live in this store')
expect(session.events).toEqual([])
})
it('leaves a balanced log when the durability checkpoint rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'accepted' },
updateTrigger,
)).rejects.toThrow('disk failed')
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
})
it('rejects overlapping updates while the first append is still settling', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('overlap'))
let release!: () => void
const checkpoint = new Promise<void>((resolve) => {
release = resolve
})
ctx.on('session/flush', () => checkpoint)
const first = ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'overlap' },
updateTrigger,
)).rejects.toThrow(/out-of-band append in progress/)
release()
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
})
})