Merge remote-tracking branch 'origin/master' into worktree/semantic-session-checkpoints

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/tui-agent/tests/tui-keyless-smoke.e2e.ts
#	packages/examples/acp-demo/package.json
#	packages/examples/cli-demo/tsconfig.json
#	packages/support/invariants/src/index.ts
#	packages/support/invariants/tests/invariants.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-21 20:09:37 +08:00
634 changed files with 21549 additions and 3328 deletions

View File

@@ -2,6 +2,8 @@
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package.
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
@@ -34,7 +36,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.

View File

@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,12 +28,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -0,0 +1,239 @@
/**
* Package-owned relational invariants for the session event log. Load this
* companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
*
* @module @deepseek-ai/dsh-session/invariant
*/
import type { Context } from 'cordis'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { TOOL_NOT_STARTED } from './repair.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-session'
/** Cordis companion plugin name. */
export const name = 'session-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Per-session bookkeeping for relational log checks. */
interface SessionTrace {
lastSeq: number
openTurn: number | null
openStep: number | null
nextTurn: number
nextStep: number
pendingCalls: Set<CallId>
}
/** One accepted event's deferred mutation of a committed session trace. */
interface SessionTraceTransition {
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
pendingCalls:
| { kind: 'none' }
| { kind: 'add' | 'delete'; callId: CallId }
| { kind: 'clear' }
}
/** Assert that a step-scoped event names the currently open turn and step. */
function requireOpenStep(
trace: SessionTrace,
kind: string,
turn: number,
step: number,
fail: InvariantFailure,
): void {
if (trace.openTurn !== turn || trace.openStep !== step) {
fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`)
}
}
/** Validate one candidate event without mutating the committed trace. */
function validateEvent(
trace: SessionTrace,
event: SessionEvent,
fail: InvariantFailure,
): SessionTraceTransition {
if (event.seq <= trace.lastSeq) {
fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
}
let openTurn = trace.openTurn
let openStep = trace.openStep
let nextTurn = trace.nextTurn
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// SessionEventMap is merge-extensible, so the default enforces turn
// enclosure for package-added events as well as the built-in variants.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
}
if (event.data.turn !== trace.nextTurn) {
fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
}
openTurn = event.data.turn
nextStep = 1
break
}
case 'turn/end': {
if (trace.openTurn !== event.data.turn) {
fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
}
if (trace.openStep !== null) {
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
}
openTurn = null
nextTurn += 1
break
}
case 'step/start': {
if (trace.openTurn !== event.data.turn) {
fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
}
if (trace.openStep !== null) {
fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
}
if (event.data.step !== trace.nextStep) {
fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
}
openStep = event.data.step
break
}
case 'step/end': {
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
pendingCalls = { kind: 'clear' }
openStep = null
nextStep += 1
break
}
case 'assistant/chunk': {
requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
break
}
case 'assistant/message': {
requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail)
break
}
case 'tool/call': {
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail)
pendingCalls = { kind: 'add', callId: event.data.callId }
break
}
case 'tool/result': {
// Session has already validated a provenance-backed content rewrite.
// It is durable turn work, not a second execution of the original call.
if (event.surfaceOp !== 'append') {
if (trace.openTurn === null) {
fail('tool/result surface replacement appended outside any open turn')
}
break
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
const syntheticNotStarted = event.data.isError && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(event.data.callId) && !syntheticNotStarted) {
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
}
pendingCalls = { kind: 'delete', callId: event.data.callId }
break
}
default: {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
}
break
}
}
return {
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
pendingCalls,
}
}
/** Apply one already-validated transition after its event commits. */
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
Object.assign(trace, transition.scalars)
switch (transition.pendingCalls.kind) {
case 'none':
break
case 'add':
trace.pendingCalls.add(transition.pendingCalls.callId)
break
case 'delete':
trace.pendingCalls.delete(transition.pendingCalls.callId)
break
case 'clear':
trace.pendingCalls.clear()
break
/* v8 ignore next -- validateEvent produces this closed transition union */
default:
assertNever(transition.pendingCalls, 'session trace pending-call transition')
}
}
/** Install the session contribution into its child registration fiber. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, SessionTrace>()
const stagedTransitions = new WeakMap<SessionEvent, {
session: Session
trace: SessionTrace
transition: SessionTraceTransition
}>()
const freshTrace = (): SessionTrace => ({
lastSeq: -1,
openTurn: null,
openStep: null,
nextTurn: 1,
nextStep: 1,
pendingCalls: new Set(),
})
const seedSession = (session: Session): SessionTrace => {
const trace = freshTrace()
traces.set(session, trace)
for (const event of session.events) {
applyTransition(trace, validateEvent(trace, event, fail))
}
return trace
}
/* v8 ignore next -- session/event always follows list() or session/created seeding */
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
for (const session of ctx.sessions.list()) seedSession(session)
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
ctx.on('session/event', (session, event) => {
const staged = stagedTransitions.get(event)
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
if (staged === undefined || staged.session !== session) {
return fail('session/event reached publication without matching pre-commit validation')
}
stagedTransitions.delete(event)
applyTransition(staged.trace, staged.transition)
}, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const trace = traceFor(session)
const transition = validateEvent(trace, event, fail)
// A later dispatch listener may veto. Validation is pure, so abandoning
// this weakly keyed transition does not advance or retain the session.
stagedTransitions.set(event, { session, trace, transition })
}, { global: true })
}, { inject: ['sessions'] })
/**
* Register the session invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,344 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(SessionInvariant)
return { ctx, fiber }
}
describe('session-log invariants', () => {
it('keeps registration global when the companion is mounted under a scope', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
let scopedCtx!: Context
await ctx.plugin(Object.assign((inner: Context) => {
scopedCtx = createScope(inner, {}).ctx
}, { inject: ['sessions', 'invariants'] }))
await scopedCtx.plugin(SessionInvariant)
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('accepts a well-formed turn, step, and tool sequence', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('does not advance committed trace state when a later dispatch listener vetoes', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
let veto = true
ctx.on('internal/dispatch', (_mode, name) => {
if (name !== 'session/event' || !veto) return
veto = false
throw new Error('later dispatch veto')
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('later dispatch veto')
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('applies the committed transition after another postcommit observer throws', async () => {
const { ctx } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('postcommit-peer'))
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(warnings).toHaveLength(2)
})
it('rejects non-monotonic event sequence numbers', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
} as never)
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/end',
seq: 0,
time: 2,
data: { turn: 1, reason: { kind: 'completed' } },
} as never) }).toThrow(/seq must strictly increase/)
})
it('enforces turn numbering and enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
.toThrow(/does not match open turn 1/)
const second = (await setup()).ctx.sessions.create()
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/expected turn 2, got 3/)
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', {
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
expect(() => outside.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Merge-extensible session events use the same default enclosure branch.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
})
it('enforces open-step identity and numbering', async () => {
const wrongTurn = (await setup()).ctx.sessions.create()
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
const nested = (await setup()).ctx.sessions.create()
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
nested.append('step/start', { turn: 1, step: 1 })
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.toThrow(/while step 1 is still open/)
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
expect(() => nested.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 2,
content: [],
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
skipped.append('step/start', { turn: 1, step: 1 })
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
})
it('requires step-scoped stream and tool events to name the open step', async () => {
const chunk = (await setup()).ctx.sessions.create()
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => chunk.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'x' },
})).toThrow(/open is turn 1\/step null/)
const tool = (await setup()).ctx.sessions.create()
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
tool.append('step/start', { turn: 1, step: 1 })
expect(() => tool.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('ghost'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
})
it('keeps fresh tool-result appends open-step checked', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('closed'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
})
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).not.toThrow()
})
it('rejects a tool-result replacement outside a turn', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/outside any open turn/)
})
it('allows not-started repair results and unresolved calls at step end', async () => {
const repaired = (await setup()).ctx.sessions.create()
expect(() => {
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
repaired.append('step/start', { turn: 1, step: 1 })
repaired.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('crashed'),
content: [],
isError: true,
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
}, { surfaceOp: 'append' })
repaired.append('step/end', { turn: 1, step: 1 })
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
}).not.toThrow()
const unresolved = (await setup()).ctx.sessions.create()
expect(() => {
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unresolved.append('step/start', { turn: 1, step: 1 })
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
unresolved.append('step/end', { turn: 1, step: 1 })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
}).not.toThrow()
})
it('does not let a result in a later step satisfy an earlier call', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
expect(() => session.append('tool/result', {
turn: 1,
step: 2,
callId: CallId('c1'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
})
it('replays seeded sessions and tracks each session independently', async () => {
const { ctx } = await setup()
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
]
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
.not.toThrow()
})
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await fiber.dispose()
await ctx.plugin(SessionInvariant)
expect(() => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'h' },
})).not.toThrow()
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
})
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
expect(() => session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
})

View File

@@ -717,10 +717,11 @@ describe('SessionStore', () => {
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events[0]![0]).toBe(session)
expect(events[0]![1].type).toBe('user/message')
expect(events).toHaveLength(2)
expect(events[1]![0]).toBe(session)
expect(events[1]![1].type).toBe('user/message')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.sessions.list()).toEqual([session])
@@ -732,6 +733,7 @@ describe('SessionStore', () => {
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
@@ -973,8 +975,9 @@ describe('SessionStore', () => {
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events.at(-1)?.type).toBe('user/message')
})
it('contains session/event observer failures after the append commit point', async () => {
@@ -1058,6 +1061,8 @@ describe('SessionStore', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', {
content: [{ type: 'text', text: 'source' }],
source: { kind: 'user' },
@@ -1077,19 +1082,19 @@ describe('SessionStore', () => {
step: 1,
content: [{ type: 'text', text: 'replacement' }],
}, {
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
})).toThrow('reject surface candidate')
expect(session.events).toHaveLength(1)
expect(surface.nodes).toEqual([0])
expect(session.events).toHaveLength(3)
expect(surface.nodes).toEqual([2])
expect(surface.replaceGeneration).toBe(0)
session.append('user/message', {
content: [{ type: 'text', text: 'next' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(surface.nodes).toEqual([0, 1])
expect(surface.nodes).toEqual([2, 3])
expect(surface.replaceGeneration).toBe(0)
})

View File

@@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
} as unknown as SessionEvent
}
function toolResultEvent(
seq: number,
callId: string,
surfaceOp: SurfaceEvent['surfaceOp'] = 'append',
sourceEventSeqs?: number[],
): SessionEvent {
return {
type: 'tool/result',
seq,
time: seq,
data: {
turn: 1,
step: 1,
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
},
surfaceOp,
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
}
}
describe('foldSurface provenance', () => {
it('accepts absent or valid provenance and complete replacement coverage', () => {
const events = [
@@ -94,6 +116,33 @@ describe('foldSurface provenance', () => {
)
})
describe('foldSurface tool-result rewrites', () => {
it('rejects a replacement spanning multiple current nodes', () => {
const events = [
provenanceEvent(0, undefined),
provenanceEvent(1, undefined),
toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]),
]
expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
})
it('rejects a replacement targeting a non-result node', () => {
const events = [
provenanceEvent(0, undefined),
toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]),
]
expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
})
it('rejects changes outside tool-result content', () => {
const events = [
toolResultEvent(0, 'original'),
toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]),
]
expect(() => foldSurface(events)).toThrow(/may change only content/)
})
})
describe('SurfaceManager', () => {
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'tsdown'
/** Build the package root and optional invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])