The source-level JSON-serializability invariant was only a preflight: the Session constructor copied the seed array but shared every event/data object with the caller, and append() stored the caller's `data` reference verbatim. A post-create/post-append mutation could rewrite the durable log or reintroduce a non-JSON-serializable value AFTER validation, so session.events could diverge from what was validated / what a backend can persist. - ctor deep-clones each seed event after validation (not just the array). - append() stores structuredClone(data) (serializability already checked, so the clone is safe); the returned event carries the same snapshot. Regression tests: mutating the original seed / the passed append object after the call leaves session.events unchanged. Adapted the dev-freeze invariants test to assert on the logged clone (append no longer freezes the caller's input). Documented isJsonValue's exact scope (own enumerable string keys, matching JSON.stringify) and synced the README create() signature with meta.createdAt.
292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
describe('Session', () => {
|
|
it('derives message history from the event log', () => {
|
|
const session = new Session(SessionId('s1'))
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
|
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
|
session.append('assistant/message', {
|
|
turn: 1, step: 1,
|
|
content: [
|
|
{ type: 'text', text: 'let me check' },
|
|
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
|
|
],
|
|
})
|
|
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
const messages = session.deriveMessages()
|
|
expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
|
|
// raw chunks must NOT appear in derived history
|
|
expect(messages[1]!.content).toHaveLength(2)
|
|
expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') })
|
|
})
|
|
|
|
it('renders context and steering messages as tagged synthetic user content', () => {
|
|
const session = new Session(SessionId('s2'))
|
|
session.append('context/message', {
|
|
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
|
source: { kind: 'plugin', plugin: 'watcher' },
|
|
})
|
|
session.append('steering/message', {
|
|
turn: 1,
|
|
content: [{ type: 'text', text: 'focus on tests' }],
|
|
source: { kind: 'user' },
|
|
})
|
|
|
|
const [contextMessage, steeringMessage] = session.deriveMessages()
|
|
expect(contextMessage!.role).toBe('user')
|
|
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
|
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
|
|
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
|
})
|
|
|
|
it('replays identically from a seeded event log', () => {
|
|
const original = new Session(SessionId('s3'))
|
|
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
|
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] })
|
|
|
|
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
|
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
|
expect(replayed.seq).toBe(original.seq)
|
|
})
|
|
|
|
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
|
const session = new Session(SessionId('s4'))
|
|
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
|
|
session.append('tool/result', {
|
|
turn: 1, step: 1, callId: CallId('c1'),
|
|
content: [{ type: 'text', text: 'tool out' }], isError: false,
|
|
})
|
|
const before = structuredClone(session.events)
|
|
|
|
// A request middleware / adapter mutates the messages it was handed.
|
|
const messages = session.deriveMessages()
|
|
const userBlock = messages[0]!.content[0]!
|
|
if (userBlock.type === 'text') userBlock.text = 'HACKED'
|
|
const toolBlock = messages[1]!.content[0]!
|
|
if (toolBlock.type === 'tool-result') {
|
|
toolBlock.content.push({ type: 'text', text: 'injected' })
|
|
}
|
|
messages[0]!.content.push({ type: 'text', text: 'extra' })
|
|
|
|
// The log is unchanged: deep-equal to the snapshot taken before mutation.
|
|
expect(session.events).toEqual(before)
|
|
// And a fresh derivation still reflects the original content.
|
|
expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
|
|
})
|
|
|
|
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
|
|
const session = new Session(SessionId('s5'))
|
|
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never)
|
|
expect(bad(1n)).toThrow(/non-JSON-serializable/)
|
|
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
|
|
expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
|
|
expect(bad(new Map())).toThrow(/non-JSON-serializable/)
|
|
expect(bad(undefined)).toThrow(/non-JSON-serializable/)
|
|
expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
|
|
// A sparse array: `every` skips the hole but JSON.stringify writes it null.
|
|
// Build the hole without a sparse literal or `delete` (both linted).
|
|
const sparse: unknown[] = Array(3)
|
|
sparse[0] = 1
|
|
sparse[2] = 3 // index 1 stays a hole
|
|
expect(bad(sparse)).toThrow(/non-JSON-serializable/)
|
|
// A DENSE array carrying a non-serializable element is rejected too.
|
|
expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
|
|
// A nested non-serializable value (inside a plain object) is rejected.
|
|
expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
|
|
// A circular reference is rejected (the seen-set guard, not a stack blow-up).
|
|
const cyclic: Record<string, unknown> = { a: 1 }
|
|
cyclic['self'] = cyclic
|
|
expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
|
|
// The rejected appends never entered the log.
|
|
expect(session.events).toHaveLength(0)
|
|
})
|
|
|
|
it('accepts dense arrays and nested plain objects', () => {
|
|
const session = new Session(SessionId('s6'))
|
|
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never)).not.toThrow()
|
|
expect(session.events).toHaveLength(1)
|
|
})
|
|
|
|
it('validates seed events: rejects a non-JSON-serializable seed', () => {
|
|
// A replay/fork seed must satisfy the SAME invariant as Session.append, or
|
|
// it builds a live log no backend can persist.
|
|
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(/non-JSON-serializable/)
|
|
})
|
|
|
|
it('validates seed events: rejects a non-contiguous seq', () => {
|
|
const gapSeed = [
|
|
{ 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/)
|
|
})
|
|
|
|
it('accepts a well-formed contiguous serializable seed', () => {
|
|
const goodSeed = [
|
|
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
|
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' 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)
|
|
expect(session.events).toHaveLength(3)
|
|
})
|
|
|
|
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
|
|
const seed = [
|
|
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
|
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' 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)
|
|
// 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.
|
|
const um = seed[1]!
|
|
;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
|
|
;(um.data as Record<string, unknown>)['injected'] = 1n // would have failed validation
|
|
const logged = session.events[1]!
|
|
expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
|
|
expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
|
|
})
|
|
|
|
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
|
|
const session = new Session(SessionId('append-snapshot'))
|
|
const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
|
|
const event = session.append('user/message', data)
|
|
// Mutate the caller's object after append returns. A shared reference would
|
|
// make session.events diverge from the value that passed validation.
|
|
data.content[0]!.text = 'HACKED'
|
|
;(data as Record<string, unknown>)['injected'] = 1n
|
|
const logged = session.events[0]!
|
|
expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
|
|
expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
|
|
// The returned event carries the same snapshot, not the caller's input.
|
|
expect((event.data.content[0] as { text: string }).text).toBe('original')
|
|
})
|
|
})
|
|
|
|
|
|
describe('SessionStore', () => {
|
|
it('creates sessions, emits session/created and session/event', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
|
|
const created: Session[] = []
|
|
const events: [Session, SessionEvent][] = []
|
|
ctx.on('session/created', session => void created.push(session))
|
|
ctx.on('session/event', (session, event) => void events.push([session, event]))
|
|
|
|
const session = ctx.sessions.create()
|
|
expect(created).toEqual([session])
|
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]![0]).toBe(session)
|
|
expect(events[0]![1].type).toBe('user/message')
|
|
|
|
expect(ctx.sessions.get(session.id)).toBe(session)
|
|
expect(ctx.sessions.list()).toEqual([session])
|
|
})
|
|
|
|
it('rejects duplicate ids and supports seeding', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const a = ctx.sessions.create('fixed')
|
|
expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
|
|
|
|
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
|
const forked = ctx.sessions.create('fork', { seed: [...a.events] })
|
|
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
|
|
})
|
|
|
|
it('synthesizes a minimal v1 header for a bare-created session', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const session = ctx.sessions.create('plain')
|
|
expect(session.header).toMatchObject({ version: 1, id: 'plain' })
|
|
expect(typeof session.header.createdAt).toBe('number')
|
|
expect(session.header.cwd).toBeUndefined()
|
|
expect(session.header.parentSession).toBeUndefined()
|
|
})
|
|
|
|
it('attaches cwd and parentSession from meta to the header', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const session = ctx.sessions.create('child', {
|
|
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
|
|
})
|
|
expect(session.header).toMatchObject({
|
|
version: 1,
|
|
id: 'child',
|
|
cwd: '/work/project',
|
|
parentSession: 'parent',
|
|
})
|
|
})
|
|
|
|
it('rejects a non-absolute meta.cwd', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
|
|
.toThrow(/cwd must be an absolute path/)
|
|
// the rejected session was not registered
|
|
expect(ctx.sessions.get('rel')).toBeUndefined()
|
|
})
|
|
|
|
it('a bare Session() constructed without the store still exposes a v1 header', () => {
|
|
const session = new Session(SessionId('bare'))
|
|
expect(session.header).toMatchObject({ version: 1, id: 'bare' })
|
|
expect(typeof session.header.createdAt).toBe('number')
|
|
})
|
|
|
|
it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
|
|
let session!: Session
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
session = inner.sessions.create('scoped')
|
|
}, { inject: ['sessions'] }))
|
|
expect(ctx.sessions.get('scoped')).toBe(session)
|
|
|
|
let observed = 0
|
|
ctx.on('session/event', () => void observed++)
|
|
|
|
await fiber.dispose()
|
|
expect(ctx.sessions.get('scoped')).toBeUndefined()
|
|
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
|
|
expect(observed).toBe(0)
|
|
})
|
|
|
|
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
|
|
let threw = false
|
|
ctx.on('session/created', () => {
|
|
if (!threw) { threw = true; throw new Error('boom created listener') }
|
|
})
|
|
|
|
// The throwing emit must roll the store entry back, not leak it.
|
|
expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener')
|
|
expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked
|
|
|
|
// A subsequent create of the SAME id succeeds (the already-exists check is
|
|
// not wedged) and its onAppend is correctly wired (events observable).
|
|
const events: SessionEvent[] = []
|
|
ctx.on('session/event', (_session, event) => void events.push(event))
|
|
const session = ctx.sessions.create('fixed')
|
|
expect(ctx.sessions.get('fixed')).toBe(session)
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
|
expect(events).toHaveLength(1)
|
|
})
|
|
})
|