| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965 |
- import { describe, expect, it } from 'vitest'
- import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
- import {
- Session,
- SessionId,
- SessionLogOffset,
- SessionSeq,
- foldSurface,
- isAppendSurfaceEvent,
- isReplacementSurfaceEvent,
- isSurfaceEligibleType,
- isSurfaceEvent,
- } from '@deepseek-ai/dsh-session'
- import { SurfaceManager } from '@deepseek-ai/dsh-session/surface'
- import {
- createMessage,
- createSystemMessage,
- createToolResultMessage,
- createUserMessage,
- freezeMessage,
- ToolCallId,
- MessageId,
- } from '@deepseek-ai/dsh-llm'
- type TestSurfaceOp = 'append' | { op: 'replace'; startSeq: number; endSeq: number }
- function surfaceOp(value: TestSurfaceOp): SurfaceEvent['surfaceOp'] {
- return value === 'append'
- ? value
- : { op: 'replace', startSeq: SessionSeq(value.startSeq), endSeq: SessionSeq(value.endSeq) }
- }
- function replacementMessage(text: string) {
- return createUserMessage({
- content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: 'test' },
- })
- }
- function sourceSeqs(...values: number[]) {
- return values.map(SessionSeq)
- }
- /** Build a minimal session with turn boundaries and a single user message. */
- function surfaceSession(): Session {
- const s = Session.create(SessionId('ss'))
- s.append('turn/start', { turn: 1 })
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('assistant/message', {
- stream: [],
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'hi' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append' })
- s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- return s
- }
- function provenanceEvent(seq: SessionSeq, sourceEventSeqs: unknown): SessionEvent {
- return {
- type: 'user/message',
- seq,
- time: seq,
- data: createUserMessage({
- content: [], source: { kind: 'user' },
- }),
- surfaceOp: 'append',
- ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
- } as unknown as SessionEvent
- }
- function toolResultEvent(
- seq: SessionSeq,
- callId: string,
- op: TestSurfaceOp = 'append',
- sourceEventSeqs?: number[],
- ): SessionEvent {
- return {
- type: 'tool/result',
- seq,
- time: seq,
- data: {
- turn: 1,
- step: 1,
- message: createToolResultMessage({
- callId: ToolCallId(callId),
- content: [{ type: 'text', text: `result ${seq}` }],
- isError: false,
- }),
- },
- surfaceOp: surfaceOp(op),
- ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sourceEventSeqs) },
- }
- }
- describe('foldSurface source-event references', () => {
- it('accepts absent or valid source-event references and complete replacement coverage', () => {
- const events = [
- provenanceEvent(SessionSeq(0), undefined),
- provenanceEvent(SessionSeq(1), undefined),
- {
- ...provenanceEvent(SessionSeq(2), [0, 1]),
- surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 1 }),
- },
- ] as SessionEvent[]
- expect(() => foldSurface(events)).not.toThrow()
- })
- it('rejects source-event references on a non-surface event', () => {
- const event = {
- type: 'turn/start',
- seq: SessionSeq(0),
- time: 1,
- data: { turn: 1 },
- sourceEventSeqs: sourceSeqs(0),
- } as unknown as SessionEvent
- expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
- })
- it('rejects obsolete source-event provenance on an assistant message', () => {
- const event = {
- type: 'assistant/message',
- seq: SessionSeq(0),
- time: 0,
- data: {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- stream: [],
- },
- surfaceOp: 'append',
- sourceEventSeqs: [],
- } as unknown as SessionEvent
- expect(() => foldSurface([event])).toThrow(/embeds its source stream/)
- })
- it.each([
- ['a non-array', [{ ...provenanceEvent(SessionSeq(0), undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
- ['an empty array', [provenanceEvent(SessionSeq(0), [])], /must not be empty/],
- ['duplicates', [provenanceEvent(SessionSeq(0), undefined), provenanceEvent(SessionSeq(1), [0, 0])], /must not contain duplicates/],
- ['a sparse array', [provenanceEvent(SessionSeq(0), Array<number>(1))], /densely contain/],
- ['a non-number', [{ ...provenanceEvent(SessionSeq(0), undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
- ['a fractional number', [provenanceEvent(SessionSeq(0), [0.5])], /non-negative safe integers/],
- ['a negative number', [provenanceEvent(SessionSeq(0), [-1])], /non-negative safe integers/],
- ['a self reference', [provenanceEvent(SessionSeq(0), [0])], /must reference earlier events/],
- ['a non-contiguous event seq', [provenanceEvent(SessionSeq(0), undefined), provenanceEvent(SessionSeq(2), [1])], /seq 2 is not contiguous; expected 1/],
- ['incomplete replacement coverage', [
- provenanceEvent(SessionSeq(0), undefined),
- provenanceEvent(SessionSeq(1), undefined),
- { ...provenanceEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', startSeq: 0, endSeq: 1 } },
- ], /missing 1/],
- ] as const)(
- 'rejects %s',
- (_name, events, expected) => {
- expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected)
- },
- )
- })
- describe('foldSurface tool-result rewrites', () => {
- it('rejects a replacement spanning multiple current nodes', () => {
- const events = [
- provenanceEvent(SessionSeq(0), undefined),
- provenanceEvent(SessionSeq(1), undefined),
- toolResultEvent(SessionSeq(2), 'rewrite', { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
- ]
- expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
- })
- it('rejects a replacement targeting a non-result node', () => {
- const events = [
- provenanceEvent(SessionSeq(0), undefined),
- toolResultEvent(SessionSeq(1), 'rewrite', { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ]
- expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
- })
- it('rejects changes outside tool-result content', () => {
- const events = [
- toolResultEvent(SessionSeq(0), 'original'),
- toolResultEvent(SessionSeq(1), 'changed', { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ]
- expect(() => foldSurface(events)).toThrow(/may change only content/)
- })
- it.each([
- ['toolCallId', { toolCallId: ToolCallId('changed') }],
- ['isError', { isError: true }],
- ] as const)('rejects a replacement that changes the result block %s', (_field, patch) => {
- const original = toolResultEvent(SessionSeq(0), 'original')
- const data = original.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
- const result = data.message.content[0]
- const replacement = {
- ...original,
- seq: SessionSeq(1),
- time: 1,
- data: {
- ...data,
- message: freezeMessage({
- ...data.message,
- content: [{ ...result, ...patch }] as [typeof result],
- }),
- },
- surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
- sourceEventSeqs: sourceSeqs(0),
- } as SessionEvent
- expect(() => foldSurface([original, replacement])).toThrow(/may change only content/)
- })
- it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
- const withMeta = (seq: SessionSeq, meta: unknown, op: TestSurfaceOp = 'append', sourceEventSeqs?: number[]): SessionEvent => {
- const event = toolResultEvent(SessionSeq(seq), 'c-meta', op, sourceEventSeqs)
- const data = event.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
- return {
- ...event,
- data: {
- ...data,
- message: freezeMessage({ ...data.message, id: MessageId('meta-message') }),
- meta,
- },
- } as SessionEvent
- }
- // Structurally equal arrays (fresh references) pass the rest-field equality.
- expect(() => foldSurface([
- withMeta(SessionSeq(0), { tags: ['a', { n: 1 }] }),
- withMeta(SessionSeq(1), { tags: ['a', { n: 1 }] }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ])).not.toThrow()
- // Same length, drifted element: the array branch must reject.
- expect(() => foldSurface([
- withMeta(SessionSeq(0), { tags: ['a'] }),
- withMeta(SessionSeq(1), { tags: ['b'] }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ])).toThrow(/may change only content/)
- // Array vs non-array on one side: the mixed-shape guard rejects.
- expect(() => foldSurface([
- withMeta(SessionSeq(0), { tags: ['a'] }),
- withMeta(SessionSeq(1), { tags: 'a' }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ])).toThrow(/may change only content/)
- // Same key count, different key names: the hasOwn branch rejects.
- expect(() => foldSurface([
- withMeta(SessionSeq(0), { left: 1 }),
- withMeta(SessionSeq(1), { right: 1 }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ])).toThrow(/may change only content/)
- // Different key counts: the key-length branch rejects.
- expect(() => foldSurface([
- withMeta(SessionSeq(0), { one: 1 }),
- withMeta(SessionSeq(1), { one: 1, two: 2 }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ])).toThrow(/may change only content/)
- })
- })
- describe('SurfaceManager', () => {
- it('folds a contiguous window without materializing earlier event sequences', () => {
- const baseSeq = 400_000
- const events = [
- provenanceEvent(SessionSeq(baseSeq), undefined),
- provenanceEvent(SessionSeq(baseSeq + 1), undefined),
- {
- ...provenanceEvent(SessionSeq(baseSeq + 2), [baseSeq]),
- surfaceOp: surfaceOp({ op: 'replace', startSeq: baseSeq, endSeq: baseSeq }),
- },
- ] as SessionEvent[]
- const surface = new SurfaceManager(events, SessionLogOffset(baseSeq))
- expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1])
- expect(surface.replaceGeneration).toBe(1)
- })
- it('validates tool-result rewrites against a nonzero window offset', () => {
- const baseSeq = 400_000
- const original = toolResultEvent(SessionSeq(baseSeq), 'call')
- const events: SessionEvent[] = [
- original,
- {
- ...original,
- seq: SessionSeq(baseSeq + 1),
- time: baseSeq + 1,
- surfaceOp: surfaceOp({ op: 'replace', startSeq: baseSeq, endSeq: baseSeq }),
- sourceEventSeqs: sourceSeqs(baseSeq),
- } as SessionEvent,
- ]
- expect(new SurfaceManager(events, SessionLogOffset(baseSeq)).nodes).toEqual([baseSeq + 1])
- })
- it('rejects a replacement that crosses a loaded window head', () => {
- const baseSeq = 400_000
- const events = [
- provenanceEvent(SessionSeq(baseSeq), undefined),
- {
- ...provenanceEvent(SessionSeq(baseSeq + 1), [baseSeq - 1, baseSeq]),
- surfaceOp: surfaceOp({ op: 'replace', startSeq: baseSeq - 1, endSeq: baseSeq }),
- },
- ] as SessionEvent[]
- expect(() => new SurfaceManager(events, SessionLogOffset(baseSeq)).nodes)
- .toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`)
- })
- it('shares ordered entries and nested replacement ranges with foldSurface', () => {
- const s = Session.create(SessionId('shared-fold'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('user/message', replacementMessage('summary'), {
- surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
- sourceEventSeqs: sourceSeqs(0),
- })
- s.append('user/message', replacementMessage('summary 2'), {
- surfaceOp: surfaceOp({ op: 'replace', startSeq: 2, endSeq: 1 }),
- sourceEventSeqs: sourceSeqs(2, 1),
- })
- const folded = foldSurface(s.snapshotEvents())
- expect(folded.nodes).toEqual(s.surface.nodes)
- expect(folded.replacements).toEqual([
- { seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
- { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
- ])
- folded.nodes[0] = SessionSeq(99)
- folded.replacements[0]!.shadowedSeqs.push(SessionSeq(99))
- expect(s.surface.nodes).toEqual([3])
- expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3])
- expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0])
- })
- it('does not retain fold-only replacement history in incremental state', () => {
- const s = Session.create(SessionId('incremental-state'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('user/message', replacementMessage('b'), {
- surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
- sourceEventSeqs: sourceSeqs(0),
- })
- expect(s.surface.nodes).toEqual([1])
- const manager = s.surface as unknown as { _state: object }
- expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
- expect(foldSurface(s.snapshotEvents()).replacements).toEqual([
- { seq: 1, start: 0, end: 0, shadowedSeqs: [0] },
- ])
- })
- it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
- const events = [
- provenanceEvent(SessionSeq(0), undefined),
- { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } },
- { ...provenanceEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', startSeq: 1, endSeq: 0 } },
- ] as SessionEvent[]
- expect(() => foldSurface(events)).toThrow(/start seq 1 not found/)
- expect(() => Session.create(SessionId('shared-fold-invalid'), events))
- .toThrow(/start seq 1 not found/)
- })
- it('rejects negative-zero replacement event sequences', () => {
- const event = {
- ...provenanceEvent(SessionSeq(0), [0]),
- surfaceOp: { op: 'replace', startSeq: -0, endSeq: -0 },
- } as SessionEvent
- expect(() => foldSurface([event])).toThrow(/invalid replace surfaceOp/)
- })
- it('leaves incremental state unchanged when candidate validation fails', () => {
- const s = Session.create(SessionId('atomic-validation'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- const surface = s.surface
- const nodes = surface.nodes
- expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes)
- expect(surface.replaceGeneration).toBe(0)
- expect(() => s.append(
- 'assistant/message',
- {
- stream: [],
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'invalid' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }) },
- )).toThrow(/missing 0/)
- expect(s.snapshotEvents()).toHaveLength(1)
- expect(s.surface).toBe(surface)
- expect(surface.nodes).toEqual([0])
- expect(surface.replaceGeneration).toBe(0)
- expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes)
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- expect(surface.nodes).toBe(nodes)
- expect(surface.nodes).toEqual([0, 1])
- expect(surface.replaceGeneration).toBe(0)
- expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes)
- })
- it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
- const malformed = {
- type: 'user/message',
- seq: SessionSeq(0),
- time: 1,
- data: createUserMessage({
- content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' },
- }),
- } as unknown as SessionEvent
- expect(() => foldSurface([malformed]))
- .toThrow(/surface-eligible and requires a surfaceOp marker/)
- })
- it('foldSurface rejects surfaceOp on a non-surface event', () => {
- const malformed = {
- type: 'turn/start',
- seq: SessionSeq(0),
- time: 1,
- data: { turn: 1 },
- surfaceOp: 'append',
- } as unknown as SessionEvent
- expect(() => foldSurface([malformed]))
- .toThrow(/not surface-eligible and cannot carry surfaceOp/)
- })
- it('folds an ordered sequence list from surfaceOp: append markers', () => {
- const s = surfaceSession()
- const nodes = s.surface.nodes
- // Only the user/message and assistant/message carry surfaceOp: 'append'.
- // The turn boundaries do not have surface markers.
- expect(nodes).toEqual([1, 2])
- })
- it('empty surface yields empty nodes', () => {
- const s = Session.create(SessionId('empty'))
- s.append('turn/start', { turn: 1 })
- s.append('step/start', { turn: 1, step: 1 })
- s.append('step/end', { turn: 1, step: 1 })
- s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- expect(s.surface.nodes.length).toBe(0)
- expect(s.deriveMessages()).toEqual([])
- })
- it('picks up new events incrementally (delta processing)', () => {
- const s = surfaceSession()
- expect(s.surface.nodes.length).toBe(2)
- s.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: ToolCallId('c1'),
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- }),
- }, { surfaceOp: 'append' })
- expect(s.surface.nodes.length).toBe(3)
- expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
- })
- it('replays identically from a seeded log with surface markers', () => {
- const original = surfaceSession()
- original.append('tool/result', {
- turn: 1, step: 1,
- message: createToolResultMessage({
- callId: ToolCallId('c1'),
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- }),
- }, { surfaceOp: 'append' })
- const replayed = Session.create(SessionId('replay'), original.snapshotEvents())
- expect(replayed.surface.nodes).toEqual([1, 2, 4])
- expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
- })
- it('rebuild with replace operation splices out shadowed nodes', () => {
- const s = surfaceSession()
- s.append('user/message',
- replacementMessage('summary'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) },
- )
- expect(s.surface.nodes).toEqual([4])
- })
- it('replace with both ends at real nodes splices only the range', () => {
- const s = Session.create(SessionId('range'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 0
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 1
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 2
- // Replace seq 0 through 1 inclusive: shadow a and b, keep c.
- s.append('user/message',
- replacementMessage('summary'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 1 }), sourceEventSeqs: sourceSeqs(0, 1) },
- ) // seq 3
- expect(s.surface.nodes).toEqual([3, 2])
- })
- it('single-node replacement (start === end)', () => {
- const s = Session.create(SessionId('single'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 0
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 1
- // Replace only seq 1 (single node).
- s.append('user/message',
- replacementMessage('x'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 1 }), sourceEventSeqs: sourceSeqs(1) },
- ) // seq 2
- expect(s.surface.nodes).toEqual([0, 2])
- })
- it('throws when replace start is not found', () => {
- const s = Session.create(SessionId('bad-start'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 0
- s.append('turn/start', { turn: 1 })
- expect(() => s.append('user/message',
- replacementMessage('y'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 0 }), sourceEventSeqs: sourceSeqs(0) },
- )).toThrow(/surface replace: start seq 1 not found/)
- })
- it('throws when replace end is not found', () => {
- const s = Session.create(SessionId('bad-end'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 0
- s.append('turn/start', { turn: 1 })
- expect(() => s.append('user/message',
- replacementMessage('y'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 1 }), sourceEventSeqs: sourceSeqs(0) },
- )).toThrow(/surface replace: end seq 1 not found/)
- })
- it('throws when start is after end', () => {
- const s = Session.create(SessionId('reversed'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 0
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 1
- // start=1, end=0 would be reversed order.
- expect(() => s.append('user/message',
- replacementMessage('y'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 0 }), sourceEventSeqs: sourceSeqs(1, 0) },
- )).toThrow(/start seq 1.*after end seq 0/)
- })
- it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
- const s = Session.create(SessionId('immutable'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- const sources = sourceSeqs(0)
- s.append('user/message', replacementMessage('h'), {
- surfaceOp: 'append',
- sourceEventSeqs: sources,
- })
- // Mutate caller's array after append.
- sources.push(SessionSeq(1))
- sources[0] = SessionSeq(99)
- const logged = s.snapshotEvents()[1]! as SurfaceEvent
- expect(logged.sourceEventSeqs).toEqual([0])
- })
- it('replace starting at non-head position preserves surrounding order', () => {
- const s = Session.create(SessionId('mid-replace'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 0
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 1
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }) // seq 2
- // Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
- s.append('user/message',
- replacementMessage('x'),
- { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 1 }), sourceEventSeqs: sourceSeqs(1) },
- ) // seq 3
- expect(s.surface.nodes).toEqual([0, 3, 2])
- })
- it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
- const s = Session.create(SessionId('immutable-op'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- const op = { op: 'replace' as const, startSeq: SessionSeq(0), endSeq: SessionSeq(0) }
- s.append('user/message', replacementMessage('s'), {
- surfaceOp: op,
- sourceEventSeqs: sourceSeqs(0),
- })
- // Mutate caller's object after append.
- op.startSeq = SessionSeq(99)
- const logged = s.snapshotEvents()[1]! as SurfaceEvent
- expect(logged.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 })
- })
- })
- describe('deriveMessages with surface', () => {
- it('uses the surface path when surface markers are present', () => {
- const s = surfaceSession()
- const messages = s.deriveMessages()
- expect(messages).toHaveLength(2)
- expect(messages[0]!.role).toBe('user')
- expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
- expect(messages[1]!.role).toBe('assistant')
- expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
- })
- it('surface path skips non-surface events (attempts, boundaries)', () => {
- const s = Session.create(SessionId('filter'))
- s.append('turn/start', { turn: 1 })
- s.append('assistant/attempt', {
- turn: 1, step: 1,
- stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['h', 'i'] }],
- })
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('assistant/message', {
- stream: [],
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'hi' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append' })
- s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- // Attempts and boundaries are NOT in the surface, so only 2 messages.
- expect(s.deriveMessages()).toHaveLength(2)
- })
- it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
- const s = Session.create(SessionId('compacted'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('user/message', replacementMessage('compacted'), {
- surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
- sourceEventSeqs: sourceSeqs(0),
- })
- // Only the compaction node is visible.
- const messages = s.deriveMessages()
- expect(messages).toHaveLength(1)
- expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
- })
- it('injected-context and user messages appear on surface', () => {
- const s = Session.create(SessionId('ctx'))
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
- }), { surfaceOp: 'append' })
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'focus' }],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- const messages = s.deriveMessages()
- expect(messages).toHaveLength(2)
- expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
- expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
- })
- })
- describe('Session.append surface opts', () => {
- it('records sourceEventSeqs and surfaceOp on a source-derived event', () => {
- const s = Session.create(SessionId('opts'))
- s.append('turn/start', { turn: 1 })
- s.append('step/start', { turn: 1, step: 1 })
- const event = s.append(
- 'user/message',
- createUserMessage({
- content: [{ type: 'text', text: 'h' }],
- source: { kind: 'plugin', plugin: 'test' },
- }),
- { surfaceOp: 'append', sourceEventSeqs: sourceSeqs(0, 1) },
- )
- expect(event.sourceEventSeqs).toEqual([0, 1])
- expect(event.surfaceOp).toBe('append')
- // The logged event matches the returned event.
- expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
- expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append')
- })
- it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
- // An empty-content assistant/message is surface-eligible (it can host usage)
- // but _deriveOneMessage returns null for it, so the surface derivation path's
- // null-check is exercised — the node is on the surface yet produces no message.
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } },
- { type: 'assistant/message', seq: SessionSeq(2), time: 3, data: {
- stream: [],
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, surfaceOp: 'append' },
- { type: 'step/end', seq: SessionSeq(3), time: 4, data: { turn: 1, step: 1 } },
- { type: 'turn/end', seq: SessionSeq(4), time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- 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 = Session.create(SessionId('noopts'))
- s.append('turn/start', { turn: 1 })
- expect((s.snapshotEvents()[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
- expect((s.snapshotEvents()[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
- })
- it('surfaceOp primitives are not cloned (they are immutable)', () => {
- const s = Session.create(SessionId('prim'))
- const event = s.append('assistant/message', {
- stream: [],
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append' })
- // The string 'append' is a primitive — identity-preserving is fine.
- expect(event.surfaceOp).toBe('append')
- })
- it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
- // A raw event (not built via append, which mandates the marker) of a
- // surface-eligible type but with no surfaceOp must NOT narrow to a
- // SurfaceEvent — it would otherwise be silently dropped from the surface.
- const noMarker = {
- type: 'user/message', seq: SessionSeq(0), time: 1,
- data: createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }),
- } as unknown as SessionEvent
- expect(isSurfaceEvent(noMarker)).toBe(false)
- // A non-surface type is rejected too (the type gate).
- const boundary: SessionEvent = { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }
- expect(isSurfaceEvent(boundary)).toBe(false)
- // A properly-marked surface event narrows.
- const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
- expect(isSurfaceEvent(marked)).toBe(true)
- })
- })
- describe('surface type guards', () => {
- it('isSurfaceEligibleType is true only for message-producing types', () => {
- expect(isSurfaceEligibleType('user/message')).toBe(true)
- expect(isSurfaceEligibleType('assistant/message')).toBe(true)
- expect(isSurfaceEligibleType('tool/result')).toBe(true)
- expect(isSurfaceEligibleType('turn/start')).toBe(false)
- expect(isSurfaceEligibleType('assistant/attempt')).toBe(false)
- })
- it('isSurfaceEvent narrows a fully-formed surface event', () => {
- const s = surfaceSession()
- const userMessage = s.snapshotEvents().find(e => e.type === 'user/message')!
- expect(isSurfaceEvent(userMessage)).toBe(true)
- })
- it('isSurfaceEvent rejects a non-surface-eligible type', () => {
- const s = surfaceSession()
- const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')!
- expect(isSurfaceEvent(turnStart)).toBe(false)
- })
- it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
- // A seed/load record may lack its required marker before validation.
- const markerless = {
- type: 'user/message',
- seq: SessionSeq(0),
- time: 0,
- data: createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }),
- } as unknown as SessionEvent
- expect(isSurfaceEligibleType(markerless.type)).toBe(true)
- expect(isSurfaceEvent(markerless)).toBe(false)
- })
- it('splits surface events into append-origin and replacement by their marker', () => {
- const s = surfaceSession()
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
- }), { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) })
- const appended = s.snapshotEvents().find(e => e.type === 'user/message')!
- const replacement = s.snapshotEvents().at(-1)!
- expect(isAppendSurfaceEvent(appended)).toBe(true)
- expect(isReplacementSurfaceEvent(appended)).toBe(false)
- expect(isAppendSurfaceEvent(replacement)).toBe(false)
- expect(isReplacementSurfaceEvent(replacement)).toBe(true)
- })
- it('rejects log-only and markerless events from both marker guards', () => {
- const s = surfaceSession()
- const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')!
- // A surface-eligible type whose mandatory marker is absent has no origin at
- // all: it never entered the surface.
- const markerless = {
- type: 'user/message',
- seq: SessionSeq(0),
- time: 0,
- data: createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }),
- } as unknown as SessionEvent
- expect(isAppendSurfaceEvent(turnStart)).toBe(false)
- expect(isReplacementSurfaceEvent(turnStart)).toBe(false)
- expect(isAppendSurfaceEvent(markerless)).toBe(false)
- expect(isReplacementSurfaceEvent(markerless)).toBe(false)
- })
- })
- describe('SurfaceManager.replaceGeneration', () => {
- it('folds the pending log delta on access and counts replaces', () => {
- const s = Session.create(SessionId('gen'))
- s.append('turn/start', { turn: 1 })
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'two' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- // Read the generation FIRST — before nodes — so the getter itself folds
- // the pending delta rather than piggybacking on a nodes read.
- expect(s.surface.replaceGeneration).toBe(0)
- const nodes = s.surface.nodes
- s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
- }), { surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
- expect(s.surface.replaceGeneration).toBe(1)
- })
- })
- describe('system/message surface node', () => {
- function systemEvent(seq: number, text: string, op: TestSurfaceOp = 'append', sources?: number[]): SessionEvent {
- return {
- type: 'system/message',
- seq: SessionSeq(seq),
- time: seq,
- data: { turn: 1, step: 1, message: createSystemMessage(text, 'test-plugin') },
- surfaceOp: surfaceOp(op),
- ...sources === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sources) },
- }
- }
- function userEvent(seq: number, op: TestSurfaceOp = 'append', sources?: number[]): SessionEvent {
- return {
- type: 'user/message',
- seq: SessionSeq(seq),
- time: seq,
- data: createUserMessage({ content: [{ type: 'text', text: `u${seq}` }], source: { kind: 'user' } }),
- surfaceOp: surfaceOp(op),
- ...sources === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sources) },
- }
- }
- it('projects a system node as the leading system-role message and an empty one as no message', () => {
- const s = Session.create(SessionId('sys'))
- s.append('turn/start', { turn: 1 })
- s.append('step/start', { turn: 1, step: 1 })
- s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('be brief', 'p') }, { surfaceOp: 'append' })
- s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
- expect(s.deriveMessages().map(message => message.role)).toEqual(['system', 'user'])
- expect(s.deriveMessages()[0]?.content).toEqual([{ type: 'text', text: 'be brief' }])
- const head = s.surface.nodes[0] as SessionSeq
- s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('', 'p') }, {
- surfaceOp: { op: 'replace', startSeq: head, endSeq: head },
- sourceEventSeqs: [head],
- })
- expect(s.deriveMessages().map(message => message.role)).toEqual(['user'])
- expect(s.surface.nodes).toHaveLength(2)
- })
- it('replaces node 0 with a new system node and rejects other rewrites of the head', () => {
- const replaced = foldSurface([
- systemEvent(0, 'v1'), userEvent(1), systemEvent(2, 'v2', { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
- ])
- expect(replaced.nodes).toEqual(sourceSeqs(2, 1))
- expect(() => foldSurface([
- systemEvent(0, 'v1'), userEvent(1),
- userEvent(2, { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
- ])).toThrow(/node 0 holds the system prompt/)
- expect(() => foldSurface([
- systemEvent(0, 'v1'), userEvent(1), systemEvent(2, 'v2', { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
- ])).toThrow(/node 0 holds the system prompt/)
- })
- it('leaves later system nodes and a non-system head unprotected', () => {
- const later = foldSurface([
- systemEvent(0, 'v1'), userEvent(1), systemEvent(2, 'v2'), userEvent(3),
- userEvent(4, { op: 'replace', startSeq: 1, endSeq: 3 }, [1, 2, 3]),
- ])
- expect(later.nodes).toEqual(sourceSeqs(0, 4))
- const plainHead = foldSurface([
- userEvent(0), userEvent(1),
- userEvent(2, { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
- ])
- expect(plainHead.nodes).toEqual(sourceSeqs(2))
- })
- it('rejects a seeded system/message with a non-system role or non-plugin source', () => {
- const good = systemEvent(0, 'v1')
- const badRole = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, role: 'user' } } }
- expect(() => Session.create(SessionId('bad-role'), [badRole as SessionEvent])).toThrow(/role "system"/)
- const badSource = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, source: { kind: 'user' } } } }
- expect(() => Session.create(SessionId('bad-source'), [badSource as SessionEvent])).toThrow(/plugin source/)
- })
- })
|