Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
51
packages/support/invariants/README.md
Normal file
51
packages/support/invariants/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# dsh-invariants
|
||||
|
||||
Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently.
|
||||
|
||||
**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
|
||||
## Plugin
|
||||
|
||||
A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
await ctx.plugin(Invariants) // freeze on (default)
|
||||
await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze
|
||||
```
|
||||
|
||||
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist (so a hot reload mid-turn doesn't falsely reject the next event). It listens on `session/created`, `session/event`, and `agent/status`.
|
||||
|
||||
### Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. |
|
||||
|
||||
## Invariants asserted
|
||||
|
||||
Session log (per session):
|
||||
|
||||
- **`seq` strictly increases** — the spine of replay equivalence.
|
||||
- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
|
||||
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
|
||||
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
|
||||
- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown `tools/execute` waterfall ends the step with no `tool/result`, which is legal).
|
||||
|
||||
Agent status (per agent):
|
||||
|
||||
- **legal transitions only** — `idle↔running` and `(idle|running)→disposed`. A no-op transition (`setStatus` dedups, so it never fires) and leaving the terminal `disposed` state are violations.
|
||||
|
||||
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
|
||||
|
||||
## Why runtime, not deep-readonly types
|
||||
|
||||
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
|
||||
## Seeded sessions
|
||||
|
||||
A seeded/forked session arrives with events already in its log (the `Session` constructor copies the seed without emitting `session/event`). On `session/created` the plugin replays the existing log through the checker and freezes those entries, so seeded history is held to the same contract.
|
||||
36
packages/support/invariants/package.json
Normal file
36
packages/support/invariants/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-invariants",
|
||||
"description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
282
packages/support/invariants/src/index.ts
Normal file
282
packages/support/invariants/src/index.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Dev-mode invariants: a pure-listener plugin that asserts the harness event
|
||||
* contract at runtime, and (optionally) freezes logged session-event data so
|
||||
* any code that mutates history throws instead of corrupting silently.
|
||||
*
|
||||
* Everything is a plugin — this is just listeners on `session/created`,
|
||||
* `session/event`, and `agent/status`. It is **off in production**: enable it
|
||||
* in tests and the demos, where a contract violation should be a loud failure,
|
||||
* not a subtle one. It doubles as executable documentation of the event
|
||||
* taxonomy: the assertions below ARE the contract.
|
||||
*
|
||||
* Why runtime assertions instead of compile-time deep-readonly types? See
|
||||
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
|
||||
* every log consumer and a plugin casts straight through it; a dev-mode freeze
|
||||
* + assertions catch real corruption at zero production cost and zero type
|
||||
* noise. The always-on half of that defense (cloning derived messages) lives
|
||||
* in dsh-session; this package is the dev-mode tripwire.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-invariants
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export const name = 'invariants'
|
||||
export const inject = ['sessions']
|
||||
|
||||
/**
|
||||
* Thrown when a harness event-contract invariant is violated. Extends
|
||||
* {@link HarnessError} (`code: 'INVARIANT'`) so a violation is routable like
|
||||
* any other harness failure.
|
||||
*/
|
||||
export class InvariantError extends HarnessError {
|
||||
constructor(message: string) {
|
||||
super(`invariant violated: ${message}`, 'INVARIANT')
|
||||
this.name = 'InvariantError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugin config. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Deep-freeze logged session-event data so mutating a logged event throws.
|
||||
* Default true — this plugin only runs in dev/test, where freezing is the
|
||||
* point. Set false to assert the event contract without freezing.
|
||||
*/
|
||||
freeze?: boolean
|
||||
}
|
||||
|
||||
/** Per-session bookkeeping for the session-log invariants. */
|
||||
interface SessionTrace {
|
||||
/** Highest `seq` seen so far (must strictly increase). */
|
||||
lastSeq: number
|
||||
/** Open turn number, or null between turns. */
|
||||
openTurn: number | null
|
||||
/** Open step within the current turn, or null between steps. */
|
||||
openStep: number | null
|
||||
/** The next turn number expected in this session log. */
|
||||
nextTurn: number
|
||||
/** The next step number expected within the open turn. */
|
||||
nextStep: number
|
||||
/**
|
||||
* Tool-call ids issued in the OPEN step awaiting a result. Cleared at
|
||||
* `step/end` — a result must arrive in the same step as its call.
|
||||
*/
|
||||
pendingCalls: Set<CallId>
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-freeze a value and everything reachable from it.
|
||||
*
|
||||
* Walks every object's own properties even when the object itself is already
|
||||
* frozen: `Session.append()` accepts event data from arbitrary plugins/tools,
|
||||
* so a caller can hand us a SHALLOW-frozen object whose descendants are still
|
||||
* mutable. Skipping an already-frozen node (the obvious idempotence shortcut)
|
||||
* would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A
|
||||
* `WeakSet` of visited objects keeps it terminating on cycles and avoids
|
||||
* re-walking shared subtrees / already-processed seed events.
|
||||
*/
|
||||
function deepFreeze(value: unknown, seen: WeakSet<object> = new WeakSet()): void {
|
||||
if (value === null || typeof value !== 'object') return
|
||||
if (seen.has(value)) return
|
||||
seen.add(value)
|
||||
// Freeze the node (no-op if a caller pre-froze it), then ALWAYS descend —
|
||||
// a frozen container can still hold mutable children.
|
||||
Object.freeze(value)
|
||||
for (const key of Object.keys(value)) {
|
||||
deepFreeze((value as Record<string, unknown>)[key], seen)
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert that a step-scoped event names the currently open turn and step. */
|
||||
function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void {
|
||||
if (trace.openTurn !== turn || trace.openStep !== step) {
|
||||
throw new InvariantError(
|
||||
`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert one appended event against the per-session invariants. */
|
||||
function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
// seq is strictly monotonic — the spine of replay equivalence. lastSeq
|
||||
// starts at -1, so the first event (seq 0) passes.
|
||||
if (event.seq <= trace.lastSeq) {
|
||||
throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
|
||||
}
|
||||
trace.lastSeq = event.seq
|
||||
|
||||
// Boundary/step-scoped events have explicit cases; every OTHER event type —
|
||||
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
|
||||
// by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an
|
||||
// unknown variant is valid, not a compile error.
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
if (trace.openTurn !== null) {
|
||||
throw new InvariantError(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
|
||||
}
|
||||
// Current sessions replay full logs, so numbering starts at 1 and remains
|
||||
// contiguous. If a future compaction/fork stores a partial log, it must
|
||||
// seed `nextTurn` from retained metadata before this check runs.
|
||||
if (event.data.turn !== trace.nextTurn) {
|
||||
throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
|
||||
}
|
||||
trace.openTurn = event.data.turn
|
||||
trace.nextStep = 1
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (trace.openTurn !== event.data.turn) {
|
||||
throw new InvariantError(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
|
||||
}
|
||||
if (trace.openStep !== null) {
|
||||
throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
trace.openTurn = null
|
||||
trace.nextTurn += 1
|
||||
break
|
||||
}
|
||||
case 'step/start': {
|
||||
if (trace.openTurn !== event.data.turn) {
|
||||
throw new InvariantError(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
|
||||
}
|
||||
if (trace.openStep !== null) {
|
||||
throw new InvariantError(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
// Steps are checked under the same full-log assumption as turns above.
|
||||
if (event.data.step !== trace.nextStep) {
|
||||
throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
|
||||
}
|
||||
trace.openStep = event.data.step
|
||||
break
|
||||
}
|
||||
case 'step/end': {
|
||||
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step)
|
||||
// A result must arrive in the step that issued the call; orphan calls
|
||||
// (a step that errored before its result) do not carry to the next step.
|
||||
trace.pendingCalls.clear()
|
||||
trace.openStep = null
|
||||
trace.nextStep += 1
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step)
|
||||
break
|
||||
}
|
||||
case 'tool/call': {
|
||||
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step)
|
||||
trace.pendingCalls.add(event.data.callId)
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
|
||||
// A result needs a prior matching call in the same step. (The converse
|
||||
// does NOT hold: a call may have no result — a throwing tools/execute
|
||||
// waterfall ends the step with no tool/result, which is legal.)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) {
|
||||
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary
|
||||
// case above must sit inside an open turn. The durable session log uses the
|
||||
// turn as its commit/replay boundary (the JSONL backend treats anything
|
||||
// after the last turn/end as a crash tail), so a bare event between turns is
|
||||
// silently dropped on reload. The loop records queued user messages after
|
||||
// turn/start, and an idle agent.inject() wraps its context/message in a
|
||||
// one-shot turn. A `default`
|
||||
// (not an enumerated list) is deliberate: SessionEventMap is
|
||||
// merge-extensible, so a PLUGIN-added event type appended while idle must
|
||||
// also fail here rather than fall through and be dropped on resume.
|
||||
default: {
|
||||
if (trace.openTurn === null) {
|
||||
throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Legal agent status transitions (the only state machine the loop guarantees). */
|
||||
function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
|
||||
// First observation: any status is a valid starting point.
|
||||
if (from === undefined) return
|
||||
// A no-op transition is illegal — setStatus dedups, so we never see it.
|
||||
if (from === to) {
|
||||
throw new InvariantError(`agent/status repeated ${to} (no-op transition)`)
|
||||
}
|
||||
// Leaving `disposed` is illegal — disposal is terminal.
|
||||
if (from === 'disposed') {
|
||||
throw new InvariantError(`agent/status left terminal state disposed → ${to}`)
|
||||
}
|
||||
// idle↔running and (idle|running)→disposed are all legal; nothing else exists.
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dev-mode invariants. Contributions are effect-scoped, so
|
||||
* disposing the plugin fiber removes all listeners and stops freezing
|
||||
* (HMR-safe). On (re-)apply the trace state is rebuilt by replaying each
|
||||
* existing session's log, so a hot reload mid-turn does not falsely reject the
|
||||
* next event.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const freeze = config.freeze ?? true
|
||||
const traces = new WeakMap<Session, SessionTrace>()
|
||||
// Agent status has no stored history to replay; the first observation after
|
||||
// (re-)apply seeds the baseline, so a reload never produces a false positive.
|
||||
const lastStatus = new WeakMap<Agent, AgentStatus>()
|
||||
|
||||
const freshTrace = (): SessionTrace => ({
|
||||
lastSeq: -1,
|
||||
openTurn: null,
|
||||
openStep: null,
|
||||
nextTurn: 1,
|
||||
nextStep: 1,
|
||||
pendingCalls: new Set(),
|
||||
})
|
||||
|
||||
/** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */
|
||||
const seedSession = (session: Session): SessionTrace => {
|
||||
const trace = freshTrace()
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
checkEvent(trace, event)
|
||||
if (freeze) deepFreeze(event)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
// Every store-created session (the only kind that emits session/event) is
|
||||
// seeded first — via ctx.sessions.list() at apply or session/created — so
|
||||
// the fallback is a defensive guard, never hit in practice.
|
||||
/* v8 ignore next -- traceFor's fallback: session/event always follows a seed */
|
||||
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
|
||||
|
||||
// Rebuild state for sessions that already exist at (re-)apply time — HMR
|
||||
// reload starts a fresh fiber, and a mid-turn session would otherwise look
|
||||
// like it began with a stray chunk/step-end.
|
||||
for (const session of ctx.sessions.list()) seedSession(session)
|
||||
|
||||
// A newly created session may arrive seeded/forked (the constructor copies
|
||||
// the seed WITHOUT emitting session/event), so replay its log here too.
|
||||
ctx.on('session/created', (session) => { seedSession(session) })
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
checkEvent(traceFor(session), event)
|
||||
if (freeze) deepFreeze(event)
|
||||
})
|
||||
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
checkTransition(lastStatus.get(agent), status)
|
||||
lastStatus.set(agent, status)
|
||||
})
|
||||
}
|
||||
428
packages/support/invariants/tests/invariants.spec.ts
Normal file
428
packages/support/invariants/tests/invariants.spec.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
/** A Context with the session store and the invariants plugin registered. */
|
||||
async function setup(config?: { freeze?: boolean }) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(Invariants, config ?? {})
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
/** A minimal Agent stand-in for agent/status emission. */
|
||||
function mockAgent(id: string): Agent {
|
||||
return { id } as unknown as Agent
|
||||
}
|
||||
|
||||
describe('session-log invariants', () => {
|
||||
it('accepts a well-formed turn/step/tool sequence', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-monotonic seq (replay spine)', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// Session.append enforces seq-contiguity at the source, so drive the
|
||||
// invariants seq check directly via session/event with a regressing seq.
|
||||
ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
|
||||
expect(() => { ctx.emit('session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) })
|
||||
.toThrow(/seq must strictly increase/)
|
||||
})
|
||||
|
||||
it('rejects a turn/start while another turn is open', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
|
||||
it('rejects a turn/end that does not match the open turn', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
|
||||
.toThrow(/does not match open turn 1/)
|
||||
})
|
||||
|
||||
it('rejects a step/start outside its declared turn', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
|
||||
})
|
||||
|
||||
it('rejects a step/end that does not match the open step', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => session.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
|
||||
})
|
||||
|
||||
it('rejects an assistant/chunk outside an open step', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }))
|
||||
.toThrow(/open is turn 1\/step null/)
|
||||
})
|
||||
|
||||
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
.toThrow(/outside any open turn/)
|
||||
expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects steering and plugin-added events appended outside any open turn', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// steering/message is turn-scoped: outside a turn it would land past the
|
||||
// commit boundary and be dropped on resume (the turn-enclosure RFC).
|
||||
expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
.toThrow(/outside any open turn/)
|
||||
// A PLUGIN-added (merge-extensible) event type is caught by the default too.
|
||||
expect(() => session.append('compaction/marker' as never, { foo: 'bar' } as never))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('accepts message events once a turn is open', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tool/result with no prior tool/call', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }))
|
||||
.toThrow(/no prior tool\/call/)
|
||||
})
|
||||
|
||||
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [
|
||||
{ type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' },
|
||||
] })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('crashed'),
|
||||
content: [{ type: 'text', text: 'interrupted' }],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
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('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('holds seeded sessions to the contract on session/created', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
// A seq-contiguous, serializable seed (so it passes Session's constructor
|
||||
// validation) that nonetheless violates turn nesting — a second turn/start
|
||||
// while the first turn is still open — must be rejected by the invariants
|
||||
// plugin when it replays the seed on session/created.
|
||||
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)
|
||||
})
|
||||
|
||||
it('tracks turns per session independently', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
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' } } })
|
||||
// b is a fresh session — its own turn/start must not see a's open turn.
|
||||
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts multiple steps in a turn and consecutive turns', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', { turn: 1, step: 2, content: [] })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a skipped turn number', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(() => session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/expected turn 2, got 3/)
|
||||
})
|
||||
|
||||
it('rejects a skipped step number within a turn', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
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('step/end', { turn: 1, step: 1 })
|
||||
expect(() => session.append('step/start', { turn: 1, step: 3 }))
|
||||
.toThrow(/expected step 2 in turn 1, got 3/)
|
||||
})
|
||||
|
||||
it('rejects a turn/end while a step is still open', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
|
||||
.toThrow(/while step 1 is still open/)
|
||||
})
|
||||
|
||||
it('rejects a step/start while a step is still open', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => session.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
|
||||
})
|
||||
|
||||
it('rejects a tool/result satisfying a call from a previous step', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
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: '{}' })
|
||||
// step ends with the call unresolved — pendingCalls is cleared.
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
expect(() => session.append('tool/result', { turn: 1, step: 2, callId: CallId('c1'), content: [], isError: false }))
|
||||
.toThrow(/no prior tool\/call in this step/)
|
||||
})
|
||||
|
||||
it('rejects an assistant/message naming the wrong step', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => session.append('assistant/message', { turn: 1, step: 2, content: [] }))
|
||||
.toThrow(/open is turn 1\/step 1/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR state rebuild', () => {
|
||||
it('rebuilds trace state for a session that exists at (re-)apply time', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// First registration, mid-turn: a turn is open when the plugin reloads.
|
||||
const first = await ctx.plugin(Invariants, { freeze: false })
|
||||
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 first.dispose()
|
||||
|
||||
// Re-apply (HMR): the fresh fiber must replay the existing log so the open
|
||||
// step is known — the next chunk must NOT be a false positive.
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
|
||||
.not.toThrow()
|
||||
// And a genuine violation is still caught after the rebuild.
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev-freeze', () => {
|
||||
it('freezes appended event data so mutating a logged event throws', async () => {
|
||||
const { ctx } = await setup() // freeze defaults true
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
expect(Object.isFrozen(event)).toBe(true)
|
||||
expect(Object.isFrozen(event.data)).toBe(true)
|
||||
expect(Object.isFrozen(event.data.content)).toBe(true)
|
||||
expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow()
|
||||
})
|
||||
|
||||
it('does not freeze when freeze:false', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
expect(Object.isFrozen(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('freezes seeded events on session/created', async () => {
|
||||
const { ctx } = await setup()
|
||||
const seed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } },
|
||||
]
|
||||
const session = ctx.sessions.create(undefined, { seed })
|
||||
expect(Object.isFrozen(session.events[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('freezes mutable descendants of a shallow-frozen event datum', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// A caller hands in a SHALLOW-frozen block whose nested array is still
|
||||
// mutable. deepFreeze must descend into the already-frozen object and
|
||||
// freeze the descendant, not short-circuit on the frozen container —
|
||||
// otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches.
|
||||
// `append` snapshots `data`, so the freeze applies to the LOGGED clone, not
|
||||
// the caller's input — read the event back and assert on its data.
|
||||
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]
|
||||
const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false })
|
||||
const event = session.append('user/message', { content: [block], source: { kind: 'user' } })
|
||||
const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] }
|
||||
expect(Object.isFrozen(logged.content)).toBe(true)
|
||||
expect(Object.isFrozen(logged.content[0])).toBe(true)
|
||||
expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow()
|
||||
})
|
||||
|
||||
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
// The deep-freeze WeakSet guard must terminate on a self-referential
|
||||
// structure rather than recursing forever. Session.append now rejects
|
||||
// non-serializable (incl. cyclic) data at the source, so drive the freeze
|
||||
// handler directly via hand-built session/events — exactly the shape the
|
||||
// invariants listener receives. Open a turn first (seq 0) so the cyclic
|
||||
// user/message (seq 1) satisfies the turn-enclosure invariant.
|
||||
ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
|
||||
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
|
||||
cyclic['self'] = cyclic
|
||||
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
|
||||
expect(() => { ctx.emit('session/event', session, event as never) }).not.toThrow()
|
||||
expect(Object.isFrozen(cyclic)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent status invariants', () => {
|
||||
it('accepts legal transitions: idle→running→idle and →disposed', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const agent = mockAgent('a1')
|
||||
expect(() => {
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
ctx.emit('agent/status', agent, 'running')
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
ctx.emit('agent/status', agent, 'disposed')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts running→disposed', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const agent = mockAgent('a2')
|
||||
ctx.emit('agent/status', agent, 'running')
|
||||
expect(() => { ctx.emit('agent/status', agent, 'disposed') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a no-op transition', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const agent = mockAgent('a3')
|
||||
ctx.emit('agent/status', agent, 'running')
|
||||
expect(() => { ctx.emit('agent/status', agent, 'running') }).toThrow(/no-op transition/)
|
||||
})
|
||||
|
||||
it('rejects leaving the terminal disposed state', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const agent = mockAgent('a4')
|
||||
ctx.emit('agent/status', agent, 'disposed')
|
||||
expect(() => { ctx.emit('agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/)
|
||||
})
|
||||
|
||||
it('tracks status per agent independently', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const a = mockAgent('a5')
|
||||
const b = mockAgent('b5')
|
||||
ctx.emit('agent/status', a, 'running')
|
||||
// b's first observation is independent of a.
|
||||
expect(() => { ctx.emit('agent/status', b, 'running') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR safety', () => {
|
||||
it('removes all listeners when the plugin fiber 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()
|
||||
|
||||
// After disposal: no freezing, no assertions. An event that WOULD have
|
||||
// violated the open-turn rule now passes silently, and is not frozen.
|
||||
const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(Object.isFrozen(event)).toBe(false)
|
||||
// A no-op status transition no longer throws either.
|
||||
const agent = mockAgent('hmr')
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
expect(() => { ctx.emit('agent/status', agent, 'idle') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('InvariantError carries a stable code', () => {
|
||||
const err = new InvariantError('seq must strictly increase')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('InvariantError')
|
||||
expect(err.code).toBe('INVARIANT')
|
||||
expect(err.message).toBe('invariant violated: seq must strictly increase')
|
||||
})
|
||||
|
||||
it('does not leak listeners across dispose (no stale freezing)', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
await fiber.dispose()
|
||||
const spy = vi.fn()
|
||||
ctx.on('session/event', spy)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
|
||||
// our own spy fires, proving events still flow — but the plugin's frozen.
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
expect(Object.isFrozen(session.events[0])).toBe(false)
|
||||
})
|
||||
})
|
||||
27
packages/support/invariants/tsconfig.json
Normal file
27
packages/support/invariants/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user