mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
438 lines
23 KiB
TypeScript
438 lines
23 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { SessionEventType, TodoItem } 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' } }, { surfaceOp: 'append' })
|
|
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
|
session.append('assistant/message', {
|
|
turn: 1, step: 1,
|
|
content: [
|
|
{ type: 'text', text: 'let me check' },
|
|
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
|
|
],
|
|
}, { surfaceOp: 'append' })
|
|
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
const messages = session.deriveMessages()
|
|
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('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'))
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
|
|
|
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
|
|
expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
|
|
// survives a structuredClone (the persistence-serialization boundary)
|
|
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
|
})
|
|
|
|
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' },
|
|
}, { surfaceOp: 'append' })
|
|
session.append('steering/message', {
|
|
turn: 1,
|
|
content: [{ type: 'text', text: 'focus on tests' }],
|
|
source: { kind: 'user' },
|
|
}, { surfaceOp: 'append' })
|
|
|
|
const [contextMessage, steeringMessage] = session.deriveMessages()
|
|
expect(contextMessage!.role).toBe('user')
|
|
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('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
|
|
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
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' } }, { surfaceOp: 'append' })
|
|
session.append('tool/result', {
|
|
turn: 1, step: 1, callId: CallId('c1'),
|
|
content: [{ type: 'text', text: 'tool out' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
const before = structuredClone(session.events)
|
|
|
|
// A misbehaving consumer tries to mutate the messages it was handed.
|
|
// Derived messages are frozen shared projections (cloned once off the
|
|
// log, then deep-frozen): every mutation attempt THROWS in strict mode —
|
|
// isolation by unrepresentability, not by per-call cloning.
|
|
const messages = session.deriveMessages()
|
|
const userBlock = messages[0]!.content[0]!
|
|
expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
|
|
const toolBlock = messages[1]!.content[0]!
|
|
expect(() => {
|
|
if (toolBlock.type === 'tool-result') toolBlock.content.push({ type: 'text', text: 'injected' })
|
|
}).toThrow(TypeError)
|
|
expect(() => { messages[0]!.content.push({ type: 'text', text: 'extra' }) }).toThrow(TypeError)
|
|
// The returned ARRAY is the caller's own snapshot, though — reordering it
|
|
// is the caller's business and never reaches the cache or the log.
|
|
messages.reverse()
|
|
|
|
// The log is unchanged: deep-equal to the snapshot taken before mutation.
|
|
expect(session.events).toEqual(before)
|
|
// And a fresh derivation still reflects the original content and order.
|
|
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, { surfaceOp: 'append' })
|
|
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('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
|
const session = new Session(SessionId('s5b'))
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
// The typed overload makes surfaceOp mandatory only when the type argument is
|
|
// a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it
|
|
// to the SessionEventType union, where the conditional rest collapses to
|
|
// optional — the exact shape `for (const e of log) append(e.type, e.data)`
|
|
// produces. Reproduce that here and assert the runtime guard rejects it.
|
|
const widenedType = 'user/message' as SessionEventType
|
|
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
|
.toThrow(/surface-eligible and requires a surfaceOp marker/)
|
|
// The rejected append never entered the log (only turn/start is present).
|
|
expect(session.events).toHaveLength(1)
|
|
})
|
|
|
|
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, { surfaceOp: 'append' })).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('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
|
|
// A surface-eligible event (user/message) with no surfaceOp would load fine
|
|
// but vanish from deriveMessages() (the surface is the sole derivation path),
|
|
// so a resume/fork would silently lose history. append() forbids this at
|
|
// compile time; a raw seed must be rejected at runtime to match.
|
|
const markerlessSeed = [
|
|
{ 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[]
|
|
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/surface-eligible but carries no surfaceOp/)
|
|
})
|
|
|
|
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 } }, 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)
|
|
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 } }, 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)
|
|
// 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, { surfaceOp: 'append' })
|
|
// Mutate the caller's object after append returns. A shared reference would
|
|
// make session.events diverge from the value that passed validation.
|
|
data.content[0]!.text = 'HACKED'
|
|
;(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' } }, { surfaceOp: 'append' })
|
|
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(SessionId('fixed'))
|
|
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
|
|
|
|
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
|
|
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
|
|
})
|
|
|
|
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
|
|
// prepare()/enter() are public cross-package primitives that a caller may
|
|
// separate with arbitrary work. A stale prepared session must NOT overwrite
|
|
// a live store entry of the same id — its detach disposer would later delete
|
|
// the REAL session, breaking the store-uniqueness invariant.
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const stale = ctx.sessions.prepare(SessionId('racy'))
|
|
const live = ctx.sessions.create(SessionId('racy'))
|
|
expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
|
|
// The live session is intact and still the store entry.
|
|
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
|
|
})
|
|
|
|
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const created: Session[] = []
|
|
ctx.on('session/created', session => void created.push(session))
|
|
|
|
const session = ctx.sessions.prepare(SessionId('lifecycle'))
|
|
// prepare alone does NOT enter the store.
|
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
|
const detach = ctx.sessions.enter(session)
|
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
|
|
// enter does NOT announce.
|
|
expect(created).toEqual([])
|
|
ctx.sessions.announce(session)
|
|
expect(created).toEqual([session])
|
|
// The detach disposer removes the entry + stops notification.
|
|
detach()
|
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
|
})
|
|
|
|
it('synthesizes a minimal current-version header for a bare-created session', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const session = ctx.sessions.create(SessionId('plain'))
|
|
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, 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(SessionId('child'), {
|
|
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
|
|
})
|
|
expect(session.header).toMatchObject({
|
|
version: SESSION_FORMAT_VERSION,
|
|
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(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
|
|
.toThrow(/cwd must be an absolute path/)
|
|
// the rejected session was not registered
|
|
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
|
|
})
|
|
|
|
it('a bare Session() constructed without the store still exposes a current-version header', () => {
|
|
const session = new Session(SessionId('bare'))
|
|
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, 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(SessionId('scoped'))
|
|
}, { inject: ['sessions'] }))
|
|
expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
|
|
|
|
let observed = 0
|
|
ctx.on('session/event', () => void observed++)
|
|
|
|
await fiber.dispose()
|
|
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
|
|
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
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(SessionId('fixed'))).toThrow('boom created listener')
|
|
expect(ctx.sessions.get(SessionId('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(SessionId('fixed'))
|
|
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
expect(events).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('todo/write event', () => {
|
|
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
|
const session = new Session(SessionId('t1'))
|
|
const todos: TodoItem[] = [
|
|
{ content: 'plan the work', status: 'in_progress' },
|
|
{ content: 'write the code', status: 'pending' },
|
|
]
|
|
session.append('todo/write', { todos })
|
|
|
|
const event = session.events.findLast(e => e.type === 'todo/write')!
|
|
expect(event.type).toBe('todo/write')
|
|
expect(event.data.todos).toEqual(todos)
|
|
|
|
// The append snapshots its input: mutating the caller's array afterward must
|
|
// not change what the log holds (the durable-source-of-truth contract).
|
|
todos.push({ content: 'sneak in', status: 'pending' })
|
|
todos[0]!.status = 'completed'
|
|
expect(event.data.todos).toEqual([
|
|
{ content: 'plan the work', status: 'in_progress' },
|
|
{ content: 'write the code', status: 'pending' },
|
|
])
|
|
})
|
|
|
|
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
|
const session = new Session(SessionId('t2'))
|
|
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
|
session.append('todo/write', { todos: [
|
|
{ content: 'first', status: 'completed' },
|
|
{ content: 'second', status: 'in_progress' },
|
|
] })
|
|
|
|
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
|
|
expect(current).toEqual([
|
|
{ content: 'first', status: 'completed' },
|
|
{ content: 'second', status: 'in_progress' },
|
|
])
|
|
})
|
|
|
|
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
|
|
const session = new Session(SessionId('t3'))
|
|
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
const before = session.deriveMessages().length
|
|
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
|
|
// The todo event must not add a message to the derived history…
|
|
expect(session.deriveMessages()).toHaveLength(before)
|
|
// …and must not appear on the surface linked list.
|
|
expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false)
|
|
})
|
|
|
|
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
|
const original = new Session(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])
|
|
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
|
.toEqual([{ content: 'only', status: 'completed' }])
|
|
expect(replayed.seq).toBe(original.seq)
|
|
})
|
|
})
|