diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f8b7e9c135..91425e9e2b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -209,7 +209,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:37`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:38`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -221,7 +221,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -231,7 +231,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 71b4d83fd8..1c405975a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -163,7 +163,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:328`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:345`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 2e235c4b3b..5ecb1c7044 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -189,9 +189,9 @@ export interface SurfaceNode { } ``` -## Derived history: `deriveMessages()` +## Derived history: `deriveMessages()` and `deriveEventMessage()` -`Session.deriveMessages()` projects the event log into the `Message[]` the model sees. The projection rules: +`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: - `user/message` → a user message. - `assistant/message` → an assistant message. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its `usage`, but a content-less assistant turn must not enter the provider transcript. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 913a28d76e..db3947b863 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,9 +22,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:35`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:37`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:45`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:38`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ffa57ab6a5..5cd374d0f4 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -35,8 +35,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from). It is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makes `opts` mandatory when `type` is a specific `SurfaceEventType` literal, AND `append` **throws** at runtime if a surface-eligible event arrives with no `surfaceOp` — covering the case where `type` widens to the `SessionEventType` union (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish from `deriveMessages()`. -- `session.deriveMessages(): Message[]` — derive the LLM message history by walking the surface linked list (skipping non-surface events like chunks and boundaries; a `replace` shadows the nodes it covers). The surface is the single source of derived history — there is no raw-log fallback. -- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. +- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback. +- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). +- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. - `session.events`, `session.seq`, `session.id` - `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 392998b08e..b446b80b8e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -8,6 +8,7 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' +import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' @@ -235,53 +236,68 @@ export class Session { return event } + /** The derived-message cache: frozen projections, extended per unseen node. */ + private derived: Message[] = [] + /** Surface position (nodes projected) the cache has reached. */ + private derivedNodes = 0 + /** {@link SurfaceManager.replaceGeneration} the cache was built under. */ + private derivedGeneration = 0 + /** * Derive the LLM message history by walking the session surface — the linked * list of message-producing events maintained by `surfaceOp` markers. The * surface is the single source of derived history: every message-producing * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the - * shadowed nodes from the derivation. + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. * - * - `user/message` → user message - * - `assistant/message` → assistant message (chunks are skipped — they are - * replay/UI data; the assembled message is authoritative for history). An - * EMPTY-content assistant/message is skipped: a max-tokens step cut off with - * no content still records an assistant/message to host its `usage`, but a - * content-less assistant turn must not enter the provider transcript. - * - `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. + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen** + * — cloned once off the log at projection time, so consumers can never + * mutate logged data, and mutation attempts throw instead of silently + * diverging replay from history. + * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[] { - const messages: Message[] = [] - for (const node of this.surface.nodes) { + const nodes = this.surface.nodes + const generation = this.surface.replaceGeneration + if (generation !== this.derivedGeneration) { + this.derived = [] + this.derivedNodes = 0 + this.derivedGeneration = generation + } + for (const node of nodes.slice(this.derivedNodes)) { // Surface nodes are built from this.log — node.seq is always a valid // index by construction. The non-null assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const msg = this._deriveOneMessage(this.log[node.seq]!) + const msg = this.deriveEventMessage(this.log[node.seq]!) // A surface node is one of the five message-producing types, but an // empty-content assistant/message (a max-tokens step that hosts only // usage) derives to null and must not enter the transcript. - if (msg) messages.push(msg) + if (msg) this.derived.push(deepFreeze(msg)) } - return messages + this.derivedNodes = nodes.length + return [...this.derived] } /** - * Derive a single LLM message from one surface event, or null if it produces - * no message (an empty-content assistant/message that exists only to host - * usage). + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability RFC). The returned `content` is + * deep-cloned off the logged event: the log is append-only by contract, so + * no live reference to logged data leaves this boundary. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. */ - private _deriveOneMessage(event: SessionEvent): Message | null { + deriveEventMessage(event: SessionEvent): Message | null { // Intentionally non-exhaustive: only message-producing events derive // history; turn/step boundaries, chunks, usage, and errors are // trace/replay data. @@ -312,8 +328,9 @@ export class Session { const { content, source } = event.data return { role: 'user', content: renderTagged('steering', structuredClone(content), source) } } - /* v8 ignore next 2 -- unreachable: only surface nodes (the 5 message-producing types) reach here */ default: + // A non-surface event (boundary, chunk, log-only record) projects to + // no message. Merge-extensible union: no assertNever here. return null } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 57f9a65864..37843970a2 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -72,6 +72,9 @@ export class SurfaceManager { /** The last processed seq. -1 forces a full rebuild on first access. */ private _lastProcessedSeq = -1 + /** Rewrite generation — see {@link replaceGeneration}. */ + private _replaceGeneration = 0 + constructor(private log: readonly SessionEvent[]) {} /** @@ -83,6 +86,23 @@ export class SurfaceManager { this._lastProcessedSeq = -1 this._nodes = [] this._nodeBySeq.clear() + // A wholesale rebuild is a rewrite: bump the generation so incremental + // consumers (the session's derived-message cache) discard their view. + this._replaceGeneration += 1 + } + + /** + * The surface's rewrite generation: bumped by every folded `replace` op and + * by {@link invalidate}. A replace is the ONE operation that rewrites the + * surface non-monotonically, so an incremental consumer of {@link nodes} + * (the session's derived-message cache) compares this between visits — an + * unchanged generation guarantees every node it has not seen is a pure tail + * append; a changed one means its view must rebuild. Monotonic: it never + * moves backwards, so comparisons cannot be fooled by a re-fold. + */ + get replaceGeneration(): number { + if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + return this._replaceGeneration } /** The surface nodes in linked-list order (head to tail). */ @@ -155,5 +175,6 @@ export class SurfaceManager { if (nextNode) nextNode.prev = newSeq this._nodes.splice(startIdx, 0, newNode) this._nodeBySeq.set(newSeq, newNode) + this._replaceGeneration += 1 } } diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts new file mode 100644 index 0000000000..b416d15639 --- /dev/null +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -0,0 +1,112 @@ +/** + * Derived-message cache tests: the session projects each surface node exactly + * once (O(new nodes) per call), rebuilds on a surface rewrite (replace / + * invalidate — the replaceGeneration signal), returns a fresh array snapshot + * per call over shared frozen messages, and stays deep-equal to a from-scratch + * replay derivation at every step — the incremental==scratch property the + * reconstructability RFC's invariant enforces in dev at request time. + */ + +import { describe, expect, it } from 'vitest' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +function userText(session: Session, text: string): void { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) +} + +/** From-scratch oracle: replay the log into a fresh session and derive. */ +function scratch(session: Session): unknown { + return new Session(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages() +} + +describe('derived-message cache', () => { + it('stays deep-equal to a from-scratch replay derivation as the log grows', () => { + const session = new Session(SessionId('cache-grow')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + userText(session, 'one') + expect(session.deriveMessages()).toEqual(scratch(session)) + userText(session, 'two') + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' }) + expect(session.deriveMessages()).toEqual(scratch(session)) + // An empty-content assistant/message (usage host) projects to nothing. + session.append('assistant/message', { turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' }) + expect(session.deriveMessages()).toEqual(scratch(session)) + }) + + it('rebuilds on a surface replace and still matches scratch', () => { + const session = new Session(SessionId('cache-replace')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + userText(session, 'one') + userText(session, 'two') + const beforeReplace = session.deriveMessages() + expect(beforeReplace).toHaveLength(2) + + const nodes = session.surface.nodes + session.append('context/message', { + content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] }) + + expect(session.deriveMessages()).toHaveLength(1) + expect(session.deriveMessages()).toEqual(scratch(session)) + // The array a caller took before the replace is untouched. + expect(beforeReplace).toHaveLength(2) + }) + + it('returns a fresh array per call: later appends never grow a held snapshot', () => { + const session = new Session(SessionId('cache-snapshot')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + userText(session, 'one') + const first = session.deriveMessages() + userText(session, 'two') + const second = session.deriveMessages() + expect(first).toHaveLength(1) + expect(second).toHaveLength(2) + // Shared projection objects: the same frozen message instance, once ever. + expect(second[0]).toBe(first[0]) + expect(Object.isFrozen(first[0])).toBe(true) + }) + + it('rebuilds after surface.invalidate() (the generation covers wholesale rebuilds too)', () => { + const session = new Session(SessionId('cache-invalidate')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + userText(session, 'one') + const before = session.deriveMessages() + session.surface.invalidate() + const after = session.deriveMessages() + expect(after).toEqual(before) + // A rebuild re-projects: fresh objects, same values. + expect(after[0]).not.toBe(before[0]) + }) +}) + +describe('Session.deriveEventMessage — the per-event projection', () => { + it('projects one appended event exactly as the full derivation projects its node', () => { + const session = new Session(SessionId('per-event')) + 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' }) + // The fold path (deriveMessages) and the per-event path share the + // projection, so an external reconstructor cannot disagree with the cache. + expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1)) + }) + + it('clones content off the log: the projection never aliases the logged event', () => { + const session = new Session(SessionId('per-event-clone')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const message = session.deriveEventMessage(event)! + expect(message.content).not.toBe(event.data.content) + // deriveEventMessage returns an unfrozen clone (the cache freezes ITS + // copies); mutating it must not reach the log. + ;(message.content[0] as { text: string }).text = 'mutated' + expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }]) + }) + + it('projects null for events that produce no message (boundaries, empty assistant)', () => { + const session = new Session(SessionId('per-event-null')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const boundary = session.append('step/start', { turn: 1, step: 1 }) + expect(session.deriveEventMessage(boundary)).toBeNull() + const empty = session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' }) + expect(session.deriveEventMessage(empty)).toBeNull() + }) +}) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 4be0cbcf6d..c3a887e14f 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -109,15 +109,17 @@ describe('Session properties', () => { )) }) - it('every derived message has a known role and decoupled content', () => { + it('every derived message has a known role and is frozen (append-only contract)', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) const messages = session.deriveMessages() const before = structuredClone(session.events) for (const m of messages) { expect(['user', 'assistant', 'system']).toContain(m.role) - // Mutating derived content must not touch the log (append-only). - m.content.push({ type: 'text', text: 'mutation' }) + // Derived messages are frozen shared projections: mutation THROWS + // (strict mode) instead of relying on per-call clones for isolation. + expect(Object.isFrozen(m)).toBe(true) + expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError) } expect(session.events).toEqual(before) })) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 27f8b5d420..4ff13940bc 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -78,19 +78,25 @@ describe('Session', () => { }, { surfaceOp: 'append' }) const before = structuredClone(session.events) - // A request middleware / adapter mutates the messages it was handed. + // A misbehaving consumer tries to mutate the messages it was handed. + // Derived messages are frozen shared projections (cloned once off the + // log, then deep-frozen): every mutation attempt THROWS in strict mode — + // isolation by unrepresentability, not by per-call cloning. const messages = session.deriveMessages() const userBlock = messages[0]!.content[0]! - if (userBlock.type === 'text') userBlock.text = 'HACKED' + expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError) 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' }) + expect(() => { + if (toolBlock.type === 'tool-result') toolBlock.content.push({ type: 'text', text: 'injected' }) + }).toThrow(TypeError) + expect(() => { messages[0]!.content.push({ type: 'text', text: 'extra' }) }).toThrow(TypeError) + // The returned ARRAY is the caller's own snapshot, though — reordering it + // is the caller's business and never reaches the cache or the log. + messages.reverse() // 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. + // And a fresh derivation still reflects the original content and order. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }]) })