From 30805e1983d76d0846fcbdb49d0c2b06edb1a7b9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 23 Jun 2026 09:48:15 +0800 Subject: [PATCH 1/5] fix(sqlite): bump SCHEMA_VERSION to 3 for the new surface columns --- .../session-persistence-sqlite/src/schema.ts | 2 +- .../session-persistence-sqlite/tests/sqlite.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 6012916457..b747b7900c 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 2 +export const SCHEMA_VERSION = 3 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 86d72275f6..0b8b83cd4e 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -315,7 +315,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(2) + expect(SCHEMA_VERSION).toBe(3) }) }) From 09d497d5c5b2e7d321cded16110dd34bae2fff96 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 23 Jun 2026 09:54:58 +0800 Subject: [PATCH 2/5] docs(rfc): clarify compaction rides the replace op on an existing event type --- docs/rfc/implemented/architecture/2026-06-18-session-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 42c3872db6..644d526373 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -60,4 +60,4 @@ The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empt - **`packages/session-persistence/session-persistence-jsonl`**: No changes required. - **`packages/session-persistence/session-persistence`**: Abstract interface unchanged. -The surface is the foundation for future compaction: a compaction plugin appends a new event (e.g., `compaction/marker`, added to `SessionEventMap` via declaration merging) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed nodes. Replay preserves the compaction decision deterministically. +The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed nodes — the new node takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. From 6089e226bc2423229eea942ae7e253cc847799f8 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 23 Jun 2026 13:05:59 +0800 Subject: [PATCH 3/5] refactor(session): make surface the sole derivation path, drop legacy fallback --- docs/cordis-catalog/events-and-services.md | 2 +- .../2026-06-18-session-surface.md | 2 +- packages/core/session/README.md | 6 +- packages/core/session/src/index.ts | 59 +++++++++---------- packages/core/session/src/surface.ts | 14 ----- packages/core/session/src/types.ts | 12 ++-- packages/core/session/tests/session.spec.ts | 32 +++++----- packages/core/session/tests/surface.spec.ts | 47 +-------------- .../tests/jsonl.spec.ts | 8 +-- .../tests/coordinator-contract.ts | 14 ++--- .../invariants/tests/invariants.spec.ts | 36 +++++------ 11 files changed, 87 insertions(+), 145 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 1ee86d6a5e..f8c6b12c0d 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -390,7 +390,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:303`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:300`](../../packages/core/session/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 644d526373..ae2475c786 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -53,7 +53,7 @@ The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empt ## Consequences -- **`packages/core/session`**: New `surface.ts` (`SurfaceManager`), new types (`SurfaceOp`, `SurfaceAppendOpts`), new fields on `SessionEvent`, modified `append()` (third optional `SurfaceAppendOpts` param), refactored `deriveMessages()` (surface path + legacy fallback), surface-aware `repair.ts`. +- **`packages/core/session`**: New `surface.ts` (`SurfaceManager`), new types (`SurfaceOp`, `SurfaceIntent`), new fields on `SessionEvent`, modified `append()` (third required `SurfaceIntent` param), refactored `deriveMessages()` (surface path + legacy fallback), surface-aware `repair.ts`. - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance. - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). - **`packages/support/invariants`**: Surface-related validation rules. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 206022e306..08323eff0c 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -34,8 +34,8 @@ 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). An optional third parameter `opts: SurfaceAppendOpts` 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). -- `session.deriveMessages(): Message[]` — derive the LLM message history. If any event in the log carries `surfaceOp`, derivation walks the surface linked list (skipping non-surface events). Otherwise, falls back to a linear scan of the raw log (legacy sessions without surface markers). +- `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. +- `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.events`, `session.seq`, `session.id` - `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). 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. @@ -43,7 +43,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Surface types - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. -- `SurfaceAppendOpts` — `{ surfaceOp?: SurfaceOp; sourceEventSeqs?: number[] }`, the optional third parameter to `session.append()`. +- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. ### Session event vocabulary (`types.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 23e65cebda..5df4db58e9 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -10,7 +10,7 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' 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, SurfaceAppendOpts, SurfaceEventType } from './types.ts' +import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { isJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' @@ -149,11 +149,13 @@ export class Session { * * @param type - The event type (key of {@link SessionEventMap}). * @param data - The event payload; must be JSON-serializable. - * @param opts - Optional surface metadata: `surfaceOp` controls how the - * event enters the surface linked list; `sourceEventSeqs` records - * provenance (the seq numbers of events this one derives from). Only - * accepted for {@link SurfaceEventType} events — the compiler rejects - * surface opts for non-surface types like `turn/start` or `assistant/chunk`. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the surface linked list; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. * @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 @@ -165,7 +167,7 @@ export class Session { append( type: T, data: SessionEventMap[T], - ...opts: T extends SurfaceEventType ? [opts?: SurfaceAppendOpts] : [] + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] ): SessionEvent { if (!isJsonValue(data)) { throw new Error(`session event "${type}" carries non-JSON-serializable data`) @@ -183,7 +185,7 @@ export class Session { // Surface metadata is snapshot separately: sourceEventSeqs (number[] — // primitives, so array spread is a complete copy) and surfaceOp (a string // primitive, or cloned if it's a replace object). - const surfaceOpts: SurfaceAppendOpts | undefined = opts[0] + const surfaceOpts: SurfaceIntent | undefined = opts[0] // Build the event shape with conditional surface fields via spreading. // The result is cast through `unknown` because the conditional spreads // produce an intersection type that the assignability checker can't @@ -206,9 +208,12 @@ export class Session { } /** - * Derive the LLM message history from the session surface (when surface - * markers exist) or from a linear scan of the raw event log (legacy sessions - * without surface markers). + * 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. * * - `user/message` → user message * - `assistant/message` → assistant message (chunks are skipped — they are @@ -229,33 +234,24 @@ export class Session { * negligible next to a model call. */ deriveMessages(): Message[] { - if (this.surface.hasSurface) { - const messages: Message[] = [] - for (const node of this.surface.nodes) { - // 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]!) - // 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) - } - return messages - } - // Legacy path: linear scan for sessions without surface markers. const messages: Message[] = [] - for (const event of this.log) { - const msg = this._deriveOneMessage(event) + for (const node of this.surface.nodes) { + // 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]!) + // 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) } return messages } /** - * Derive a single LLM message from one event, or null if the event type - * does not produce a message. Extracted so both the surface path and the - * legacy linear-scan path share the same derivation rules. + * 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). */ private _deriveOneMessage(event: SessionEvent): Message | null { // Intentionally non-exhaustive: only message-producing events derive @@ -288,6 +284,7 @@ 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: return null } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index eaa8baeb4a..7ed1743af3 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -79,20 +79,6 @@ export class SurfaceManager { return this._nodes } - /** Whether any event in the log carries `surfaceOp` markers. */ - get hasSurface(): boolean { - if (this._nodes.length > 0) return true - // Never processed anything — scan the whole log. - if (this._lastProcessedSeq === -1) return this.log.some(e => isSurfaceEvent(e)) - // Processed up to _lastProcessedSeq without finding surface nodes; check - // only new events. - for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isSurfaceEvent(this.log[i]!)) return true - } - return false - } - /** * Process events from `_lastProcessedSeq + 1` through the end of the log, * folding new surface markers into the existing linked list. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 1fcd150d37..44e8e35fdf 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -222,17 +222,19 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } /** - * Optional surface metadata passed to {@link Session.append}. + * Surface metadata passed to {@link Session.append}. * `surfaceOp` controls how the event enters the surface linked list; * `sourceEventSeqs` records the seq numbers of events that are provenance * sources of this one (e.g. the `assistant/chunk` seqs behind an * `assistant/message`, or the shadowed nodes behind a compaction replacement). * - * Only accepted for {@link SurfaceEventType} events — non-surface event types - * (`turn/start`, `assistant/chunk`, `error`, …) cannot carry surface metadata. + * Required for {@link SurfaceEventType} events — every message-producing event + * MUST declare how it enters the surface, because the surface is the sole + * source of derived history. Non-surface event types (`turn/start`, + * `assistant/chunk`, `error`, …) cannot carry surface metadata. */ -export interface SurfaceAppendOpts { - surfaceOp?: SurfaceOp +export interface SurfaceIntent { + surfaceOp: SurfaceOp sourceEventSeqs?: number[] } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f095bdfb40..46f96742cc 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -7,7 +7,7 @@ describe('Session', () => { it('derives message history from the event log', () => { const session = new Session(SessionId('s1')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) session.append('assistant/message', { turn: 1, step: 1, @@ -15,8 +15,8 @@ describe('Session', () => { { type: 'text', text: 'let me check' }, { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }, ], - }) - session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + }, { surfaceOp: 'append' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const messages = session.deriveMessages() @@ -44,12 +44,12 @@ describe('Session', () => { session.append('context/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' }, - }) + }, { surfaceOp: 'append' }) session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus on tests' }], source: { kind: 'user' }, - }) + }, { surfaceOp: 'append' }) const [contextMessage, steeringMessage] = session.deriveMessages() expect(contextMessage!.role).toBe('user') @@ -60,8 +60,8 @@ describe('Session', () => { it('replays identically from a seeded event log', () => { const original = new Session(SessionId('s3')) - original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) - original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }) + original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' }) const replayed = new Session(SessionId('s3-replay'), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) @@ -70,11 +70,11 @@ describe('Session', () => { 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('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool out' }], isError: false, - }) + }, { surfaceOp: 'append' }) const before = structuredClone(session.events) // A request middleware / adapter mutates the messages it was handed. @@ -95,7 +95,7 @@ describe('Session', () => { it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => { const session = new Session(SessionId('s5')) - const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never) + const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' }) expect(bad(1n)).toThrow(/non-JSON-serializable/) expect(bad(() => 0)).toThrow(/non-JSON-serializable/) expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/) @@ -122,7 +122,7 @@ describe('Session', () => { it('accepts dense arrays and nested plain objects', () => { const session = new Session(SessionId('s6')) - expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never)).not.toThrow() + expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow() expect(session.events).toHaveLength(1) }) @@ -174,7 +174,7 @@ describe('Session', () => { it('snapshots append data: mutating the passed object after append does not affect session.events', () => { const session = new Session(SessionId('append-snapshot')) const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } } - const event = session.append('user/message', data) + const event = session.append('user/message', data, { surfaceOp: 'append' }) // Mutate the caller's object after append returns. A shared reference would // make session.events diverge from the value that passed validation. data.content[0]!.text = 'HACKED' @@ -201,7 +201,7 @@ describe('SessionStore', () => { const session = ctx.sessions.create() expect(created).toEqual([session]) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(events).toHaveLength(1) expect(events[0]![0]).toBe(session) expect(events[0]![1].type).toBe('user/message') @@ -216,7 +216,7 @@ describe('SessionStore', () => { const a = ctx.sessions.create(SessionId('fixed')) expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') - a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) + a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -309,7 +309,7 @@ describe('SessionStore', () => { await fiber.dispose() expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined() - session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(observed).toBe(0) }) @@ -332,7 +332,7 @@ describe('SessionStore', () => { ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(events).toHaveLength(1) }) }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index b4e1022494..a7f4c13dad 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -28,38 +28,6 @@ describe('SurfaceManager', () => { expect(nodes[1]!.next).toBeNull() }) - it('hasSurface returns false when no events have surfaceOp', () => { - const s = new Session(SessionId('nosurface')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(s.surface.hasSurface).toBe(false) - }) - - it('hasSurface returns true when any event has surfaceOp', () => { - const s = surfaceSession() - expect(s.surface.hasSurface).toBe(true) - }) - - it('hasSurface detects surface markers that arrive after initial processing', () => { - // Start with no surface markers. Access nodes first to set _lastProcessedSeq - // (via delta processing), keeping _nodes empty. Then append a mix of non-surface - // and surface events, and verify hasSurface detects via the delta-only check. - const s = new Session(SessionId('late')) - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) - // Access nodes to trigger processing: sets _lastProcessedSeq = 1, _nodes = []. - expect(s.surface.nodes.length).toBe(0) - // Append non-surface events first (exercises the loop-continue branch), then - // a surface event (exercises the return-true branch). - s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - s.append('turn/start', { turn: 2, trigger: { kind: 'continuation' } }) - s.append('assistant/message', { turn: 2, step: 1, content: [] }, { surfaceOp: 'append' }) - // hasSurface checks only new seqs [2, 3, 4]; skips 2 and 3 (non-surface), - // finds surfaceOp on seq 4 and returns true. - expect(s.surface.hasSurface).toBe(true) - }) - it('invalidate resets to full rebuild', () => { const s = surfaceSession() expect(s.surface.nodes.length).toBe(2) @@ -76,7 +44,6 @@ describe('SurfaceManager', () => { s.append('step/end', { turn: 1, step: 1 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(s.surface.nodes.length).toBe(0) - expect(s.surface.hasSurface).toBe(false) // deriveMessages returns empty array expect(s.deriveMessages()).toEqual([]) }) @@ -235,16 +202,6 @@ describe('deriveMessages with surface', () => { expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' }) }) - it('falls back to linear scan when no surface markers exist', () => { - const s = new Session(SessionId('legacy')) - s.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }) - const messages = s.deriveMessages() - expect(messages).toHaveLength(2) - expect(messages[0]!.role).toBe('user') - expect(messages[1]!.role).toBe('assistant') - }) - it('surface path skips non-surface events (chunks, boundaries)', () => { const s = new Session(SessionId('filter')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -308,9 +265,9 @@ describe('Session.append surface opts', () => { expect(s.deriveMessages()).toHaveLength(0) }) - it('append without surface opts produces an event without surface fields', () => { + it('a non-surface event carries no surface fields', () => { const s = new Session(SessionId('noopts')) - s.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect((s.events[0] as SessionEvent).sourceEventSeqs).toBeUndefined() expect((s.events[0] as SessionEvent).surfaceOp).toBeUndefined() }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 1df70f9c9a..7df0fd5180 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -276,8 +276,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => const a = ctx.sessions.create(SessionId('sa')) const b = ctx.sessions.create(SessionId('sb')) - a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }) - b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }) + a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) b.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', a) @@ -647,7 +647,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Make the durable materialize fail on the next flush. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise } @@ -695,7 +695,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // can never diverge from the live log. The throw surfaces at the caller's // append site, not asynchronously in a backend flush. expect(() => { - session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never) + session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) }).toThrow(/non-JSON-serializable/) // The bad event was rejected, so the log stayed empty. expect(session.events.length).toBe(0) diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 431d02b4cb..71cd296fb0 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -128,7 +128,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) - const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }) + const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // Mutate the live event object AFTER it was buffered by session/event. ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -232,7 +232,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const fiber = await fix.mount(ctx) @@ -253,7 +253,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) const fiber = await fix.mount(ctx) const session = await liveSessionInFiber(ctx, 'drain', WORK) - session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // No explicit flush — dispose must drain. await fiber.dispose() @@ -279,7 +279,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Backend instance 1 materializes the session. const backend1 = await fix.mount(ctx) 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('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) @@ -290,7 +290,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await backend1.dispose() await fix.mount(ctx) session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow() @@ -452,7 +452,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) // Re-emit session/created for the SAME live session (idempotent initFor). @@ -704,7 +704,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) - session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) + session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate')) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index f66b6a8929..2eaae73169 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -25,12 +25,12 @@ describe('session-log invariants', () => { 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('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) - session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] }) + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] }, { surfaceOp: 'append' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }).not.toThrow() @@ -89,9 +89,9 @@ describe('session-log invariants', () => { const { ctx } = await setup({ freeze: false }) const session = ctx.sessions.create() // No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC). - expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) + expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) .toThrow(/outside any open turn/) - expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } })) + expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) .toThrow(/outside any open turn/) }) @@ -100,7 +100,7 @@ describe('session-log invariants', () => { const session = ctx.sessions.create() // steering/message is turn-scoped: outside a turn it would land past the // commit boundary and be dropped on resume (the turn-enclosure RFC). - expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) .toThrow(/outside any open turn/) // A PLUGIN-added (merge-extensible) event type is caught by the default too. // Cast through `any`: 'compaction/marker' is not in SessionEventType (it's @@ -115,7 +115,7 @@ describe('session-log invariants', () => { const { ctx } = await setup({ freeze: false }) const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) + expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) .not.toThrow() }) @@ -124,7 +124,7 @@ describe('session-log 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 }) - expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false })) + expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }, { surfaceOp: 'append' })) .toThrow(/no prior tool\/call/) }) @@ -136,7 +136,7 @@ describe('session-log invariants', () => { session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, step: 1, content: [ { type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' }, - ] }) + ] }, { surfaceOp: 'append' }) session.append('tool/result', { turn: 1, step: 1, @@ -144,7 +144,7 @@ describe('session-log invariants', () => { content: [{ type: 'text', text: 'interrupted' }], isError: true, error: { name: 'InterruptedError', code: 'interrupted' }, - }) + }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) }).not.toThrow() @@ -190,10 +190,10 @@ describe('session-log invariants', () => { expect(() => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { turn: 1, step: 1, content: [] }) + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 2 }) - session.append('assistant/message', { turn: 1, step: 2, content: [] }) + session.append('assistant/message', { turn: 1, step: 2, content: [] }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 2 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -246,7 +246,7 @@ describe('session-log invariants', () => { // step ends with the call unresolved — pendingCalls is cleared. session.append('step/end', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 2 }) - expect(() => session.append('tool/result', { turn: 1, step: 2, callId: CallId('c1'), content: [], isError: false })) + expect(() => session.append('tool/result', { turn: 1, step: 2, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })) .toThrow(/no prior tool\/call in this step/) }) @@ -255,7 +255,7 @@ describe('session-log 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 }) - expect(() => session.append('assistant/message', { turn: 1, step: 2, content: [] })) + expect(() => session.append('assistant/message', { turn: 1, step: 2, content: [] }, { surfaceOp: 'append' })) .toThrow(/open is turn 1\/step 1/) }) }) @@ -287,7 +287,7 @@ describe('dev-freeze', () => { const { ctx } = await setup() // freeze defaults true const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + 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) @@ -298,7 +298,7 @@ describe('dev-freeze', () => { 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' } }) + const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(Object.isFrozen(event)).toBe(false) }) @@ -324,7 +324,7 @@ describe('dev-freeze', () => { // the caller's input — read the event back and assert on its data. const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }] const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false }) - const event = session.append('user/message', { content: [block], source: { kind: 'user' } }) + const 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(logged.content)).toBe(true) expect(Object.isFrozen(logged.content[0])).toBe(true) @@ -424,7 +424,7 @@ describe('HMR safety', () => { 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' } }) + 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. expect(spy).toHaveBeenCalledOnce() expect(Object.isFrozen(session.events[0])).toBe(false) From 1ec8c40d0dcef27e689e6d81412ae464d29c91da Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 23 Jun 2026 13:26:45 +0800 Subject: [PATCH 4/5] refactor(surface): use nodeBySeq map for lookup in _replace, drop dead params --- docs/core-data-structures/session.md | 46 +++++++++++++++++++++++++++- packages/core/session/src/surface.ts | 28 ++++++++--------- scripts/type-equiv.manifest.json | 4 +++ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 62f6d062a4..2618db43c9 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -66,7 +66,51 @@ type SessionEvent = { `SessionEventType = keyof SessionEventMap`. Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default`. -The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) additionally carry two optional surface fields: `surfaceOp` (how the event enters the derived surface linked list — `'append'` or a `{ op: 'replace', start, end }` shadow) and `sourceEventSeqs` (provenance). See the [session surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md). +## Surface types + +The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the derived surface linked list. See the [session surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md). + +### `SurfaceEventType` — the message-producing subset of event types + +```ts type-equiv +export type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'context/message' + | 'steering/message' +``` + +### `SurfaceOp` — how an event entered the surface + +```ts type-equiv +export type SurfaceOp = + | 'append' + | { op: 'replace'; start: number; end: number } +``` + +`'append'` is the normal tail-append path. `replace` shadows surface nodes from `start` through `end` inclusive (both must be valid surface node seqs; `start === end` replaces a single node) and inserts the new node in their place. + +### `SurfaceIntent` — the parameter to `session.append()` + +```ts type-equiv +export interface SurfaceIntent { + surfaceOp: SurfaceOp + sourceEventSeqs?: number[] +} +``` + +Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived history. Non-surface types reject it at compile time. + +### `SurfaceNode` — a node in the surface linked list + +```ts type-equiv +export interface SurfaceNode { + seq: number + prev: number | null + next: number | null +} +``` ## Derived history: `deriveMessages()` diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7ed1743af3..3615668674 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -55,7 +55,7 @@ export interface SurfaceNode { export class SurfaceManager { /** Surface nodes in linked-list order (head to tail). Empty until first access. */ private _nodes: SurfaceNode[] = [] - /** Map from event seq → node for O(1) lookup during replacements. */ + /** Map from event seq → node. */ private _nodeBySeq = new Map() /** The last processed seq. -1 forces a full rebuild on first access. */ private _lastProcessedSeq = -1 @@ -100,7 +100,7 @@ export class SurfaceManager { this._nodes.push(node) this._nodeBySeq.set(event.seq, node) } else { - this._replace(this._nodes, this._nodeBySeq, event.seq, event.surfaceOp) + this._replace(event.seq, event.surfaceOp) } } this._lastProcessedSeq = this.log.length - 1 @@ -108,31 +108,31 @@ export class SurfaceManager { /** Apply a replace operation to the in-progress surface. */ private _replace( - nodes: SurfaceNode[], - nodeBySeq: Map, newSeq: number, op: Extract, ): void { - const startIdx = nodes.findIndex(n => n.seq === op.start) - if (startIdx === -1) { + const startNode = this._nodeBySeq.get(op.start) + if (!startNode) { throw new Error(`surface replace: start seq ${op.start} not found in surface`) } - const endIdx = nodes.findIndex(n => n.seq === op.end) - if (endIdx === -1) { + const endNode = this._nodeBySeq.get(op.end) + if (!endNode) { throw new Error(`surface replace: end seq ${op.end} not found in surface`) } + const startIdx = this._nodes.indexOf(startNode) + const endIdx = this._nodes.indexOf(endNode) if (startIdx > endIdx) { throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) } // Remove shadowed nodes from `[startIdx, endIdx]` inclusive. const count = endIdx - startIdx + 1 - const removed = nodes.splice(startIdx, count) - for (const r of removed) nodeBySeq.delete(r.seq) + const removed = this._nodes.splice(startIdx, count) + for (const r of removed) this._nodeBySeq.delete(r.seq) // Insert the new node where the removed range was. - const prevNode = startIdx > 0 ? nodes[startIdx - 1] : undefined - const nextNode = startIdx < nodes.length ? nodes[startIdx] : undefined + const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined + const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined const newNode: SurfaceNode = { seq: newSeq, @@ -141,7 +141,7 @@ export class SurfaceManager { } if (prevNode) prevNode.next = newSeq if (nextNode) nextNode.prev = newSeq - nodes.splice(startIdx, 0, newNode) - nodeBySeq.set(newSeq, newNode) + this._nodes.splice(startIdx, 0, newNode) + this._nodeBySeq.set(newSeq, newNode) } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f46c4fca6b..30165e2388 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -19,6 +19,10 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, From 358ae02c5610e923e7a1e6405da29f9fd1fe9f71 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 23 Jun 2026 13:37:13 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(invariants):=20enforce=20replace=20pro?= =?UTF-8?q?venance=20=E2=80=94=20sourceEventSeqs=20must=20cover=20every=20?= =?UTF-8?q?shadowed=20surface=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-18-session-surface.md | 2 +- packages/support/invariants/src/index.ts | 44 ++++++++- .../invariants/tests/invariants.spec.ts | 90 +++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index ae2475c786..0ee163cbcf 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls ### Invariants -The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace start ≤ end). +The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). ## Consequences diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 492db4bdd0..b2372e28db 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -69,6 +69,13 @@ interface SessionTrace { pendingCalls: Set /** Every seq seen so far — validates `sourceEventSeqs` references. */ knownSeqs: Set + /** + * The seqs currently on the surface linked list, in linked-list order + * (head to tail). A replace reorders this relative to seq order (the new + * node takes the replaced range's position), so range validation is + * positional, not by seq comparison. + */ + surface: number[] } /** @@ -147,9 +154,39 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { } } } - if (se.surfaceOp !== undefined && typeof se.surfaceOp !== 'string') { - if (se.surfaceOp.start > se.surfaceOp.end) { - throw new InvariantError(`surface replace: start ${se.surfaceOp.start} must be <= end ${se.surfaceOp.end}`) + // Fold this event into the tracked surface linked list, validating the + // replace contract as we go. `append` adds a tail node; `replace` shadows a + // positional range — every shadowed node must appear in sourceEventSeqs. + if (se.surfaceOp !== undefined) { + if (se.surfaceOp === 'append') { + trace.surface.push(event.seq) + } else { + const { start, end } = se.surfaceOp + if (start > end) { + throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`) + } + const startIdx = trace.surface.indexOf(start) + if (startIdx === -1) { + throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) + } + const endIdx = trace.surface.indexOf(end) + if (endIdx === -1) { + throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) + } + if (startIdx > endIdx) { + throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) + } + // Every node the replace shadows (surface positions [startIdx, endIdx] + // inclusive) must appear in sourceEventSeqs — the provenance contract. + const shadowed = trace.surface.slice(startIdx, endIdx + 1) + const recorded = new Set(se.sourceEventSeqs ?? []) + const missing = shadowed.filter(seq => !recorded.has(seq)) + if (missing.length > 0) { + throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) + } + // Apply the replace to the tracked surface: the new node takes the + // range's position so order stays in sync for later replaces. + trace.surface.splice(startIdx, shadowed.length, event.seq) } } @@ -288,6 +325,7 @@ export function apply(ctx: Context, config: Config = {}): void { nextStep: 1, pendingCalls: new Set(), knownSeqs: new Set(), + surface: [], }) /** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */ diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 2eaae73169..21f6356aee 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -542,6 +542,96 @@ describe('surface invariants', () => { }).toThrow(/must be <= end/) }) + it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + // Replace shadows surface nodes [2, 3] but records provenance for only [2]. + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] }) + }).toThrow(/must include every shadowed surface node; missing 3/) + }) + + it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] }) + }).not.toThrow() + }) + + it('rejects a replace naming a start seq that is not on the surface', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + // seq 1 (step/start) is a real earlier event but never entered the surface. + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) + }).toThrow(/start seq 1 is not on the surface/) + }) + + it('rejects a replace naming an end seq that is not on the surface', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + // start (2) is on the surface but end (99) never entered it. + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] }) + }).toThrow(/end seq 99 is not on the surface/) + }) + + it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + // Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4 + // precedes seq 3 in linked-list order even though 4 > 3 numerically. + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 + // A replace with start=3, end=4 passes the seq check (3 <= 4) but is + // reversed positionally (3 is at pos 1, 4 is at pos 0). + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5 + }).toThrow(/is after end seq 4 .* on the surface/) + }) + + it('rejects a replace that omits sourceEventSeqs entirely', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + // A replace with no sourceEventSeqs records no provenance for the node it shadows. + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }) + }).toThrow(/must include every shadowed surface node; missing 2/) + }) + + it('catches an incomplete-provenance replace on the load/seed path', async () => { + const { ctx } = await setup({ freeze: false }) + const badSeed = [ + { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + { type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } }, + { type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, + { type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, + { type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }] }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] }, + ] + expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/) + }) + it('rejects sourceEventSeqs on a non-surface event', async () => { const { ctx } = await setup() const session = ctx.sessions.create()