From 11a29fdefe5a3fbc71e22758fee110d2aae5cf83 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:25:12 +0800 Subject: [PATCH] feat(invariants): dev-mode event-contract assertions + session-log freeze (RFC 005 pt 3, RFC 008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New @deepseek-ai/dsh-invariants plugin (pure listeners, off in prod) asserts the event taxonomy at runtime — seq monotonicity, turn/step nesting, a tool/result needs a prior tool/call (NOT the converse), legal agent/status transitions — and deep-freezes logged event data so mutating history throws. Seeded sessions are checked + frozen on session/created. The real RFC 008 fix is always-on: deriveMessages now structured-clones the content it emits, so the loop's sanctioned request/adapter mutation can no longer reach back and rewrite the append-only log. The pervasive DeepReadonly type flip is rejected (compile-only, high-noise, castable) — recorded in ADR 0012, which folds in RFC 008. Wired into both demos. --- .../0012-dev-invariants-over-deep-readonly.md | 27 ++ docs/adr/README.md | 1 + ...5-runtime-validation-and-error-taxonomy.md | 2 +- docs/rfc/008-immutable-public-surfaces.md | 2 +- docs/rfc/README.md | 2 +- examples/coding-agent/cordis.yml | 4 + examples/echo-agent/cordis.yml | 5 + packages/invariants/README.md | 46 ++++ packages/invariants/package.json | 33 +++ packages/invariants/src/index.ts | 193 ++++++++++++++ packages/invariants/tests/invariants.spec.ts | 239 ++++++++++++++++++ packages/invariants/tsconfig.json | 15 ++ packages/session/src/index.ts | 18 +- packages/session/tests/session.spec.ts | 25 ++ scripts/publint-all.ts | 1 + tsconfig.base.json | 3 +- tsconfig.build.json | 3 +- tsconfig.typecheck.json | 3 +- yarn.lock | 15 ++ 19 files changed, 626 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0012-dev-invariants-over-deep-readonly.md create mode 100644 packages/invariants/README.md create mode 100644 packages/invariants/package.json create mode 100644 packages/invariants/src/index.ts create mode 100644 packages/invariants/tests/invariants.spec.ts create mode 100644 packages/invariants/tsconfig.json diff --git a/docs/adr/0012-dev-invariants-over-deep-readonly.md b/docs/adr/0012-dev-invariants-over-deep-readonly.md new file mode 100644 index 0000000000..01d711e38c --- /dev/null +++ b/docs/adr/0012-dev-invariants-over-deep-readonly.md @@ -0,0 +1,27 @@ +# ADR 0012: Dev-mode invariants over compile-time deep-readonly + +Status: accepted (2026-06-13) + +## Context + +The session log is append-only by contract, but the types don't enforce it: `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable, and `deriveMessages()` handed the logged `content` arrays/blocks out by reference. The loop then passes those derived messages into the `agent/request` waterfall and on to adapters, where mutating the request is sanctioned — so a request middleware could reach back and rewrite history, silently breaking replay equivalence and the derived-history guarantee. Separately, the event taxonomy (turn/step nesting, seq monotonicity, tool-call/result pairing, legal status transitions) was asserted only where individual tests happened to look. + +Two ways to defend the log: make immutability part of the type (`DeepReadonly` on the way out), or catch corruption at runtime in dev. The RFC (005) proposed the runtime route; RFC 008 proposed the type route. + +## Decision + +Reject the pervasive `DeepReadonly` type flip. Instead: + +1. **Always-on:** `deriveMessages()` deep-clones the content it emits (one `structuredClone` per derived message). In-flight mutation of a request can no longer reach the log — this is the real fix, and it costs nothing meaningful next to a model call. +2. **Dev-mode:** a new `dsh-invariants` plugin (pure listeners, off in production, on in tests and demos) asserts the event contract and `Object.freeze`s logged event data so any *other* code that mutates a logged event throws instead of corrupting silently. Seeded sessions are frozen and checked on `session/created` (the constructor copies the seed without emitting `session/event`). + +The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown `tools/execute` waterfall ends the step), and both `idle→disposed` and `running→disposed` are legal. + +`DeepReadonly` was rejected because it is compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise. + +## Consequences + +- History corruption is caught loudly in tests and demos, at zero production cost and zero type noise. The trade-off is that the guarantee is dynamic (a dev-mode tripwire) rather than static. +- The invariants plugin doubles as executable documentation of the event taxonomy — the assertions are the contract. +- `Session.events` keeps its `readonly SessionEvent[]` type; no consumer churn. +- This folds in RFC 008 — there is no separate deep-readonly ADR; this records the decision to *not* pursue that approach. `InvariantError` is a plain `Error` with a `code` for now; a later taxonomy change can promote it. diff --git a/docs/adr/README.md b/docs/adr/README.md index a4c7a6e84f..ee3ebde6c6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,3 +23,4 @@ Do NOT write an ADR for: a mechanical or local choice (a variable name, a one-fi | [0009](0009-capability-seams.md) | Capability seams — interface / implementation / consumer split | accepted | | [0010](0010-twin-llm-adapters.md) | Two LLM adapters as a design-verification twin | accepted | | [0011](0011-runtime-arg-validation.md) | Runtime arg validation at the model boundary | accepted | +| [0012](0012-dev-invariants-over-deep-readonly.md) | Dev-mode invariants over compile-time deep-readonly | accepted | diff --git a/docs/rfc/005-runtime-validation-and-error-taxonomy.md b/docs/rfc/005-runtime-validation-and-error-taxonomy.md index a84bb4c264..a754c638ff 100644 --- a/docs/rfc/005-runtime-validation-and-error-taxonomy.md +++ b/docs/rfc/005-runtime-validation-and-error-taxonomy.md @@ -1,6 +1,6 @@ # RFC 005: Runtime validation at the model boundary, error taxonomy, dev-mode invariants -Status: partially implemented — part 1 (arg validation) → [ADR 0011](../adr/0011-runtime-arg-validation.md); parts 2-3 in progress +Status: partially implemented — part 1 (arg validation) → [ADR 0011](../adr/0011-runtime-arg-validation.md); part 3 (dev invariants) → [ADR 0012](../adr/0012-dev-invariants-over-deep-readonly.md); part 2 (error taxonomy) in progress ## Problem diff --git a/docs/rfc/008-immutable-public-surfaces.md b/docs/rfc/008-immutable-public-surfaces.md index e4faf9a0a4..fae635a78e 100644 --- a/docs/rfc/008-immutable-public-surfaces.md +++ b/docs/rfc/008-immutable-public-surfaces.md @@ -1,6 +1,6 @@ # RFC 008: Deep-readonly public surfaces -Status: proposed +Status: implemented (revised) — the pervasive `DeepReadonly` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. See [ADR 0012](../adr/0012-dev-invariants-over-deep-readonly.md). ## Problem diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 46613a73b4..4760bb98f6 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -11,4 +11,4 @@ Proposals for substantial future work — reviewed before implementation, unlike | [005](005-runtime-validation-and-error-taxonomy.md) | Runtime arg validation, structured error taxonomy, dev-mode invariants | partially implemented | | [006](006-doc-sync-and-api-reports.md) | Doc-sync enforcement and API extractor reports | proposed | | [007](007-supply-chain-and-vendor-drift.md) | Supply chain checks and vendor drift verification | proposed | -| [008](008-immutable-public-surfaces.md) | Deep-readonly public surfaces | proposed | +| [008](008-immutable-public-surfaces.md) | Deep-readonly public surfaces | implemented (revised) | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 96a6fef4b1..1e1104254c 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -30,6 +30,10 @@ - id: agents name: '@deepseek-ai/dsh-agent' +# Dev-mode event-contract assertions + session-log freeze (off in prod). +- id: invariants + name: '@deepseek-ai/dsh-invariants' + # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the # pi-ai-backed twin (same config shape; `reasoning: high` replaces # thinking/reasoningEffort). diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 62e42f9ec6..276dd700ef 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -27,6 +27,11 @@ - id: agents name: '@deepseek-ai/dsh-agent' +# Dev-mode event-contract assertions + session-log freeze (off in prod; +# on here so the demo smoke test exercises the contract). +- id: invariants + name: '@deepseek-ai/dsh-invariants' + - id: agent-loop name: '@deepseek-ai/dsh-agent-loop' config: diff --git a/packages/invariants/README.md b/packages/invariants/README.md new file mode 100644 index 0000000000..ee12612006 --- /dev/null +++ b/packages/invariants/README.md @@ -0,0 +1,46 @@ +# dsh-invariants + +Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently. + +**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract. + +## Plugin + +```ts +import Invariants from '@deepseek-ai/dsh-invariants' + +await ctx.plugin(Invariants) // freeze on (default) +await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze +``` + +`inject`: none required — it listens on `session/created`, `session/event`, and `agent/status`, all emitted by services it does not depend on directly. + +### Config + +| Key | Default | Meaning | +|---|---|---| +| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. | + +## Invariants asserted + +Session log (per session): + +- **`seq` strictly increases** — the spine of replay equivalence. +- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. +- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. +- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. +- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown `tools/execute` waterfall ends the step with no `tool/result`, which is legal). + +Agent status (per agent): + +- **legal transitions only** — `idle↔running` and `(idle|running)→disposed`. A no-op transition (`setStatus` dedups, so it never fires) and leaving the terminal `disposed` state are violations. + +On any violation it throws `InvariantError` (`code: 'INVARIANT'`). + +## Why runtime, not deep-readonly types + +A `DeepReadonly` 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 [ADR 0012](../../docs/adr/0012-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. diff --git a/packages/invariants/package.json b/packages/invariants/package.json new file mode 100644 index 0000000000..5e8bc76d91 --- /dev/null +++ b/packages/invariants/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-invariants", + "description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/invariants/src/index.ts b/packages/invariants/src/index.ts new file mode 100644 index 0000000000..4f04f70209 --- /dev/null +++ b/packages/invariants/src/index.ts @@ -0,0 +1,193 @@ +/** + * Dev-mode invariants: a pure-listener plugin that asserts the harness event + * contract at runtime, and (optionally) freezes logged session-event data so + * any code that mutates history throws instead of corrupting silently. + * + * Everything is a plugin — this is just listeners on `session/created`, + * `session/event`, and `agent/status`. It is **off in production**: enable it + * in tests and the demos, where a contract violation should be a loud failure, + * not a subtle one. It doubles as executable documentation of the event + * taxonomy: the assertions below ARE the contract. + * + * Why runtime assertions instead of compile-time deep-readonly types? See + * ADR 0012. Briefly: a `DeepReadonly` is high type-noise across + * every log consumer and a plugin casts straight through it; a dev-mode freeze + * + assertions catch real corruption at zero production cost and zero type + * noise. The always-on half of that defense (cloning derived messages) lives + * in dsh-session; this package is the dev-mode tripwire. + * + * @module @deepseek-ai/dsh-invariants + */ + +import type { Context } from 'cordis' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +export const name = 'invariants' + +/** + * Thrown when a harness event-contract invariant is violated. Plain `Error` + * with a `code` for now; a later change promotes the harness error taxonomy. + */ +export class InvariantError extends Error { + readonly code = 'INVARIANT' + constructor(message: string) { + super(`invariant violated: ${message}`) + this.name = 'InvariantError' + } +} + +/** Plugin config. */ +export interface Config { + /** + * Deep-freeze logged session-event data so mutating a logged event throws. + * Default true — this plugin only runs in dev/test, where freezing is the + * point. Set false to assert the event contract without freezing. + */ + freeze?: boolean +} + +/** Per-session bookkeeping for the session-log invariants. */ +interface SessionTrace { + /** Highest `seq` seen so far (must strictly increase). */ + lastSeq: number + /** Open turn number, or null between turns. */ + openTurn: number | null + /** Open step within the current turn, or null between steps. */ + openStep: number | null + /** Outstanding tool-call ids awaiting a result (a result needs a prior call). */ + pendingCalls: Set +} + +/** Deep-freeze a value and everything reachable from it. Idempotent. */ +function deepFreeze(value: unknown): void { + if (value === null || typeof value !== 'object') return + if (Object.isFrozen(value)) return + Object.freeze(value) + for (const key of Object.keys(value)) { + deepFreeze((value as Record)[key]) + } +} + +/** Assert one appended event against the per-session invariants. */ +function checkEvent(trace: SessionTrace, event: SessionEvent): void { + // seq is strictly monotonic — the spine of replay equivalence. lastSeq + // starts at -1, so the first event (seq 0) passes. + if (event.seq <= trace.lastSeq) { + throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`) + } + trace.lastSeq = event.seq + + // Intentionally non-exhaustive: only events that carry ordering structure + // are checked; the rest are trace/replay data with no nesting contract. + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check + 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`) + } + trace.openTurn = event.data.turn + 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}`) + } + trace.openTurn = null + trace.openStep = null + 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}`) + } + trace.openStep = event.data.step + break + } + case 'step/end': { + if (trace.openStep !== event.data.step) { + throw new InvariantError(`step/end ${event.data.step} does not match open step ${trace.openStep}`) + } + trace.openStep = null + break + } + case 'assistant/chunk': { + // A chunk belongs to an open step — step/start must precede it. + if (trace.openStep === null) { + throw new InvariantError('assistant/chunk outside an open step (step/start must precede its chunks)') + } + break + } + case 'tool/call': { + trace.pendingCalls.add(event.data.callId) + break + } + case 'tool/result': { + // A result needs a prior matching call. (The converse does NOT hold: a + // call may have no result — a thrown tools/execute waterfall ends the + // step with no tool/result, which is legal.) + if (!trace.pendingCalls.delete(event.data.callId)) { + throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call`) + } + break + } + } +} + +/** Legal agent status transitions (the only state machine the loop guarantees). */ +function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { + // First observation: any status is a valid starting point. + if (from === undefined) return + // A no-op transition is illegal — setStatus dedups, so we never see it. + if (from === to) { + throw new InvariantError(`agent/status repeated ${to} (no-op transition)`) + } + // Leaving `disposed` is illegal — disposal is terminal. + if (from === 'disposed') { + throw new InvariantError(`agent/status left terminal state disposed → ${to}`) + } + // idle↔running and (idle|running)→disposed are all legal; nothing else exists. +} + +/** + * Register the dev-mode invariants. Returns nothing — contributions are + * effect-scoped, so disposing the plugin fiber removes all listeners and + * stops freezing (HMR-safe). + */ +export function apply(ctx: Context, config: Config = {}): void { + const freeze = config.freeze ?? true + const traces = new WeakMap() + const lastStatus = new WeakMap() + + const traceFor = (session: Session): SessionTrace => { + let trace = traces.get(session) + if (!trace) { + trace = { lastSeq: -1, openTurn: null, openStep: null, pendingCalls: new Set() } + traces.set(session, trace) + } + return trace + } + + ctx.on('session/created', (session) => { + // A seeded/forked session arrives with events already in its log — the + // constructor copies the seed WITHOUT emitting session/event, so replay + // them through the checker here and freeze the existing entries. + const trace = traceFor(session) + for (const event of session.events) { + checkEvent(trace, event) + if (freeze) deepFreeze(event) + } + }) + + ctx.on('session/event', (session, event) => { + checkEvent(traceFor(session), event) + if (freeze) deepFreeze(event) + }) + + ctx.on('agent/status', (agent, status) => { + checkTransition(lastStatus.get(agent), status) + lastStatus.set(agent, status) + }) +} + +export default apply diff --git a/packages/invariants/tests/invariants.spec.ts b/packages/invariants/tests/invariants.spec.ts new file mode 100644 index 0000000000..3b7f88f6d4 --- /dev/null +++ b/packages/invariants/tests/invariants.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import Invariants, { InvariantError } from '@deepseek-ai/dsh-invariants' + +/** A Context with the session store and the invariants plugin registered. */ +async function setup(config?: { freeze?: boolean }) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(Invariants, config ?? {}) + return { ctx, fiber } +} + +/** A minimal Agent stand-in for agent/status emission. */ +function mockAgent(id: string): Agent { + return { id } as unknown as Agent +} + +describe('session-log invariants', () => { + it('accepts a well-formed turn/step/tool sequence', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('rejects a turn/start while another turn is open', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + }) + + it('rejects a turn/end that does not match the open turn', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) + .toThrow(/does not match open turn 1/) + }) + + it('rejects a step/start outside its declared turn', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) + }) + + it('rejects a step/end that does not match the open step', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + expect(() => session.append('step/end', { turn: 1, step: 2 })).toThrow(/does not match open step 1/) + }) + + it('rejects an assistant/chunk outside an open step', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } })) + .toThrow(/outside an open step/) + }) + + it('rejects a tool/result with no prior tool/call', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false })) + .toThrow(/no prior tool\/call/) + }) + + it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', message: 'boom' } }) + }).not.toThrow() + }) + + it('holds seeded sessions to the contract on session/created', async () => { + const { ctx } = await setup({ freeze: false }) + // A seed whose seq is non-monotonic must be rejected when the session is + // created (the constructor copies the seed without emitting session/event). + 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: 0, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + ] + expect(() => ctx.sessions.create(undefined, badSeed)).toThrow(InvariantError) + }) + + it('tracks turns per session independently', async () => { + const { ctx } = await setup({ freeze: false }) + const a = ctx.sessions.create('a') + const b = ctx.sessions.create('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() + }) +}) + +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() + const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + expect(Object.isFrozen(event)).toBe(true) + expect(Object.isFrozen(event.data)).toBe(true) + expect(Object.isFrozen(event.data.content)).toBe(true) + expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow() + }) + + it('does not freeze when freeze:false', async () => { + const { ctx } = await setup({ freeze: false }) + const session = ctx.sessions.create() + const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + expect(Object.isFrozen(event)).toBe(false) + }) + + it('freezes seeded events on session/created', async () => { + const { ctx } = await setup() + const seed = [ + { type: 'user/message' as const, seq: 0, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } }, + ] + const session = ctx.sessions.create(undefined, seed) + expect(Object.isFrozen(session.events[0])).toBe(true) + }) + + it('is idempotent over already-frozen sub-structures', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + // Pre-freeze a block before appending; deepFreeze must short-circuit on it + // (the already-frozen guard) while still freezing the enclosing event. + const block = Object.freeze({ type: 'text' as const, text: 'pre-frozen' }) + const event = session.append('user/message', { content: [block], source: { kind: 'user' } }) + expect(Object.isFrozen(event)).toBe(true) + expect(Object.isFrozen(event.data.content)).toBe(true) + expect(Object.isFrozen(event.data.content[0])).toBe(true) + }) +}) + +describe('agent status invariants', () => { + it('accepts legal transitions: idle→running→idle and →disposed', async () => { + const { ctx } = await setup({ freeze: false }) + const agent = mockAgent('a1') + expect(() => { + ctx.emit('agent/status', agent, 'idle') + ctx.emit('agent/status', agent, 'running') + ctx.emit('agent/status', agent, 'idle') + ctx.emit('agent/status', agent, 'disposed') + }).not.toThrow() + }) + + it('accepts running→disposed', async () => { + const { ctx } = await setup({ freeze: false }) + const agent = mockAgent('a2') + ctx.emit('agent/status', agent, 'running') + expect(() => { ctx.emit('agent/status', agent, 'disposed') }).not.toThrow() + }) + + it('rejects a no-op transition', async () => { + const { ctx } = await setup({ freeze: false }) + const agent = mockAgent('a3') + ctx.emit('agent/status', agent, 'running') + expect(() => { ctx.emit('agent/status', agent, 'running') }).toThrow(/no-op transition/) + }) + + it('rejects leaving the terminal disposed state', async () => { + const { ctx } = await setup({ freeze: false }) + const agent = mockAgent('a4') + ctx.emit('agent/status', agent, 'disposed') + expect(() => { ctx.emit('agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) + }) + + it('tracks status per agent independently', async () => { + const { ctx } = await setup({ freeze: false }) + const a = mockAgent('a5') + const b = mockAgent('b5') + ctx.emit('agent/status', a, 'running') + // b's first observation is independent of a. + expect(() => { ctx.emit('agent/status', b, 'running') }).not.toThrow() + }) +}) + +describe('HMR safety', () => { + it('removes all listeners when the plugin fiber is disposed', async () => { + const { ctx, fiber } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + + await fiber.dispose() + + // After disposal: no freezing, no assertions. An event that WOULD have + // violated the open-turn rule now passes silently, and is not frozen. + const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(Object.isFrozen(event)).toBe(false) + // A no-op status transition no longer throws either. + const agent = mockAgent('hmr') + ctx.emit('agent/status', agent, 'idle') + expect(() => { ctx.emit('agent/status', agent, 'idle') }).not.toThrow() + }) + + it('InvariantError carries a stable code', () => { + const err = new InvariantError('seq must strictly increase') + expect(err).toBeInstanceOf(Error) + expect(err.name).toBe('InvariantError') + expect(err.code).toBe('INVARIANT') + expect(err.message).toBe('invariant violated: seq must strictly increase') + }) + + it('does not leak listeners across dispose (no stale freezing)', async () => { + const { ctx, fiber } = await setup() + await fiber.dispose() + const spy = vi.fn() + ctx.on('session/event', spy) + const session = ctx.sessions.create() + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) + // our own spy fires, proving events still flow — but the plugin's frozen. + expect(spy).toHaveBeenCalledOnce() + expect(Object.isFrozen(session.events[0])).toBe(false) + }) +}) diff --git a/packages/invariants/tsconfig.json b/packages/invariants/tsconfig.json new file mode 100644 index 0000000000..54fbb4adac --- /dev/null +++ b/packages/invariants/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cosmokit" }, + { "path": "../../vendor/cordis" }, + { "path": "../llm" }, + { "path": "../session" }, + { "path": "../agent" } + ] +} diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 7b3b04812c..75147899f0 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -94,6 +94,14 @@ export class Session { * - `tool/result` → user message carrying a tool-result block * - `context/message` / `steering/message` → tagged synthetic user messages * at their chronological position + * + * The returned `content` is **deep-cloned** off the logged events: the loop + * hands these messages into the mutable `agent/request` waterfall and on to + * adapters, where mutating the request is sanctioned — but the session log + * is append-only by contract. Cloning at this boundary keeps in-flight + * mutation from reaching back and rewriting history (which would silently + * break replay equivalence). Cost is one structured clone per step, + * negligible next to a model call. */ deriveMessages(): Message[] { const messages: Message[] = [] @@ -104,29 +112,29 @@ export class Session { // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (event.type) { case 'user/message': { - messages.push({ role: 'user', content: event.data.content }) + messages.push({ role: 'user', content: structuredClone(event.data.content) }) break } case 'assistant/message': { - messages.push({ role: 'assistant', content: event.data.content }) + messages.push({ role: 'assistant', content: structuredClone(event.data.content) }) break } case 'tool/result': { const { callId, content, isError } = event.data messages.push({ role: 'user', - content: [{ type: 'tool-result', toolCallId: callId, content, isError }], + content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }], }) break } case 'context/message': { const { content, source } = event.data - messages.push({ role: 'user', content: renderTagged('context', content, source) }) + messages.push({ role: 'user', content: renderTagged('context', structuredClone(content), source) }) break } case 'steering/message': { const { content, source } = event.data - messages.push({ role: 'user', content: renderTagged('steering', content, source) }) + messages.push({ role: 'user', content: renderTagged('steering', structuredClone(content), source) }) break } } diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts index 44f2bae980..740ce78bda 100644 --- a/packages/session/tests/session.spec.ts +++ b/packages/session/tests/session.spec.ts @@ -54,6 +54,31 @@ describe('Session', () => { expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) expect(replayed.seq).toBe(original.seq) }) + + it('isolates the log from mutation through a derived message (append-only contract)', () => { + const session = new Session(SessionId('s4')) + session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }) + session.append('tool/result', { + turn: 1, step: 1, callId: CallId('c1'), + content: [{ type: 'text', text: 'tool out' }], isError: false, + }) + const before = structuredClone(session.events) + + // A request middleware / adapter mutates the messages it was handed. + const messages = session.deriveMessages() + const userBlock = messages[0]!.content[0]! + if (userBlock.type === 'text') userBlock.text = 'HACKED' + const toolBlock = messages[1]!.content[0]! + if (toolBlock.type === 'tool-result') { + toolBlock.content.push({ type: 'text', text: 'injected' }) + } + messages[0]!.content.push({ type: 'text', text: 'extra' }) + + // The log is unchanged: deep-equal to the snapshot taken before mutation. + expect(session.events).toEqual(before) + // And a fresh derivation still reflects the original content. + expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }]) + }) }) describe('SessionStore', () => { diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 45e5a3e0be..4b967e9c4b 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -15,6 +15,7 @@ const packages = [ 'packages/llm-pi-ai', 'packages/bash-local', 'packages/tool-bash', + 'packages/invariants', ] const root = resolve(import.meta.dirname, '..') diff --git a/tsconfig.base.json b/tsconfig.base.json index 8adcf0313d..6311883ba8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -44,7 +44,8 @@ "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"] + "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], + "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"] } } } diff --git a/tsconfig.build.json b/tsconfig.build.json index d99cc777cd..7f62071870 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -20,6 +20,7 @@ { "path": "./packages/llm-deepseek" }, { "path": "./packages/llm-pi-ai" }, { "path": "./packages/bash-local" }, - { "path": "./packages/tool-bash" } + { "path": "./packages/tool-bash" }, + { "path": "./packages/invariants" } ] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 0d36220af6..181f319dfd 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -26,7 +26,8 @@ "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"] + "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], + "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"] } }, "include": ["packages/*/src", "packages/*/tests", "examples", "scripts"] diff --git a/yarn.lock b/yarn.lock index 2b82f1a35a..a57f97dadc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -611,6 +611,21 @@ __metadata: languageName: unknown linkType: soft +"@deepseek-ai/dsh-invariants@workspace:packages/invariants": + version: 0.0.0-use.local + resolution: "@deepseek-ai/dsh-invariants@workspace:packages/invariants" + dependencies: + "@deepseek-ai/dsh-agent": "npm:^0.0.1" + "@deepseek-ai/dsh-llm": "npm:^0.0.1" + "@deepseek-ai/dsh-session": "npm:^0.0.1" + cordis: "npm:^4.0.0-rc.6" + peerDependencies: + "@deepseek-ai/dsh-agent": ^0.0.1 + "@deepseek-ai/dsh-session": ^0.0.1 + cordis: ^4.0.0-rc.6 + languageName: unknown + linkType: soft + "@deepseek-ai/dsh-llm-deepseek@npm:^0.0.1, @deepseek-ai/dsh-llm-deepseek@workspace:packages/llm-deepseek": version: 0.0.0-use.local resolution: "@deepseek-ai/dsh-llm-deepseek@workspace:packages/llm-deepseek"