Merge remote-tracking branch 'origin/master' into xtr/trajectory-virtual-scroll

# Conflicts:
#	docs/core-data-structures/session.i18n.yaml
#	packages/client/ui-trajectory/README.i18n.yaml
#	packages/client/ui-trajectory/README.zh.md
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.zh.md
This commit is contained in:
_Kerman
2026-08-05 15:41:04 +08:00
1305 changed files with 8237 additions and 4447 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: 892be8237d008b85418d8b815325a970b140a163
README.md: 6b1fa9f15b26c87dde9ed7bc356154f2fcc59b42
README.zh.md: 031239faf0fd14d582988f05590816cd17c329b6

View File

@@ -35,7 +35,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.create()` and detached replay or inspection sessions through `Session.create()`; the detached factory does not publish lifecycle events or bind the session to a fiber.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over the complete identified, frozen messages stored by those entries. Assistant messages preserve provider/model provenance and adapter-private replay state in their model source. A surface rewrite rebuilds the projection; there is no raw-log fallback.
@@ -49,6 +49,8 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit.
Session-event import separates ownership from message validation. `snapshotSessionEvent(event)` clones a borrowed event before validating and freezing its identified message. `adoptSessionEvent(event)` performs the same message work in place and returns the original event; callers may use it only when they transfer an exclusively owned object graph with no mutable child shared with another event.
### Chunk-row storage codec (`chunk-rows.ts`)
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only.

View File

@@ -30,9 +30,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -103,17 +103,12 @@ declare module 'cordis' {
}
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
/** Validate and freeze one detached creation header in place. */
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('session header is not a plain JSON record')
}
const record = snapshot as Record<string, unknown>
const record = input as Record<string, unknown>
if (record.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
}
@@ -148,31 +143,52 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
return validateSessionHeader(id, snapshot)
}
/**
* Validate an exclusively owned event and deeply freeze its identified message
* without copying the event. The caller transfers an object graph that no
* producer retains and that shares no mutable children with another event.
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
* @param event - exclusively owned event imported across a trusted boundary.
* @returns the same event object with a validated, deeply frozen message.
*/
export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
assertMessageEventShape(
event,
`session event at seq ${event.seq}`,
)
switch (event.type) {
case 'user/message':
deepFreeze(event.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(event.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return event
}
/**
* Detach one event while preserving deep immutability for its identified message.
* @param event - event imported across a query or persistence boundary.
* @returns a detached event snapshot with a validated, deeply frozen message.
*/
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
assertMessageEventShape(
snapshot,
`session event at seq ${snapshot.seq}`,
)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return snapshot
return adoptSessionEvent(structuredClone(event))
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
@@ -378,7 +394,8 @@ const attachments = new WeakMap<Session, SessionEntry>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
* Plain class (not a Service) — create live instances via
* `ctx.sessions.create()` and detached instances via {@link create}.
* Seeding with an existing event log replays/forks a session.
* @typert object
*/
@@ -433,7 +450,19 @@ export class Session {
*/
readonly firstLiveSeq: number
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
* @param id - session identity.
* @param seed - optional borrowed replay or fork events.
* @param header - optional borrowed storage metadata.
* @returns a detached session.
*/
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
return new Session(id, seed, header)
}
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -843,7 +872,7 @@ export class SessionStore extends Service {
...meta?.origin === undefined ? {} : { origin: meta.origin },
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
}
return new Session(sessionId, seed, header)
return Session.create(sessionId, seed, header)
}
/**

View File

@@ -16,12 +16,12 @@ function userText(session: Session, text: string): void {
/** 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()
return Session.create(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'))
const session = Session.create(SessionId('cache-grow'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
userText(session, 'one')
expect(session.deriveMessages()).toEqual(scratch(session))
@@ -54,7 +54,7 @@ describe('derived-message cache', () => {
})
it('rebuilds on a surface replace and still matches scratch', () => {
const session = new Session(SessionId('cache-replace'))
const session = Session.create(SessionId('cache-replace'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
userText(session, 'one')
userText(session, 'two')
@@ -72,7 +72,7 @@ describe('derived-message cache', () => {
})
it('returns a fresh array per call: later appends never grow a held snapshot', () => {
const session = new Session(SessionId('cache-snapshot'))
const session = Session.create(SessionId('cache-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
userText(session, 'one')
const first = session.deriveMessages()
@@ -89,7 +89,7 @@ describe('derived-message cache', () => {
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'))
const session = Session.create(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
@@ -99,7 +99,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
})
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
const session = Session.create(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
@@ -113,7 +113,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
})
it('projects null for events that produce no message (boundaries, empty assistant)', () => {
const session = new Session(SessionId('per-event-null'))
const session = Session.create(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()

View File

@@ -217,7 +217,7 @@ describe('SessionStore.fork', () => {
it('rejects a detached Session object that is not live in ctx.sessions', async () => {
const { sessions } = await setup()
const detached = new Session(SessionId('detached'))
const detached = Session.create(SessionId('detached'))
expect(() => sessions.fork(detached))
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
@@ -226,7 +226,7 @@ describe('SessionStore.fork', () => {
it('rejects a stale Session object whose id is live on a different instance', async () => {
const { ctx, sessions } = await setup()
ctx.sessions.create(SessionId('same-id'))
const stale = new Session(SessionId('same-id'))
const stale = Session.create(SessionId('same-id'))
expect(() => sessions.fork(stale))
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))

View File

@@ -82,7 +82,7 @@ const logArb = fc.array(anyEventArb, { maxLength: 25 })
let counter = 0
function build(events: Appendable[]): Session {
const session = new Session(SessionId(`prop-${counter++}`))
const session = Session.create(SessionId(`prop-${counter++}`))
for (const e of events) {
// Forward the generated intent verbatim; non-surface events carry none.
if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
@@ -110,7 +110,7 @@ describe('Session properties', () => {
it('replay-from-seed reproduces the derivation identically', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
// Every explicit replay grows by exactly one log-only boundary.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
@@ -121,8 +121,8 @@ describe('Session properties', () => {
it('replaying a log that already ends in end-seed adds no further marker', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events])
const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events])
// Lazy resume makes browsing a pickup, so this must not grow per open.
expect(twice.events).toEqual(once.events)
}))

View File

@@ -75,7 +75,7 @@ describe('foldRequestHeader', () => {
})
it('takes the latest full snapshot and skips unrelated events', () => {
const session = new Session(SessionId('fold'))
const session = Session.create(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', createUserMessage({
@@ -91,9 +91,9 @@ describe('legacy request-header format', () => {
const legacy = [{
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
expect(() => Session.create(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
const session = new Session(SessionId('legacy-append-delta'))
const session = Session.create(SessionId('legacy-append-delta'))
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
.toThrow(/unsupported legacy request\/header-delta/)
@@ -104,10 +104,10 @@ describe('legacy request-header format', () => {
const legacy = [{
type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
expect(() => Session.create(SessionId('legacy-seed-reason'), legacy))
.toThrow('unsupported legacy request/header reason "fallback"')
const session = new Session(SessionId('legacy-append-reason'))
const session = Session.create(SessionId('legacy-append-reason'))
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
.toThrow('unsupported legacy request/header reason "fallback"')
@@ -130,13 +130,13 @@ describe('Session.requestContext', () => {
}
it('reads undefined before any record exists', () => {
expect(new Session(SessionId('no-capacity')).requestContext()).toBeUndefined()
expect(Session.create(SessionId('no-capacity')).requestContext()).toBeUndefined()
})
it('folds a seeded log on first read, taking the last record', () => {
// The fold watermark starts at 0 with the seed already in the log, so the
// first read must consume the whole seed rather than skip it.
const session = new Session(SessionId('seeded-capacity'), seedWith(
const session = Session.create(SessionId('seeded-capacity'), seedWith(
CAPACITY,
{ ...CAPACITY, model: 'later', contextWindow: 256_000 },
))
@@ -144,7 +144,7 @@ describe('Session.requestContext', () => {
})
it('advances incrementally across appends and skips unrelated events', () => {
const session = new Session(SessionId('incremental-capacity'), seedWith(CAPACITY))
const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('todo/write', { todos: [] })
expect(session.requestContext()).toEqual(CAPACITY)
@@ -155,7 +155,7 @@ describe('Session.requestContext', () => {
})
it('folds a batch appended between two reads', () => {
const session = new Session(SessionId('batched-capacity'), seedWith(CAPACITY))
const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
session.append('todo/write', { todos: [] })
@@ -164,7 +164,7 @@ describe('Session.requestContext', () => {
})
it('exposes a frozen record so a reader cannot desync later comparisons', () => {
const session = new Session(SessionId('frozen-capacity'), seedWith(CAPACITY))
const session = Session.create(SessionId('frozen-capacity'), seedWith(CAPACITY))
const held = session.requestContext()
if (held === undefined) throw new Error('expected a folded capacity record')
expect(Object.isFrozen(held)).toBe(true)

View File

@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
adoptSessionEvent,
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
@@ -13,7 +14,7 @@ import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurf
describe('Session', () => {
it('exposes one stable readonly surface view', () => {
const session = new Session(SessionId('surface-view'))
const session = Session.create(SessionId('surface-view'))
const surface = session.surface
expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
@@ -21,7 +22,7 @@ describe('Session', () => {
})
it('derives message history from the event log', () => {
const session = new Session(SessionId('s1'))
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
@@ -61,7 +62,7 @@ describe('Session', () => {
it('accepts and round-trips a max-tokens turn/end reason', () => {
// The max-tokens TurnEndReason variant carries no extra data, so it must
// append and persist like any other reason (JSON-serializable, no fields).
const session = new Session(SessionId('s1'))
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
@@ -72,7 +73,7 @@ describe('Session', () => {
})
it('finds the latest message-turn outcome past later non-message turns', () => {
const session = new Session(SessionId('message-turn-outcome'))
const session = Session.create(SessionId('message-turn-outcome'))
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 1,
@@ -108,10 +109,10 @@ describe('Session', () => {
})
it('round-trips the coarse aborted turn outcome', () => {
const session = new Session(SessionId('aborted'))
const session = Session.create(SessionId('aborted'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
@@ -129,12 +130,12 @@ describe('Session', () => {
},
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy-aborted'), legacy))
expect(() => Session.create(SessionId('legacy-aborted'), legacy))
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
it('renders injected-context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
const session = Session.create(SessionId('s2'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
@@ -155,7 +156,7 @@ describe('Session', () => {
})
it('keeps the exact identified context message in durable history and projection', () => {
const session = new Session(SessionId('s2-raw'))
const session = Session.create(SessionId('s2-raw'))
const message = createUserMessage({
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
@@ -168,7 +169,7 @@ describe('Session', () => {
})
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
const original = Session.create(SessionId('s3'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
@@ -186,7 +187,7 @@ describe('Session', () => {
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
const replayed = Session.create(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
// The seed verbatim, plus the end-seed event the constructor appends.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
@@ -195,16 +196,16 @@ describe('Session', () => {
})
it('marks an explicitly empty seed without marking a fresh session', () => {
const fresh = new Session(SessionId('fresh-empty'))
const fresh = Session.create(SessionId('fresh-empty'))
expect(fresh.events).toEqual([])
const resumed = new Session(SessionId('resumed-empty'), [])
const resumed = Session.create(SessionId('resumed-empty'), [])
expect(resumed.firstLiveSeq).toBe(0)
expect(resumed.events).toMatchObject([
{ type: 'session/end-seed', seq: 0, data: {} },
])
const reopened = new Session(SessionId('reopened-empty'), resumed.events)
const reopened = Session.create(SessionId('reopened-empty'), resumed.events)
expect(reopened.firstLiveSeq).toBe(1)
expect(reopened.events).toEqual(resumed.events)
})
@@ -214,7 +215,7 @@ describe('Session', () => {
type: 'request/header', seq: 0, time: 1,
data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-header'), [requestHeader]))
expect(() => Session.create(SessionId('old-header'), [requestHeader]))
.toThrow('seed request/header at index 0 lacks provider/model')
const assistantMessage = {
@@ -222,20 +223,20 @@ describe('Session', () => {
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
surfaceOp: 'append',
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
.toThrow('seed assistant/message at index 0 lacks an identified message')
const malformedHeader = {
type: 'request/header', seq: 0, time: 1,
data: { header: 'old-header' },
} as unknown as SessionEvent
expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
expect(() => Session.create(SessionId('malformed-header'), [malformedHeader]))
.toThrow('seed request/header at index 0 lacks provider/model')
const unrelatedPrimitiveData = {
type: 'plugin/event', seq: 0, time: 1, data: null,
} as unknown as SessionEvent
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
.toEqual([unrelatedPrimitiveData])
})
@@ -353,7 +354,7 @@ describe('Session', () => {
for (const { name, event, message } of invalid) {
expect(
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
() => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
name,
).toThrow(message)
}
@@ -389,6 +390,45 @@ describe('Session', () => {
.toEqual([{ type: 'plugin-block', value: 1 }])
})
it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
const owned = {
type: 'user/message',
seq: 0,
time: 1,
surfaceOp: 'append',
data: {
id: 'owned-message',
role: 'user',
content: [{ type: 'text', text: 'owned' }],
source: { kind: 'user' },
},
} as SessionEvent<'user/message'>
expect(adoptSessionEvent(owned)).toBe(owned)
expect(Object.isFrozen(owned.data)).toBe(true)
expect(Object.isFrozen(owned.data.content)).toBe(true)
const source = structuredClone(owned)
const snapshot = snapshotSessionEvent(source)
expect(snapshot).not.toBe(source)
expect(snapshot.data).not.toBe(source.data)
expect(snapshot.data.content).not.toBe(source.data.content)
})
it('validates message shape before adopting ownership', () => {
const malformed = {
type: 'user/message',
seq: 0,
time: 1,
data: {
id: 'wrong-role',
role: 'assistant',
content: [],
source: { kind: 'user' },
},
} as unknown as SessionEvent
expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
@@ -405,7 +445,7 @@ describe('Session', () => {
reason: 'initial',
},
} as const
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0])
.toEqual(valid)
for (const reasoningEffort of ['', 1]) {
@@ -413,7 +453,7 @@ describe('Session', () => {
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
const config = invalid.data.header.config as unknown as Record<string, unknown>
config.reasoningEffort = reasoningEffort
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
}
})
@@ -435,7 +475,7 @@ describe('Session', () => {
reason: 'initial',
},
} as const
expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
for (const adapterDefaults of [
null,
@@ -447,13 +487,13 @@ describe('Session', () => {
const invalid = structuredClone(valid) as unknown as SessionEvent
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
invalid.data.header.adapterDefaults = adapterDefaults as never
expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid]))
expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
.toThrow('seed request/header at index 0 has invalid adapterDefaults')
}
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
const session = Session.create(SessionId('s4'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -487,7 +527,7 @@ describe('Session', () => {
})
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
const session = new Session(SessionId('s5'))
const session = Session.create(SessionId('s5'))
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/)
@@ -514,7 +554,7 @@ describe('Session', () => {
})
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
const session = Session.create(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
@@ -528,7 +568,7 @@ describe('Session', () => {
})
it('accepts dense arrays and nested plain objects', () => {
const session = new Session(SessionId('s6'))
const session = Session.create(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, { surfaceOp: 'append' })).not.toThrow()
expect(session.events).toHaveLength(1)
})
@@ -539,7 +579,7 @@ describe('Session', () => {
const badSeed = [
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
})
it('validates seed events: rejects a non-contiguous seq', () => {
@@ -547,7 +587,7 @@ describe('Session', () => {
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
})
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
@@ -562,7 +602,7 @@ describe('Session', () => {
}) },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
})
it('accepts a well-formed contiguous serializable seed', () => {
@@ -573,7 +613,7 @@ describe('Session', () => {
}), surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
const session = Session.create(SessionId('seed-ok'), goodSeed)
expect(session.events.slice(0, 3)).toEqual(goodSeed)
expect(session.firstLiveSeq).toBe(3)
})
@@ -596,7 +636,7 @@ describe('Session', () => {
},
})
const session = new Session(SessionId('seed-entry-snapshot'), seed)
const session = Session.create(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
expect(session.events.slice(0, 1)).toEqual([accepted])
@@ -613,7 +653,7 @@ describe('Session', () => {
})
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-nested-drift'), seed)
const session = Session.create(SessionId('seed-nested-drift'), seed)
expect(reads).toBe(1)
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
@@ -630,7 +670,7 @@ describe('Session', () => {
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
@@ -650,7 +690,7 @@ describe('Session', () => {
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
@@ -663,7 +703,7 @@ describe('Session', () => {
}
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
.toThrow(/not losslessly JSON-serializable/)
})
@@ -675,7 +715,7 @@ describe('Session', () => {
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
}) as unknown as SessionEvent
const session = new Session(SessionId('seed-null-prototype'), [event])
const session = Session.create(SessionId('seed-null-prototype'), [event])
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
})
@@ -708,7 +748,7 @@ describe('Session', () => {
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-unstable-metadata'), seed)
const session = Session.create(SessionId('seed-unstable-metadata'), seed)
const event = session.events[1]!
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
@@ -745,7 +785,7 @@ describe('Session', () => {
}] as unknown as SessionEvent[]
try {
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
.toThrow(`invalid seed event at index 1: ${expected}`)
} finally {
hasOwn.mockRestore()
@@ -762,7 +802,7 @@ describe('Session', () => {
}, surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
const session = Session.create(SessionId('seed-snapshot'), seed)
// Mutate the ORIGINAL seed objects after construction: a shared reference
// would let this rewrite the forked log (or reintroduce non-serializable
// data past validation). The snapshot must shield session.events.
@@ -775,7 +815,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 session = Session.create(SessionId('append-snapshot'))
const data = {
id: MessageId('append-input'),
role: 'user' as const,
@@ -795,7 +835,7 @@ describe('Session', () => {
})
it('reads a nested append-data getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-nested-drift'))
const session = Session.create(SessionId('append-nested-drift'))
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
@@ -813,7 +853,7 @@ describe('Session', () => {
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
const session = Session.create(SessionId('append-bad-metadata'))
expect(() => session.append(
'user/message',
@@ -831,7 +871,7 @@ describe('Session', () => {
readonly start = 0
readonly end = 0
}
const session = new Session(SessionId('append-exotic-metadata'))
const session = Session.create(SessionId('append-exotic-metadata'))
expect(() => session.append(
'user/message',
@@ -844,7 +884,7 @@ describe('Session', () => {
})
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-unstable-metadata'))
const session = Session.create(SessionId('append-unstable-metadata'))
const source = session.append(
'user/message',
createUserMessage({
@@ -875,7 +915,7 @@ describe('Session', () => {
})
it('rejects invalid plain surface metadata shapes at append', () => {
const session = new Session(SessionId('append-invalid-surface-shape'))
const session = Session.create(SessionId('append-invalid-surface-shape'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
@@ -896,7 +936,7 @@ describe('Session', () => {
})
it('rejects surface metadata on non-surface append and seed events', () => {
const session = new Session(SessionId('non-surface-metadata'))
const session = Session.create(SessionId('non-surface-metadata'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
@@ -908,7 +948,7 @@ describe('Session', () => {
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
time: 1,
@@ -919,7 +959,7 @@ describe('Session', () => {
})
it('deep-freezes seeded and appended event snapshots', () => {
const seeded = new Session(SessionId('seed-frozen'), [{
const seeded = Session.create(SessionId('seed-frozen'), [{
type: 'turn/start',
seq: 0,
time: 1,
@@ -932,7 +972,7 @@ describe('Session', () => {
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = new Session(SessionId('append-frozen'))
const appended = Session.create(SessionId('append-frozen'))
const appendedEvent = appended.append('todo/write', {
todos: [{ content: 'first', status: 'pending' }],
})
@@ -944,7 +984,7 @@ describe('Session', () => {
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = new Session(SessionId('events-snapshot'))
const session = Session.create(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const before = session.events
const beforeEvent = before[0]!
@@ -973,7 +1013,7 @@ describe('Session', () => {
seedLength: 2,
}
const session = new Session(SessionId('header-owned'), undefined, input)
const session = Session.create(SessionId('header-owned'), undefined, input)
input.cwd = '/caller-mutated'
expect(session.header).toEqual({
@@ -998,15 +1038,15 @@ describe('Session', () => {
readonly createdAt = 123
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
createdAt: 123,
parentSession: 1n,
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('other'),
createdAt: 123,
@@ -1033,7 +1073,7 @@ describe('Session', () => {
]
for (const { header, error } of cases) {
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
}
})
@@ -1057,7 +1097,7 @@ describe('Session', () => {
]
for (const [index, event] of cases.entries()) {
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
})
@@ -1145,7 +1185,7 @@ describe('SessionStore', () => {
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('owned-key'))
const session = Session.create(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
@@ -1302,7 +1342,7 @@ describe('SessionStore', () => {
})
it('a bare Session() constructed without the store still exposes a current-version header', () => {
const session = new Session(SessionId('bare'))
const session = Session.create(SessionId('bare'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
expect(typeof session.header.createdAt).toBe('number')
})
@@ -1626,7 +1666,7 @@ describe('SessionStore', () => {
it('does not let internal dispatch replace the disposed callback tuple', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const replacement = new Session(SessionId('replacement-disposed'))
const replacement = Session.create(SessionId('replacement-disposed'))
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/disposed') args[0] = replacement
@@ -1644,7 +1684,7 @@ describe('SessionStore', () => {
describe('todo/write event', () => {
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
const session = new Session(SessionId('t1'))
const session = Session.create(SessionId('t1'))
const todos: TodoItem[] = [
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
@@ -1666,7 +1706,7 @@ describe('todo/write event', () => {
})
it('is last-write-wins: the current list is the most recent todo/write', () => {
const session = new Session(SessionId('t2'))
const session = Session.create(SessionId('t2'))
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
session.append('todo/write', { todos: [
{ content: 'first', status: 'completed' },
@@ -1681,7 +1721,7 @@ describe('todo/write event', () => {
})
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = new Session(SessionId('t3'))
const session = Session.create(SessionId('t3'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1694,12 +1734,12 @@ describe('todo/write event', () => {
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = new Session(SessionId('t4'))
const original = Session.create(SessionId('t4'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = new Session(SessionId('t4-replay'), [...original.events])
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)

View File

@@ -21,7 +21,7 @@ import {
/** Build a minimal session with turn boundaries and a single user message. */
function surfaceSession(): Session {
const s = new Session(SessionId('ss'))
const s = Session.create(SessionId('ss'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
@@ -288,7 +288,7 @@ describe('SurfaceManager', () => {
})
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))
const s = Session.create(SessionId('shared-fold'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -332,7 +332,7 @@ describe('SurfaceManager', () => {
})
it('does not retain fold-only replacement history in incremental state', () => {
const s = new Session(SessionId('incremental-state'))
const s = Session.create(SessionId('incremental-state'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -363,12 +363,12 @@ describe('SurfaceManager', () => {
] as SessionEvent[]
expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
expect(() => new Session(SessionId('shared-fold-invalid'), events))
expect(() => Session.create(SessionId('shared-fold-invalid'), events))
.toThrow(/start seq 42 not found/)
})
it('leaves incremental state unchanged when candidate validation fails', () => {
const s = new Session(SessionId('atomic-validation'))
const s = Session.create(SessionId('atomic-validation'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -445,7 +445,7 @@ describe('SurfaceManager', () => {
})
it('empty surface yields empty nodes', () => {
const s = new Session(SessionId('empty'))
const s = Session.create(SessionId('empty'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 })
@@ -479,7 +479,7 @@ describe('SurfaceManager', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events])
const replayed = Session.create(SessionId('replay'), [...original.events])
expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
})
@@ -504,7 +504,7 @@ describe('SurfaceManager', () => {
})
it('replace with both ends at real nodes splices only the range', () => {
const s = new Session(SessionId('range'))
const s = Session.create(SessionId('range'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -533,7 +533,7 @@ describe('SurfaceManager', () => {
})
it('single-node replacement (start === end)', () => {
const s = new Session(SessionId('single'))
const s = Session.create(SessionId('single'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -559,7 +559,7 @@ describe('SurfaceManager', () => {
})
it('throws when replace start is not found', () => {
const s = new Session(SessionId('bad-start'))
const s = Session.create(SessionId('bad-start'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -580,7 +580,7 @@ describe('SurfaceManager', () => {
})
it('throws when replace end is not found', () => {
const s = new Session(SessionId('bad-end'))
const s = Session.create(SessionId('bad-end'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -601,7 +601,7 @@ describe('SurfaceManager', () => {
})
it('throws when start is after end', () => {
const s = new Session(SessionId('reversed'))
const s = Session.create(SessionId('reversed'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -626,7 +626,7 @@ describe('SurfaceManager', () => {
})
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
const s = new Session(SessionId('immutable'))
const s = Session.create(SessionId('immutable'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -650,7 +650,7 @@ describe('SurfaceManager', () => {
})
it('replace starting at non-head position preserves surrounding order', () => {
const s = new Session(SessionId('mid-replace'))
const s = Session.create(SessionId('mid-replace'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -679,7 +679,7 @@ describe('SurfaceManager', () => {
})
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
const s = new Session(SessionId('immutable-op'))
const s = Session.create(SessionId('immutable-op'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -714,7 +714,7 @@ describe('deriveMessages with surface', () => {
})
it('surface path skips non-surface events (chunks, boundaries)', () => {
const s = new Session(SessionId('filter'))
const s = Session.create(SessionId('filter'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
@@ -738,7 +738,7 @@ describe('deriveMessages with surface', () => {
})
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
const s = new Session(SessionId('compacted'))
const s = Session.create(SessionId('compacted'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -760,7 +760,7 @@ describe('deriveMessages with surface', () => {
})
it('injected-context and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
const s = Session.create(SessionId('ctx'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
@@ -780,7 +780,7 @@ describe('deriveMessages with surface', () => {
describe('Session.append surface opts', () => {
it('records sourceEventSeqs and surfaceOp on the event', () => {
const s = new Session(SessionId('opts'))
const s = Session.create(SessionId('opts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
const event = s.append('assistant/message',
@@ -825,20 +825,20 @@ describe('Session.append surface opts', () => {
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
const s = new Session(SessionId('nomessage'), seed)
const s = Session.create(SessionId('nomessage'), seed)
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
expect(s.deriveMessages()).toHaveLength(0)
})
it('a non-surface event carries no surface fields', () => {
const s = new Session(SessionId('noopts'))
const s = Session.create(SessionId('noopts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
})
it('surfaceOp primitives are not cloned (they are immutable)', () => {
const s = new Session(SessionId('prim'))
const s = Session.create(SessionId('prim'))
const event = s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
@@ -949,7 +949,7 @@ describe('surface type guards', () => {
describe('SurfaceManager.replaceGeneration', () => {
it('folds the pending log delta on access and counts replaces', () => {
const s = new Session(SessionId('gen'))
const s = Session.create(SessionId('gen'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },