Add a `max-tokens` variant to `TurnEndReasonMap` and carry the model finish reason up from `runStep` to `runTurn`, applying the rule "any max-tokens step in the turn surfaces as max-tokens" (disposed/aborted/ error still take precedence). This lets consumers distinguish a clean stop from a truncated one — the contract RFC 010's ACP bridge maps to the `max_tokens` stop reason. Also add an AGENTS.md rule: write an ADR when (and only when) a PR makes a durable, contested, surprising decision.
305 lines
15 KiB
TypeScript
305 lines
15 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('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' },
|
|
})
|
|
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)
|
|
})
|
|
})
|