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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user