fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -5,7 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than
| Package | Role | ctx key |
|---|---|---|
| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
| `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |

View File

@@ -1,9 +1,11 @@
# dsh-invariants
Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently.
Dev-mode event-contract assertions. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests at runtime; it does not own or change product behavior.
**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
## Plugin
A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
@@ -14,17 +16,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
declare const ctx: Context
await ctx.plugin(Invariants) // freeze on (default)
await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze
await ctx.plugin(Invariants)
```
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist (so a hot reload mid-turn doesn't falsely reject the next event). It listens on `session/created`, `session/event`, and `agent/status`.
### Config
| Key | Default | Meaning |
|---|---|---|
| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. |
`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. It registers only listeners and has no configuration.
## Invariants asserted
@@ -46,10 +41,10 @@ Model requests (on `llm/stream`):
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
## Why runtime, not deep-readonly types
## Why runtime assertions remain useful
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
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 in development 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/forked session arrives with events already in its log (the `Session` constructor copies the seed without emitting `session/event`). On `session/created` the plugin replays the existing log through the checker and freezes those entries, so seeded history is held to the same contract.
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.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-invariants",
"description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness",
"description": "Dev-mode event-contract assertions for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,20 +1,18 @@
/**
* Dev-mode invariants: a pure-listener plugin that asserts the harness event
* contract at runtime, and (optionally) freezes logged session-event data so
* any code that mutates history throws instead of corrupting silently.
* Dev-mode invariants: a pure-listener plugin that asserts relationships in
* the harness event contract at runtime.
*
* Everything is a plugin — this is just listeners on `session/created`,
* `session/event`, and `agent/status`. It is **off in production**: enable it
* in tests and the demos, where a contract violation should be a loud failure,
* not a subtle one. It doubles as executable documentation of the event
* taxonomy: the assertions below ARE the contract.
* `session/event`, `agent/status`, and the scoped dispatch and request seams.
* It is **off in production**: enable it in tests and demos, where a contract
* violation should be a loud failure rather than a subtle one. It doubles as
* executable documentation of the event taxonomy: the assertions below are
* the contract.
*
* Why runtime assertions instead of compile-time deep-readonly types? See
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
* every log consumer and a plugin casts straight through it; a dev-mode freeze
* + assertions catch real corruption at zero production cost and zero type
* noise. The always-on half of that defense (cloning derived messages) lives
* in dsh-session; this package is the dev-mode tripwire.
* Session owns immutable log storage: it snapshots and deep-freezes every
* accepted event at the source. This plugin checks relationships that one
* event's types and immutability cannot express, including turn/step nesting,
* scoped dispatch, status transitions, and request reconstructability.
*
* @module @deepseek-ai/dsh-invariants
*/
@@ -44,16 +42,6 @@ export class InvariantError extends HarnessError {
}
}
/** Plugin config. */
export interface Config {
/**
* Deep-freeze logged session-event data so mutating a logged event throws.
* Default true — this plugin only runs in dev/test, where freezing is the
* point. Set false to assert the event contract without freezing.
*/
freeze?: boolean
}
/** Per-session bookkeeping for the session-log invariants. */
interface SessionTrace {
/** Highest `seq` seen so far (must strictly increase). */
@@ -87,29 +75,6 @@ interface AgentSubject {
agent: Agent
}
/**
* Deep-freeze a value and everything reachable from it.
*
* Walks every object's own properties even when the object itself is already
* frozen: `Session.append()` accepts event data from arbitrary plugins/tools,
* so a caller can hand us a SHALLOW-frozen object whose descendants are still
* mutable. Skipping an already-frozen node (the obvious idempotence shortcut)
* would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A
* `WeakSet` of visited objects keeps it terminating on cycles and avoids
* re-walking shared subtrees / already-processed seed events.
*/
function deepFreeze(value: unknown, seen: WeakSet<object> = new WeakSet()): void {
if (value === null || typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
// Freeze the node (no-op if a caller pre-froze it), then ALWAYS descend —
// a frozen container can still hold mutable children.
Object.freeze(value)
for (const key of Object.keys(value)) {
deepFreeze((value as Record<string, unknown>)[key], seen)
}
}
/** Assert that a step-scoped event names the currently open turn and step. */
function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void {
if (trace.openTurn !== turn || trace.openStep !== step) {
@@ -311,13 +276,13 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
/**
* Register the dev-mode invariants. Contributions are effect-scoped, so
* disposing the plugin fiber removes all listeners and stops freezing
* (HMR-safe). On (re-)apply the trace state is rebuilt by replaying each
* existing session's log, so a hot reload mid-turn does not falsely reject the
* next event.
* 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, config: Config = {}): void {
const freeze = config.freeze ?? true
export function apply(ctx: Context): void {
const traces = new WeakMap<Session, SessionTrace>()
// Agent status has no stored history to replay; the first observation after
// (re-)apply seeds the baseline, so a reload never produces a false positive.
@@ -334,13 +299,12 @@ export function apply(ctx: Context, config: Config = {}): void {
surface: [],
})
/** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */
/** 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) {
checkEvent(trace, event)
if (freeze) deepFreeze(event)
}
return trace
}
@@ -362,7 +326,6 @@ export function apply(ctx: Context, config: Config = {}): void {
ctx.on('session/event', (session, event) => {
checkEvent(traceFor(session), event)
if (freeze) deepFreeze(event)
})
ctx.on('agent/status', (agent, status) => {

View File

@@ -8,10 +8,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
import { InvariantError } from '@deepseek-ai/dsh-invariants'
/** A Context with the session store and the invariants plugin registered. */
async function setup(config?: { freeze?: boolean }) {
async function setup() {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(Invariants, config ?? {})
const fiber = await ctx.plugin(Invariants)
return { ctx, fiber }
}
@@ -22,7 +22,7 @@ function mockAgent(id: string): Agent {
describe('session-log invariants', () => {
it('accepts a well-formed turn/step/tool sequence', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -38,7 +38,7 @@ describe('session-log invariants', () => {
})
it('rejects a non-monotonic seq (replay spine)', async () => {
const { ctx } = await setup({ freeze: false })
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.
@@ -48,7 +48,7 @@ describe('session-log invariants', () => {
})
it('rejects a turn/start while another turn is open', async () => {
const { ctx } = await setup({ freeze: false })
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' } } }))
@@ -56,7 +56,7 @@ describe('session-log invariants', () => {
})
it('rejects a turn/end that does not match the open turn', async () => {
const { ctx } = await setup({ freeze: false })
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' } }))
@@ -64,14 +64,14 @@ describe('session-log invariants', () => {
})
it('rejects a step/start outside its declared turn', async () => {
const { ctx } = await setup({ freeze: false })
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({ freeze: false })
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 })
@@ -79,7 +79,7 @@ describe('session-log invariants', () => {
})
it('rejects an assistant/chunk outside an open step', async () => {
const { ctx } = await setup({ freeze: false })
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' } }))
@@ -87,7 +87,7 @@ describe('session-log invariants', () => {
})
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
const { ctx } = await setup({ freeze: false })
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' }))
@@ -97,7 +97,7 @@ describe('session-log invariants', () => {
})
it('rejects steering and plugin-added events appended outside any open turn', async () => {
const { ctx } = await setup({ freeze: false })
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).
@@ -113,7 +113,7 @@ describe('session-log invariants', () => {
})
it('accepts message events once a turn is open', async () => {
const { ctx } = await setup({ freeze: false })
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' }))
@@ -121,7 +121,7 @@ describe('session-log invariants', () => {
})
it('rejects a tool/result with no prior tool/call', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -130,7 +130,7 @@ describe('session-log invariants', () => {
})
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -152,7 +152,7 @@ describe('session-log invariants', () => {
})
it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -164,7 +164,7 @@ describe('session-log invariants', () => {
})
it('holds seeded sessions to the contract on session/created', async () => {
const { ctx } = await setup({ freeze: false })
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
@@ -177,7 +177,7 @@ describe('session-log invariants', () => {
})
it('tracks turns per session independently', async () => {
const { ctx } = await setup({ freeze: false })
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' } } })
@@ -186,7 +186,7 @@ describe('session-log invariants', () => {
})
it('accepts multiple steps in a turn and consecutive turns', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -203,7 +203,7 @@ describe('session-log invariants', () => {
})
it('rejects a skipped turn number', async () => {
const { ctx } = await setup({ freeze: false })
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' } })
@@ -212,7 +212,7 @@ describe('session-log invariants', () => {
})
it('rejects a skipped step number within a turn', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -222,7 +222,7 @@ describe('session-log invariants', () => {
})
it('rejects a turn/end while a step is still open', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -231,7 +231,7 @@ describe('session-log invariants', () => {
})
it('rejects a step/start while a step is still open', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -239,7 +239,7 @@ describe('session-log invariants', () => {
})
it('rejects a tool/result satisfying a call from a previous step', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -252,7 +252,7 @@ describe('session-log invariants', () => {
})
it('rejects an assistant/message naming the wrong step', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -266,7 +266,7 @@ describe('HMR state rebuild', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
// First registration, mid-turn: a turn is open when the plugin reloads.
const first = await ctx.plugin(Invariants, { freeze: false })
const 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 })
@@ -274,7 +274,7 @@ describe('HMR state rebuild', () => {
// Re-apply (HMR): the fresh fiber must replay the existing log so the open
// step is known — the next chunk must NOT be a false positive.
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
.not.toThrow()
// And a genuine violation is still caught after the rebuild.
@@ -283,76 +283,49 @@ describe('HMR state rebuild', () => {
})
})
describe('dev-freeze', () => {
it('freezes appended event data so mutating a logged event throws', async () => {
const { ctx } = await setup() // freeze defaults true
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
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('does not freeze when freeze:false', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(Object.isFrozen(event)).toBe(false)
})
it('freezes seeded events on session/created', async () => {
const { ctx } = await setup()
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 = ctx.sessions.create(undefined, { seed })
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('freezes mutable descendants of a shallow-frozen event datum', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// A caller hands in a SHALLOW-frozen block whose nested array is still
// mutable. deepFreeze must descend into the already-frozen object and
// freeze the descendant, not short-circuit on the frozen container —
// otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches.
// `append` snapshots `data`, so the freeze applies to the LOGGED clone, not
// the caller's input — read the event back and assert on its data.
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()
})
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
// The deep-freeze WeakSet guard must terminate on a self-referential
// structure rather than recursing forever. Session.append now rejects
// non-serializable (incl. cyclic) data at the source, so drive the freeze
// handler directly via hand-built session/events — exactly the shape the
// invariants listener receives. Open a turn first (seq 0) so the cyclic
// user/message (seq 1) satisfies the turn-enclosure invariant.
ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }).not.toThrow()
expect(Object.isFrozen(cyclic)).toBe(true)
})
})
describe('agent status invariants', () => {
it('accepts legal transitions: idle→running→idle and →disposed', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const agent = mockAgent('a1')
expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
@@ -363,28 +336,28 @@ describe('agent status invariants', () => {
})
it('accepts running→disposed', async () => {
const { ctx } = await setup({ freeze: false })
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({ freeze: false })
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({ freeze: false })
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({ freeze: false })
const { ctx } = await setup()
const a = mockAgent('a5')
const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
@@ -401,10 +374,10 @@ describe('HMR safety', () => {
await fiber.dispose()
// After disposal: no freezing, no assertions. An event that WOULD have
// violated the open-turn rule now passes silently, and is not frozen.
// 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(false)
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')
@@ -419,16 +392,17 @@ describe('HMR safety', () => {
expect(err.message).toBe('invariant violated: seq must strictly increase')
})
it('does not leak listeners across dispose (no stale freezing)', async () => {
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' })
// our own spy fires, proving events still flow — but the plugin's frozen.
// 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(false)
expect(Object.isFrozen(session.events[0])).toBe(true)
})
})
@@ -640,7 +614,7 @@ describe('surface invariants', () => {
})
it('catches an incomplete-provenance replace on the load/seed path', async () => {
const { ctx } = await setup({ freeze: false })
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 } },
@@ -655,10 +629,10 @@ describe('surface invariants', () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// Type system prevents surface metadata on non-surface events; this test
// exercises the runtime guard against casts or persisted-data bypass.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] }))
// Session rejects this at its own acceptance boundary. Emit a hand-built
// record to cover the listener's defensive check for alternate producers.
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
.toThrow(/cannot carry sourceEventSeqs/)
})
@@ -666,8 +640,8 @@ describe('surface invariants', () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' }))
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
.toThrow(/cannot carry surfaceOp/)
})
})
@@ -675,7 +649,7 @@ describe('surface invariants', () => {
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({ freeze: false })
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' })
@@ -737,7 +711,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
})
it('rejects a loop-built request with no header event or no step/start in its log', async () => {
const { ctx } = await setup({ freeze: false })
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 })
@@ -778,7 +752,7 @@ describe('request cross-check ordering (prepend)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })

View File

@@ -57,7 +57,8 @@ describe('dsh-subagent-mock', () => {
// the structured path is only reachable when the cap is on; with it off and
// no schema requested, the result has no structured field.
const run = ctx.subagents.start('mock', baseRequest())
await expect(run.result).resolves.toMatchObject({ structured: undefined })
const result = await run.result
expect(result).not.toHaveProperty('structured')
})
it('honors a configured stop reason', async () => {