feat(invariants): add package-owned service seam
This commit is contained in:
@@ -1,62 +1,63 @@
|
||||
# dsh-invariants
|
||||
|
||||
Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior.
|
||||
Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Packages publish optional `./invariant` companion plugins that contribute their own assertions.
|
||||
|
||||
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
## Service: `InvariantService` (`ctx.invariants`)
|
||||
|
||||
Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
|
||||
```ts
|
||||
interface Config {
|
||||
enabled?: boolean
|
||||
package_allowlist?: string[]
|
||||
package_blocklist?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
|
||||
Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the empty allowlist or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches.
|
||||
|
||||
## Plugin
|
||||
Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic.
|
||||
|
||||
A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
|
||||
`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically.
|
||||
|
||||
The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation.
|
||||
|
||||
`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product-package dependency to the service.
|
||||
|
||||
## Package companions
|
||||
|
||||
| Companion | Registration | Checks |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace |
|
||||
| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions |
|
||||
| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency |
|
||||
| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log |
|
||||
|
||||
The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency.
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.plugin(InvariantService, {
|
||||
enabled: true,
|
||||
package_allowlist: ['^@deepseek-ai/dsh-'],
|
||||
package_blocklist: ['^@deepseek-ai/dsh-agent-loop$'],
|
||||
})
|
||||
ctx.plugin(SessionInvariant)
|
||||
```
|
||||
|
||||
`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 does not falsely reject the next event. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration.
|
||||
|
||||
## 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 tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
|
||||
- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
|
||||
|
||||
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.
|
||||
|
||||
Model requests (on `llm/stream`):
|
||||
|
||||
- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
|
||||
|
||||
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
|
||||
|
||||
## Why runtime assertions remain useful
|
||||
|
||||
Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly<SessionEvent>` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships wherever it is mounted while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
|
||||
## Seeded sessions
|
||||
|
||||
A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state.
|
||||
The standard agent spine mounts the service and all four companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams.
|
||||
None, as the service and companions observe runtime events and requests but never alter prompts, messages, schemas, streams, or tool results.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped.
|
||||
- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is.
|
||||
- The shipped checks cover only the four listed package contracts; a merge-extended event family has no family-specific assertion until its owner publishes one.
|
||||
- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract.
|
||||
- Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-invariants",
|
||||
"description": "Runtime event-contract assertions for DeepSeek Harness development diagnostics",
|
||||
"description": "Registry service for package-owned DeepSeek Harness runtime invariants",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -22,21 +22,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,409 +1,199 @@
|
||||
/**
|
||||
* Runtime listeners that fail loudly when cross-event contracts are broken:
|
||||
* turn and step nesting, scoped dispatch, status transitions, and request
|
||||
* reconstruction. The plugin has no environment guard and is active wherever
|
||||
* mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions
|
||||
* may omit it. Sessions own immutable, surface-valid event storage; this plugin
|
||||
* checks only relationships that event acceptance cannot express.
|
||||
* Configurable registry for package-owned runtime invariant contributions.
|
||||
* Packages register checks from optional `./invariant` companion plugins;
|
||||
* ordinary package entrypoints stay independent of diagnostics.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-invariants
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Inject } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
|
||||
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'
|
||||
}
|
||||
/** Runtime invariant selection configured on the service plugin. */
|
||||
export interface Config {
|
||||
/** Global switch; defaults to `true`. */
|
||||
readonly enabled?: boolean
|
||||
/** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */
|
||||
readonly package_allowlist?: string[]
|
||||
/** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */
|
||||
readonly package_blocklist?: string[]
|
||||
}
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* Throw a package-attributed invariant failure.
|
||||
* @param message - violated package contract without the standard prefix.
|
||||
* @returns never because reporting a violation throws.
|
||||
*/
|
||||
export type InvariantFailure = (message: string) => never
|
||||
|
||||
/** Install one package's listeners into the registration's child context. */
|
||||
export interface InvariantInstaller {
|
||||
/**
|
||||
* 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.
|
||||
* Install the package contribution.
|
||||
* @param ctx - child context owned by this invariant registration.
|
||||
* @param fail - reporter bound to the registering package name.
|
||||
* @returns nothing after synchronous listener installation completes.
|
||||
*/
|
||||
pendingCalls: Set<CallId>
|
||||
(ctx: Context, fail: InvariantFailure): void
|
||||
/** Services the child installer fiber may access. */
|
||||
readonly inject?: Inject
|
||||
}
|
||||
|
||||
/** One accepted event's deferred mutation of a live session trace. */
|
||||
interface SessionTraceTransition {
|
||||
/** Scalar state after the event commits. */
|
||||
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
|
||||
/** The event's mutation of the open step's pending call set. */
|
||||
pendingCalls:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'add' | 'delete'; callId: CallId }
|
||||
| { kind: 'clear' }
|
||||
/** Internal effect shape used to join child startup before a companion loads. */
|
||||
interface PendingInvariantRegistration extends PromiseLike<() => void> {
|
||||
(): void | Promise<void>
|
||||
}
|
||||
|
||||
/** 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}`,
|
||||
)
|
||||
/** Thrown when a package-owned runtime invariant is violated. */
|
||||
export class InvariantError extends Error {
|
||||
/** Stable machine-readable invariant failure code. */
|
||||
readonly code = 'INVARIANT' as const
|
||||
/** Full npm package name that owns the violated invariant. */
|
||||
readonly packageName: string
|
||||
|
||||
/**
|
||||
* Construct a package-attributed invariant failure.
|
||||
* @param packageName - full npm package name that registered the check.
|
||||
* @param message - violated contract, without the standard error prefix.
|
||||
*/
|
||||
constructor(packageName: string, message: string) {
|
||||
super(`invariant violated by "${packageName}": ${message}`)
|
||||
this.name = 'InvariantError'
|
||||
this.packageName = packageName
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one candidate event without mutating the committed session trace. */
|
||||
function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition {
|
||||
// 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}`)
|
||||
}
|
||||
let openTurn = trace.openTurn
|
||||
let openStep = trace.openStep
|
||||
let nextTurn = trace.nextTurn
|
||||
let nextStep = trace.nextStep
|
||||
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
openTurn = event.data.turn
|
||||
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`)
|
||||
}
|
||||
openTurn = null
|
||||
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}`)
|
||||
}
|
||||
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.
|
||||
pendingCalls = { kind: 'clear' }
|
||||
openStep = null
|
||||
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)
|
||||
pendingCalls = { kind: 'add', callId: 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 tool-execution
|
||||
// pipeline step ends the turn with no tool/result, which is legal.)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
pendingCalls = { kind: 'delete', callId: event.data.callId }
|
||||
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
|
||||
}
|
||||
}
|
||||
return {
|
||||
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
|
||||
pendingCalls,
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
invariants: InvariantService
|
||||
}
|
||||
}
|
||||
|
||||
/** 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')
|
||||
}
|
||||
/** Compile and validate one package-filter list. */
|
||||
function compilePatterns(field: 'package_allowlist' | 'package_blocklist', values: readonly string[]): RegExp[] {
|
||||
const seen = new Set<string>()
|
||||
return values.map((value) => {
|
||||
if (value.length === 0 || value.trim() !== value) {
|
||||
throw new Error(`invariants: ${field} entries must be non-blank and have no surrounding whitespace`)
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
throw new Error(`invariants: ${field} contains duplicate regex ${JSON.stringify(value)}`)
|
||||
}
|
||||
seen.add(value)
|
||||
try {
|
||||
return new RegExp(value)
|
||||
} catch (cause) {
|
||||
throw new Error(`invariants: ${field} contains invalid regex ${JSON.stringify(value)}`, { cause })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Validate and apply one event while rebuilding an already-committed log. */
|
||||
function replayEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
applyTransition(trace, validateEvent(trace, event))
|
||||
}
|
||||
|
||||
/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */
|
||||
function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
|
||||
if (from === undefined) return
|
||||
if (from === to) {
|
||||
throw new InvariantError(`agent/status repeated ${to} (no-op transition)`)
|
||||
}
|
||||
if (from === 'disposed') {
|
||||
throw new InvariantError(`agent/status left terminal state disposed → ${to}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the runtime invariants. Contributions are effect-scoped, so
|
||||
* disposing the plugin fiber removes all listeners (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.
|
||||
*
|
||||
* @param ctx - Cordis context that receives the invariant listeners.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const traces = new WeakMap<Session, SessionTrace>()
|
||||
const stagedTransitions = new WeakMap<SessionEvent, {
|
||||
session: Session
|
||||
trace: SessionTrace
|
||||
transition: SessionTraceTransition
|
||||
}>()
|
||||
// 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(),
|
||||
/** Package-owned invariant registry with global and regex-based selection. */
|
||||
export class InvariantService extends Service {
|
||||
static Config: Schema<Config> = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
package_allowlist: z.array(z.string()).default([]),
|
||||
package_blocklist: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
/** Build (or rebuild) a session's trace by replaying its whole log. */
|
||||
const seedSession = (session: Session): SessionTrace => {
|
||||
const trace = freshTrace()
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
replayEvent(trace, event)
|
||||
}
|
||||
return trace
|
||||
private readonly enabled: boolean
|
||||
private readonly ownerCtx: Context
|
||||
private readonly packageAllowlist: readonly RegExp[]
|
||||
private readonly packageBlocklist: readonly RegExp[]
|
||||
private readonly registrations = new Set<string>()
|
||||
|
||||
/**
|
||||
* Create and install the invariant registry.
|
||||
* @param ctx - Cordis context that owns the service.
|
||||
* @param config - global enablement and package-name regex filters.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'invariants')
|
||||
this.ownerCtx = ctx
|
||||
this.enabled = config.enabled ?? true
|
||||
this.packageAllowlist = compilePatterns('package_allowlist', config.package_allowlist ?? [])
|
||||
this.packageBlocklist = compilePatterns('package_blocklist', config.package_blocklist ?? [])
|
||||
}
|
||||
|
||||
// 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)
|
||||
/** Return whether one full package name passes the configured filters. */
|
||||
private selected(packageName: string): boolean {
|
||||
if (!this.enabled) return false
|
||||
if (this.packageAllowlist.length > 0
|
||||
&& !this.packageAllowlist.some(pattern => pattern.test(packageName))) return false
|
||||
return !this.packageBlocklist.some(pattern => pattern.test(packageName))
|
||||
}
|
||||
|
||||
// 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) }, { global: true })
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
// Session resolves dispatch before committing, so internal/dispatch has
|
||||
// already staged this exact event. A later dispatch veto skips every
|
||||
// session/event callback and therefore leaves the live trace unchanged.
|
||||
const staged = stagedTransitions.get(event)
|
||||
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
||||
if (staged === undefined || staged.session !== session) {
|
||||
throw new InvariantError('session/event reached publication without matching pre-commit validation')
|
||||
/**
|
||||
* Register one package's invariant installer. The package name is reserved
|
||||
* even when filtering disables its checks. Enabled installers run in a child
|
||||
* fiber; failure disposes that fiber and releases the reservation.
|
||||
* @param packageName - full npm package name that owns the contribution.
|
||||
* @param installer - synchronous listener installer for the child context.
|
||||
* @returns an effect-scoped disposer for the registration.
|
||||
*/
|
||||
register(packageName: string, installer: InvariantInstaller): () => void {
|
||||
if (packageName.length === 0 || packageName.trim() !== packageName || /\s/.test(packageName)) {
|
||||
throw new Error('invariants: packageName must be non-blank and contain no whitespace')
|
||||
}
|
||||
stagedTransitions.delete(event)
|
||||
applyTransition(staged.trace, staged.transition)
|
||||
}, { global: true })
|
||||
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
checkTransition(lastStatus.get(agent), status)
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
|
||||
// --- Scoped-dispatch invariants (the agent-scoping seam) ---------------
|
||||
//
|
||||
// Every scope-filtered event family must dispatch with a scope carrier
|
||||
// (scopeTarget) whose key IS the subject the event's arguments name —
|
||||
// a dispatch without one silently reverts that event to global delivery
|
||||
// (agent-scoped listeners over-hear foreign agents), and a mis-keyed one
|
||||
// delivers to the wrong agent's listeners. `internal/dispatch` fires
|
||||
// synchronously before listener delivery, so a violation throws at the
|
||||
// dispatching call site. The generated table maps each family to the unique
|
||||
// payload path whose Program type matches the real scopeTarget routing key;
|
||||
// `null` means the key is external to the payload, so only carrier presence
|
||||
// can be asserted.
|
||||
ctx.on('internal/dispatch', (_mode, name, args, thisArg) => {
|
||||
const subjectOf = scopedSubjectResolverFor(name)
|
||||
if (subjectOf === undefined) return
|
||||
if (!isScopeCarrier(thisArg)) {
|
||||
throw new InvariantError(
|
||||
`"${name}" is a scope-filtered event but was dispatched without a scope carrier — `
|
||||
+ 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))')
|
||||
}
|
||||
if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) {
|
||||
throw new InvariantError(
|
||||
`"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
|
||||
+ 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))')
|
||||
}
|
||||
if (name === 'session/event') {
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const trace = traceFor(session)
|
||||
const transition = validateEvent(trace, event)
|
||||
// The exact event identity reaches the contained post-commit listener.
|
||||
// A later internal/dispatch listener may still veto; because validation
|
||||
// is pure, abandoning this weakly keyed transition does not advance the
|
||||
// committed trace or retain the session.
|
||||
stagedTransitions.set(event, { session, trace, transition })
|
||||
}
|
||||
}, { global: true })
|
||||
|
||||
// Request-reconstruction cross-check (the reconstructability RFC): a
|
||||
// loop-built request — frozen envelope + live sessionId is the marker; a
|
||||
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must
|
||||
// be EXACTLY what the session log reconstructs:
|
||||
//
|
||||
// - messages: the folded header's session prefix (messagePrefix — the
|
||||
// `agent/session-prefix` product, logged on the header because no
|
||||
// session event carries it) followed by the
|
||||
// derivation over the log prefix strictly before the in-flight step's
|
||||
// `step/start` (the reconstruction boundary). The derivation is compared
|
||||
// against a FRESH Session built over that prefix — the same projection
|
||||
// code with zero shared state, so the live cache under test cannot vouch
|
||||
// for itself. Boundary-correct by construction: content appended after
|
||||
// the boundary (an `agent/request`-window inject) is legitimately absent
|
||||
// from this request, and a current-surface comparison would false-fire.
|
||||
// - header: every non-content field must equal the fold of the log's
|
||||
// `request/header` events — the loop logs the header event BEFORE
|
||||
// dispatch, so the fold already covers this request.
|
||||
//
|
||||
// Registered with `prepend: true` so a short-circuiting llm/stream listener
|
||||
// (the replay adapter returns its chunks without calling next()) cannot
|
||||
// silence the check by registering first. Prepend beats APPEND-registered
|
||||
// listeners only — two prepended listeners have no defined mutual order
|
||||
// (cordis unshift) — which is fine: correctness rests on the seq-bounded
|
||||
// fold below, never on listener timing.
|
||||
ctx.on('llm/stream', (options: GenerateOptions, next) => {
|
||||
if (options.sessionId === undefined || !Object.isFrozen(options)) return next()
|
||||
// GenerateOptions types sessionId as Branded<'SessionId'>, which IS
|
||||
// SessionId (dsh-llm cannot import it without a cycle) — no cast needed.
|
||||
const session = ctx.sessions.get(options.sessionId)
|
||||
if (!session) return next()
|
||||
if (!Object.isFrozen(options.messages)) {
|
||||
throw new InvariantError('a loop-built request must carry a frozen messages array')
|
||||
if (this.registrations.has(packageName)) {
|
||||
throw new Error(`invariants: package "${packageName}" is already registered`)
|
||||
}
|
||||
|
||||
const events = session.events
|
||||
// seq === index (checked above), so the last step/start's seq bounds the
|
||||
// prefix directly. The in-flight step's step/start is necessarily the
|
||||
// last one: the loop cannot open another step while this call streams.
|
||||
let boundary = -1
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
if (events[i]?.type === 'step/start') {
|
||||
boundary = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (boundary === -1) {
|
||||
throw new InvariantError('a loop-built request with no step/start in its session log')
|
||||
}
|
||||
const header = foldRequestHeader(events)
|
||||
if (header === undefined) {
|
||||
throw new InvariantError('a loop-built request with no request/header event in its session log')
|
||||
}
|
||||
const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
|
||||
// The reconstruction equation: the folded header's session prefix, then
|
||||
// the boundary derivation — the loop
|
||||
// logs the header event BEFORE dispatch, so the fold already covers this
|
||||
// request's prefix. JSON equality is sound here: both sides are
|
||||
// structuredClones produced by the same projection/build code path, so key
|
||||
// insertion order matches when the values do.
|
||||
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
|
||||
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
|
||||
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
|
||||
}
|
||||
// Service method tracing binds `this.ctx` to the caller. This explicit
|
||||
// origin keeps registrations and their child fibers owned by the service;
|
||||
// companion disposal is covered independently by the returned disposer.
|
||||
const ctx = this.ownerCtx
|
||||
const registrations = this.registrations
|
||||
registrations.add(packageName)
|
||||
|
||||
const headerMatches = options.model === header.config.model
|
||||
&& options.system === header.system
|
||||
&& options.temperature === header.config.temperature
|
||||
&& options.maxTokens === header.config.maxTokens
|
||||
&& JSON.stringify(options.stop) === JSON.stringify(header.config.stop)
|
||||
&& JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? [])
|
||||
if (!headerMatches) {
|
||||
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`)
|
||||
let registration: PendingInvariantRegistration
|
||||
try {
|
||||
registration = ctx.effect(async () => {
|
||||
if (!this.selected(packageName)) {
|
||||
return () => {
|
||||
registrations.delete(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
const installInvariant = (childCtx: Context) => {
|
||||
installer(childCtx, (message): never => {
|
||||
throw new InvariantError(packageName, message)
|
||||
})
|
||||
}
|
||||
const child = ctx.plugin(installer.inject === undefined
|
||||
? installInvariant
|
||||
: Object.assign(installInvariant, { inject: installer.inject }))
|
||||
|
||||
try {
|
||||
await child
|
||||
} catch (error) {
|
||||
try {
|
||||
await child.dispose()
|
||||
} finally {
|
||||
registrations.delete(packageName)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await child.dispose()
|
||||
} finally {
|
||||
registrations.delete(packageName)
|
||||
}
|
||||
}
|
||||
}, `invariants.register(${JSON.stringify(packageName)})`)
|
||||
} catch (error) {
|
||||
registrations.delete(packageName)
|
||||
throw error
|
||||
}
|
||||
return next()
|
||||
}, { global: true, prepend: true })
|
||||
// Cordis attaches setup thenability and async teardown to this callable;
|
||||
// the service seam intentionally exposes only the conventional disposer.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- the extra runtime shape stays private.
|
||||
return registration
|
||||
}
|
||||
}
|
||||
|
||||
export default InvariantService
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Generated scoped-event routing-subject resolvers for dsh-invariants.
|
||||
* Do not edit by hand; run `pnpm run gen-scoped-events`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-invariants/scoped-events.generated
|
||||
*/
|
||||
|
||||
import type { Events } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
type ScopedEventName = {
|
||||
[K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never
|
||||
}[keyof Events]
|
||||
|
||||
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
|
||||
|
||||
function adapt<K extends ScopedEventName>(
|
||||
resolver: (args: Parameters<Events[K]>) => unknown,
|
||||
): ScopedSubjectResolver {
|
||||
return args => resolver(args as Parameters<Events[K]>)
|
||||
}
|
||||
|
||||
const scopedSubjectResolvers = Object.freeze({
|
||||
'agent/created': adapt<'agent/created'>(args => args[0]),
|
||||
'agent/disposed': adapt<'agent/disposed'>(args => args[0]),
|
||||
'agent/error': adapt<'agent/error'>(args => args[0]),
|
||||
'agent/post-step': adapt<'agent/post-step'>(args => args[0]),
|
||||
'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]),
|
||||
'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]),
|
||||
'agent/queued': adapt<'agent/queued'>(args => args[0]),
|
||||
'agent/request': adapt<'agent/request'>(args => args[0]),
|
||||
'agent/request-error': adapt<'agent/request-error'>(args => args[0]),
|
||||
'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]),
|
||||
'agent/session-start': adapt<'agent/session-start'>(args => args[0]),
|
||||
'agent/status': adapt<'agent/status'>(args => args[0]),
|
||||
'agent/step-result': adapt<'agent/step-result'>(args => args[0]),
|
||||
'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]),
|
||||
'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]),
|
||||
'approval/request': adapt<'approval/request'>(args => args[0].agent),
|
||||
'session/created': null,
|
||||
'session/disposed': null,
|
||||
'session/event': null,
|
||||
'session/flush': null,
|
||||
'subagent/end': null,
|
||||
'subagent/start': null,
|
||||
'system-prompt/assemble': adapt<'system-prompt/assemble'>(args => args[1].scope),
|
||||
'tools/execute': adapt<'tools/execute'>(args => args[0].agent),
|
||||
'tools/post-execute': adapt<'tools/post-execute'>(args => args[0].agent),
|
||||
'tools/pre-execute': adapt<'tools/pre-execute'>(args => args[0].agent),
|
||||
'tools/result': adapt<'tools/result'>(args => args[0].agent),
|
||||
} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)
|
||||
|
||||
const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers
|
||||
|
||||
/**
|
||||
* Resolve the routing key named by one scoped event payload. A null
|
||||
* resolver means the payload cannot expose its external routing key, so the
|
||||
* invariant checks carrier presence only.
|
||||
* @param event - runtime Cordis event name.
|
||||
* @returns the generated subject resolver, null for presence-only,
|
||||
* or undefined when the event is not scope-filtered.
|
||||
*/
|
||||
export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {
|
||||
return scopedSubjectResolverIndex[event]
|
||||
}
|
||||
@@ -1,851 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { Session, 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() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(Invariants)
|
||||
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('keeps pre-commit staging and post-commit application global when mounted under a scope', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let scopedCtx!: Context
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scopedCtx = createScope(inner, {}).ctx
|
||||
}, { inject: ['sessions'] }))
|
||||
await scopedCtx.plugin(Invariants)
|
||||
const globalSession = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
|
||||
|
||||
expect(() => {
|
||||
globalSession.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
globalSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a well-formed turn/step/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: [{ type: 'text', text: 'ok' }], 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 the trace when a later internal-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()
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
})
|
||||
|
||||
it('applies the committed transition after a prepended 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(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
expect(warnings).toEqual([
|
||||
'session "postcommit-peer": session/event listener threw: Error: hostile observer',
|
||||
'session "postcommit-peer": session/event listener threw: Error: hostile observer',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a non-monotonic seq (replay spine)', async () => {
|
||||
const { ctx } = await setup()
|
||||
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(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('rejects a turn/start while another turn is open', 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('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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects steering and plugin-added events appended outside any open turn', async () => {
|
||||
const { ctx } = await setup()
|
||||
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' } }, { surfaceOp: 'append' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
// A PLUGIN-added (merge-extensible) event type is caught by the default too.
|
||||
// Cast through `any`: 'compaction/marker' is not in SessionEventType (it's
|
||||
// merge-extensible), so the typed append() won't accept it. The test verifies
|
||||
// the runtime default-branch turn-enclosure check.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
|
||||
expect(() => (session.append as any)('compaction/marker', { foo: 'bar' }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('accepts message events once a turn is open', 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('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tool/result with no prior tool/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 })
|
||||
expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }, { surfaceOp: 'append' }))
|
||||
.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()
|
||||
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', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [
|
||||
{ type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' },
|
||||
] }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('crashed'),
|
||||
content: [{ type: 'text', text: 'interrupted' }],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
}, { surfaceOp: 'append' })
|
||||
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()
|
||||
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()
|
||||
// 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()
|
||||
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()
|
||||
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', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [] }, { surfaceOp: 'append' })
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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 }, { surfaceOp: 'append' }))
|
||||
.toThrow(/no prior tool\/call in this step/)
|
||||
})
|
||||
|
||||
it('rejects an assistant/message naming the wrong step', 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 })
|
||||
expect(() => session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [] }, { surfaceOp: 'append' }))
|
||||
.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)
|
||||
const first = await ctx.plugin(Invariants)
|
||||
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 mid-step: the new fiber must reconstruct the open boundaries from the log.
|
||||
await ctx.plugin(Invariants)
|
||||
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
|
||||
.not.toThrow()
|
||||
// Rebuild must not disable later violations.
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session immutability', () => {
|
||||
it('always freezes appended event data without the invariants plugin', () => {
|
||||
const session = new Session(SessionId('appended'))
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(Object.isFrozen(event)).toBe(true)
|
||||
expect(Object.isFrozen(event.data)).toBe(true)
|
||||
expect(Object.isFrozen(event.data.content)).toBe(true)
|
||||
expect(Object.isFrozen(event.data.content[0])).toBe(true)
|
||||
expect(Object.isFrozen(session.events)).toBe(true)
|
||||
expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow()
|
||||
})
|
||||
|
||||
it('always freezes seeded events without the invariants plugin', () => {
|
||||
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 } }, surfaceOp: 'append' as const },
|
||||
]
|
||||
const session = new Session(SessionId('seeded'), seed)
|
||||
expect(Object.isFrozen(seed[0])).toBe(false)
|
||||
expect(Object.isFrozen(session.events)).toBe(true)
|
||||
expect(Object.isFrozen(session.events[0])).toBe(true)
|
||||
expect(Object.isFrozen(session.events[0]?.data)).toBe(true)
|
||||
expect(Object.isFrozen(session.events[1]?.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('snapshots and freezes descendants of a shallow-frozen caller value', () => {
|
||||
const session = new Session(SessionId('shallow-frozen'))
|
||||
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' } }, { surfaceOp: 'append' })
|
||||
const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] }
|
||||
expect(Object.isFrozen(innerContent)).toBe(false)
|
||||
expect(Object.isFrozen(logged.content)).toBe(true)
|
||||
expect(Object.isFrozen(logged.content[0])).toBe(true)
|
||||
innerContent[0]!.text = 'caller mutation'
|
||||
expect(logged.content[0]!.text).toBe('inner')
|
||||
expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent status invariants', () => {
|
||||
it('accepts legal transitions: idle→running→idle and →disposed', async () => {
|
||||
const { ctx } = await setup()
|
||||
const agent = mockAgent('a1')
|
||||
expect(() => {
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts running→disposed', async () => {
|
||||
const { ctx } = await setup()
|
||||
const agent = mockAgent('a2')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a no-op transition', async () => {
|
||||
const { ctx } = await setup()
|
||||
const agent = mockAgent('a3')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/)
|
||||
})
|
||||
|
||||
it('rejects leaving the terminal disposed state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const agent = mockAgent('a4')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/)
|
||||
})
|
||||
|
||||
it('tracks status per agent independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = mockAgent('a5')
|
||||
const b = mockAgent('b5')
|
||||
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
|
||||
// b's first observation is independent of a.
|
||||
expect(() => { ctx.emit(scopeTarget(b, b), '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 the plugin's assertions are gone, so an event that would
|
||||
// violate the open-turn rule passes. Session still owns immutability.
|
||||
const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(Object.isFrozen(event)).toBe(true)
|
||||
// A no-op status transition no longer throws either.
|
||||
const agent = mockAgent('hmr')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), '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', 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' } }, { surfaceOp: 'append' })
|
||||
// The spy proves events still flow after plugin disposal. Session, not the
|
||||
// disposed listener, freezes the accepted record.
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
expect(Object.isFrozen(session.events[0])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface contract under the invariants composition', () => {
|
||||
it('accepts well-formed surface metadata', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
// Events must be turn-enclosed and step-scoped events need an open step.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts replace surface op', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] })
|
||||
// no throw — well-formed replace op
|
||||
})
|
||||
|
||||
it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', 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 })
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
}).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
}).toThrow(/must not be empty except on assistant\/message/)
|
||||
})
|
||||
|
||||
it('rejects duplicate sourceEventSeqs', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1, 1] })
|
||||
}).toThrow(/must not contain duplicates/)
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing the event itself (self-reference)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) // seq 0
|
||||
// The next event is seq 1. Referencing its own seq fails on "must reference
|
||||
// earlier events" (the check order is: earlier first, then unknown).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] })
|
||||
}).toThrow(/must reference earlier/)
|
||||
})
|
||||
|
||||
it('accepts sourceEventSeqs referencing a valid earlier event', async () => {
|
||||
// Session seqs are contiguous, so every non-negative ref below the current
|
||||
// seq necessarily names an existing earlier event.
|
||||
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 })
|
||||
// seqs so far: 0, 1. The next event at seq 2 references seq 1 → valid.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing a far-future seq', 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('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [99] })
|
||||
}).toThrow(/must reference earlier/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose start is positioned after its end on the surface', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Reversed range: start seq 3 is at a later surface position than end seq 2.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] })
|
||||
}).toThrow(/is after end seq 2/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Replace shadows surface nodes [2, 3] but records provenance for only [2].
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/must include every shadowed surface node; missing 3/)
|
||||
})
|
||||
|
||||
it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a replace naming a start seq that is not on the surface', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// seq 1 (step/start) is a real earlier event but never entered the surface.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
|
||||
}).toThrow(/start seq 1 not found in surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace naming an end seq that is not on the surface', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// start (2) is on the surface but end (99) never entered it.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/end seq 99 not found in surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4
|
||||
// precedes seq 3 in surface order even though 4 > 3 numerically.
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
|
||||
// A replace with start=3, end=4 passes the seq check (3 <= 4) but is
|
||||
// reversed positionally (3 is at pos 1, 4 is at pos 0).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5
|
||||
}).toThrow(/is after end seq 4/)
|
||||
})
|
||||
|
||||
it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the
|
||||
// head seq (4) is numerically GREATER than the tail seq (3): the surface is
|
||||
// not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is
|
||||
// valid positionally and must be accepted even though start seq > end seq.
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a replace that omits sourceEventSeqs entirely', 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('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// A replace with no sourceEventSeqs records no provenance for the node it shadows.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } })
|
||||
}).toThrow(/must include every shadowed surface node; missing 2/)
|
||||
})
|
||||
|
||||
it('catches an incomplete-provenance replace on the load/seed path', 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: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } },
|
||||
{ type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
||||
{ type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
||||
{ type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] },
|
||||
]
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('request-reconstruction cross-check (llm/stream)', () => {
|
||||
/** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */
|
||||
async function requestSetup() {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-check'))
|
||||
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' })
|
||||
const boundary = session.deriveMessages()
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
||||
return { ctx, session, boundary }
|
||||
}
|
||||
|
||||
/** Dispatch the llm/stream waterfall with a stub core, collecting the check's verdict. */
|
||||
function dispatch(ctx: Context, options: unknown): void {
|
||||
// The invariants listener runs synchronously at dispatch time (its checks
|
||||
// precede next()); the stub core just yields nothing.
|
||||
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
|
||||
}
|
||||
|
||||
it('passes a frozen request that equals the boundary derivation + the folded header', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('is boundary-correct: content logged after step/start is legitimately absent from this request', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
// An agent/request-window inject: lands in the log after the boundary,
|
||||
// belongs to the NEXT request. A current-surface comparison would
|
||||
// false-fire here; the seq-bounded rebuild must not.
|
||||
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||
const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
|
||||
// The prefixed request matches the fold…
|
||||
const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, prefixed) }).not.toThrow()
|
||||
// …a request that DROPPED the logged prefix diverges…
|
||||
const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/)
|
||||
// …and so does one that misplaced it (prefix sent after the history).
|
||||
const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/)
|
||||
})
|
||||
|
||||
it('rejects a frozen request whose messages diverge from the boundary derivation', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
|
||||
const options = Object.freeze({ model: 'm', messages: Object.freeze(messages), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the boundary derivation/)
|
||||
})
|
||||
|
||||
it('rejects a frozen request whose fields diverge from the folded header', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const options = Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the folded request header/)
|
||||
})
|
||||
|
||||
it('rejects a loop-built request with no header event or no step/start in its log', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
||||
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
|
||||
})
|
||||
|
||||
it('rejects a frozen request carrying an unfrozen messages array', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const options = Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).toThrow(/frozen messages array/)
|
||||
})
|
||||
|
||||
it('skips hand-built (unfrozen) requests — compaction summarize is out of scope', async () => {
|
||||
const { ctx, session } = await requestSetup()
|
||||
// Unfrozen envelope + arbitrary messages: a direct one-shot call.
|
||||
const options = { model: 'summarizer', messages: [{ role: 'user', content: [{ type: 'text', text: 'summarize!' }] }], sessionId: session.id }
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('skips requests without a sessionId or with an unknown session', async () => {
|
||||
const { ctx } = await requestSetup()
|
||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
|
||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('request cross-check ordering (prepend)', () => {
|
||||
it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => {
|
||||
// Replay short-circuits without next(), so the check prepends ahead of ordinary listeners;
|
||||
// correctness still comes from its sequence-bounded rebuild, not listener timing.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
|
||||
await ctx.plugin(Invariants)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('prepend-check'))
|
||||
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('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
||||
|
||||
const divergent = Object.freeze({
|
||||
model: 'm',
|
||||
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(() => {
|
||||
void ctx.waterfall('llm/stream', divergent as never, () => (async function* () {})() as never)
|
||||
}).toThrow(/diverges from the boundary derivation/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped-dispatch invariants', () => {
|
||||
async function scopedCtx() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(Invariants)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('rejects a scoped-family dispatch without a carrier (teaching error)', async () => {
|
||||
const ctx = await scopedCtx()
|
||||
const agent = { id: 'a1' } as unknown as Agent
|
||||
expect(() => { ctx.emit('agent/error', agent, 1, 0, new Error('x')) })
|
||||
.toThrow(/dispatched without a scope carrier/)
|
||||
})
|
||||
|
||||
it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => {
|
||||
const ctx = await scopedCtx()
|
||||
// Real Session objects keep the synthetic Agent handles structurally valid.
|
||||
const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent
|
||||
const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent
|
||||
// One dispatch per table row keeps every subject extractor covered: the
|
||||
// matching carrier passes, the foreign-keyed one throws.
|
||||
const rows: [string, unknown[]][] = [
|
||||
['agent/created', [agent]],
|
||||
['agent/disposed', [agent]],
|
||||
['agent/status', [agent, 'idle']],
|
||||
['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]],
|
||||
['agent/session-start', [agent, 'startup']],
|
||||
['agent/pre-step', [agent, 1, 1, new AbortController().signal]],
|
||||
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
|
||||
['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]],
|
||||
['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
|
||||
['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
|
||||
['agent/turn-stop', [agent, 1]],
|
||||
['agent/error', [agent, 1, 0, new Error('x')]],
|
||||
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
|
||||
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
|
||||
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
|
||||
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
|
||||
]
|
||||
for (const [event, args] of rows) {
|
||||
const subject = agent
|
||||
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) },
|
||||
`${event} with matching carrier`).not.toThrow()
|
||||
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) },
|
||||
`${event} with foreign carrier`).toThrow(/DIFFERENT subject/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a carrier keyed to a different subject than the arguments name', async () => {
|
||||
const ctx = await scopedCtx()
|
||||
const agent = { id: 'a1' } as unknown as Agent
|
||||
const other = { id: 'a2' } as unknown as Agent
|
||||
expect(() => { ctx.emit(scopeTarget(agent, other), 'agent/error', agent, 1, 0, new Error('x')) })
|
||||
.toThrow(/keyed to a DIFFERENT subject/)
|
||||
// The correct spelling passes.
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/error', agent, 1, 0, new Error('x')) })
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
})
|
||||
266
packages/support/invariants/tests/service.spec.ts
Normal file
266
packages/support/invariants/tests/service.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import InvariantService, { InvariantError, type Config } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
invariantProbe: InvariantProbeService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
'invariants-test/ping'(): void
|
||||
}
|
||||
}
|
||||
|
||||
class InvariantProbeService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'invariantProbe')
|
||||
}
|
||||
}
|
||||
|
||||
interface RuntimeRegistration extends PromiseLike<() => void> {
|
||||
(): void | Promise<void>
|
||||
}
|
||||
|
||||
interface InstalledRegistration {
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
function runtimeRegistration(registration: () => void): RuntimeRegistration {
|
||||
return registration as RuntimeRegistration
|
||||
}
|
||||
|
||||
async function setup(config: Config = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(InvariantService, config)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
async function registerProbe(
|
||||
ctx: Context,
|
||||
packageName: string,
|
||||
probe: () => void,
|
||||
): Promise<InstalledRegistration> {
|
||||
const registration = runtimeRegistration(ctx.invariants.register(packageName, (child) => {
|
||||
child.on('invariants-test/ping', probe, { global: true })
|
||||
}))
|
||||
await registration
|
||||
return {
|
||||
async dispose() { await registration() },
|
||||
}
|
||||
}
|
||||
|
||||
describe('InvariantService selection', () => {
|
||||
it('applies defaults when constructed directly without schema normalization', async () => {
|
||||
const ctx = new Context()
|
||||
const service = new InvariantService(ctx)
|
||||
const probe = vi.fn()
|
||||
const registration = runtimeRegistration(service.register('@deepseek-ai/dsh-session', (child) => {
|
||||
child.on('invariants-test/ping', probe, { global: true })
|
||||
}))
|
||||
await registration
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await registration()
|
||||
})
|
||||
|
||||
it('enables registrations by default and treats empty lists as admit-all and exclude-none', async () => {
|
||||
for (const config of [{}, { package_allowlist: [], package_blocklist: [] }]) {
|
||||
const { ctx } = await setup(config)
|
||||
const probe = vi.fn()
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-session', probe)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('disables every installer while still reserving package ownership', async () => {
|
||||
const { ctx } = await setup({ enabled: false })
|
||||
const probe = vi.fn()
|
||||
const registration = await registerProbe(ctx, '@deepseek-ai/dsh-session', probe)
|
||||
expect(() => ctx.invariants.register('@deepseek-ai/dsh-session', () => {}))
|
||||
.toThrow(/already registered/)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
await registration.dispose()
|
||||
})
|
||||
|
||||
it('uses unanchored, case-sensitive JavaScript regex sources', async () => {
|
||||
const unanchored = await setup({ package_allowlist: ['session'] })
|
||||
const unanchoredProbe = vi.fn()
|
||||
await registerProbe(unanchored.ctx, '@deepseek-ai/dsh-session-extra', unanchoredProbe)
|
||||
unanchored.ctx.emit('invariants-test/ping')
|
||||
expect(unanchoredProbe).toHaveBeenCalledOnce()
|
||||
|
||||
const anchored = await setup({ package_allowlist: ['^@deepseek-ai/dsh-session$'] })
|
||||
const anchoredProbe = vi.fn()
|
||||
await registerProbe(anchored.ctx, '@deepseek-ai/dsh-session-extra', anchoredProbe)
|
||||
anchored.ctx.emit('invariants-test/ping')
|
||||
expect(anchoredProbe).not.toHaveBeenCalled()
|
||||
|
||||
const caseSensitive = await setup({ package_allowlist: ['Session'] })
|
||||
const caseProbe = vi.fn()
|
||||
await registerProbe(caseSensitive.ctx, '@deepseek-ai/dsh-session', caseProbe)
|
||||
caseSensitive.ctx.emit('invariants-test/ping')
|
||||
expect(caseProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets the blocklist override an allowlist match', async () => {
|
||||
const { ctx } = await setup({
|
||||
package_allowlist: ['^@deepseek-ai/dsh-'],
|
||||
package_blocklist: ['session'],
|
||||
})
|
||||
const sessionProbe = vi.fn()
|
||||
const agentProbe = vi.fn()
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-session', sessionProbe)
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-agent', agentProbe)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(sessionProbe).not.toHaveBeenCalled()
|
||||
expect(agentProbe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts zero-match patterns for packages registered later', async () => {
|
||||
const { ctx } = await setup({ package_allowlist: ['^@later/invariants$'] })
|
||||
const now = vi.fn()
|
||||
const later = vi.fn()
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-session', now)
|
||||
await registerProbe(ctx, '@later/invariants', later)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(now).not.toHaveBeenCalled()
|
||||
expect(later).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows the same source in both lists and applies blocklist precedence', async () => {
|
||||
const { ctx } = await setup({ package_allowlist: ['agent'], package_blocklist: ['agent'] })
|
||||
const probe = vi.fn()
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-agent', probe)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InvariantService validation', () => {
|
||||
it.each([
|
||||
[{ package_allowlist: [''] }, /non-blank/],
|
||||
[{ package_allowlist: [' '] }, /non-blank/],
|
||||
[{ package_allowlist: [' session'] }, /surrounding whitespace/],
|
||||
[{ package_blocklist: ['session '] }, /surrounding whitespace/],
|
||||
[{ package_allowlist: ['session', 'session'] }, /duplicate regex/],
|
||||
[{ package_blocklist: ['agent', 'agent'] }, /duplicate regex/],
|
||||
[{ package_allowlist: ['['] }, /invalid regex/],
|
||||
[{ package_blocklist: ['('] }, /invalid regex/],
|
||||
])('rejects malformed filter config %#', async (config, message) => {
|
||||
await expect((async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, config)
|
||||
})()).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it.each(['', ' ', ' package', 'pack age', 'package\n'])('rejects malformed package name %j', async (packageName) => {
|
||||
const { ctx } = await setup()
|
||||
expect(() => ctx.invariants.register(packageName, () => {})).toThrow(/packageName/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('InvariantService lifecycle', () => {
|
||||
it('honors the installer dependency surface in its child fiber', async () => {
|
||||
const { ctx } = await setup()
|
||||
await ctx.plugin(InvariantProbeService)
|
||||
let registration!: RuntimeRegistration
|
||||
await ctx.plugin({
|
||||
inject: ['invariants', 'invariantProbe'],
|
||||
apply(child: Context) {
|
||||
const installer = Object.assign((installerCtx: Context) => {
|
||||
expect(Object.keys(installerCtx.fiber.inject)).toContain('invariantProbe')
|
||||
expect(Object.keys(installerCtx.fiber.store ?? {})).toContain('invariantProbe')
|
||||
expect(installerCtx.invariantProbe).toBeInstanceOf(InvariantProbeService)
|
||||
}, { inject: ['invariantProbe'] })
|
||||
expect(installer.inject).toEqual(['invariantProbe'])
|
||||
registration = runtimeRegistration(child.invariants.register('@deepseek-ai/dsh-probe', installer))
|
||||
return Promise.resolve(registration)
|
||||
},
|
||||
})
|
||||
await registration
|
||||
})
|
||||
|
||||
it('attributes failures to the registering package with the stable code', async () => {
|
||||
const { ctx } = await setup()
|
||||
const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child, fail) => {
|
||||
child.on('invariants-test/ping', () => fail('seq must strictly increase'), { global: true })
|
||||
}))
|
||||
await registration
|
||||
let caught: unknown
|
||||
try {
|
||||
ctx.emit('invariants-test/ping')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvariantError)
|
||||
expect(caught).toMatchObject({
|
||||
name: 'InvariantError',
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session',
|
||||
message: 'invariant violated by "@deepseek-ai/dsh-session": seq must strictly increase',
|
||||
})
|
||||
})
|
||||
|
||||
it('disposes the child fiber completely and permits HMR re-registration', async () => {
|
||||
const { ctx } = await setup()
|
||||
const first = vi.fn()
|
||||
const firstRegistration = await registerProbe(ctx, '@deepseek-ai/dsh-session', first)
|
||||
ctx.emit('invariants-test/ping')
|
||||
await firstRegistration.dispose()
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
|
||||
const second = vi.fn()
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-session', second)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
expect(second).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reserves ownership until asynchronous child disposal completes', async () => {
|
||||
const { ctx } = await setup()
|
||||
let finishDisposal!: () => void
|
||||
const disposalBarrier = new Promise<void>((resolve) => { finishDisposal = resolve })
|
||||
const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child) => {
|
||||
child.effect(() => async () => { await disposalBarrier })
|
||||
}))
|
||||
await registration
|
||||
|
||||
const disposing = registration()
|
||||
expect(() => ctx.invariants.register('@deepseek-ai/dsh-session', () => {}))
|
||||
.toThrow(/already registered/)
|
||||
finishDisposal()
|
||||
await disposing
|
||||
|
||||
const replacement = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', () => {}))
|
||||
await replacement
|
||||
await replacement()
|
||||
})
|
||||
|
||||
it('rolls back listeners and ownership atomically when an installer fails', async () => {
|
||||
const { ctx } = await setup()
|
||||
const leaked = vi.fn()
|
||||
const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child) => {
|
||||
child.on('invariants-test/ping', leaked, { global: true })
|
||||
throw new Error('installer failed')
|
||||
}))
|
||||
await expect(Promise.resolve(failed)).rejects.toThrow('installer failed')
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(leaked).not.toHaveBeenCalled()
|
||||
|
||||
const retry = vi.fn()
|
||||
await registerProbe(ctx, '@deepseek-ai/dsh-session', retry)
|
||||
ctx.emit('invariants-test/ping')
|
||||
expect(retry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases a synchronous reservation if the service fiber is already inactive', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const service = ctx.invariants
|
||||
await fiber.dispose()
|
||||
expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i)
|
||||
})
|
||||
})
|
||||
@@ -15,28 +15,7 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
"path": "../../../vendor/schemastery"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user