refactor(session): route construction through Session.create
This commit is contained in:
@@ -13,7 +13,7 @@ import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurf
|
||||
|
||||
describe('Session', () => {
|
||||
it('exposes one stable readonly surface view', () => {
|
||||
const session = new Session(SessionId('surface-view'))
|
||||
const session = Session.create(SessionId('surface-view'))
|
||||
const surface = session.surface
|
||||
|
||||
expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
|
||||
@@ -21,7 +21,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('derives message history from the event log', () => {
|
||||
const session = new Session(SessionId('s1'))
|
||||
const session = Session.create(SessionId('s1'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
|
||||
@@ -61,7 +61,7 @@ describe('Session', () => {
|
||||
it('accepts and round-trips a max-tokens turn/end reason', () => {
|
||||
// The max-tokens TurnEndReason variant carries no extra data, so it must
|
||||
// append and persist like any other reason (JSON-serializable, no fields).
|
||||
const session = new Session(SessionId('s1'))
|
||||
const session = Session.create(SessionId('s1'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('finds the latest message-turn outcome past later non-message turns', () => {
|
||||
const session = new Session(SessionId('message-turn-outcome'))
|
||||
const session = Session.create(SessionId('message-turn-outcome'))
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
@@ -108,10 +108,10 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('round-trips the coarse aborted turn outcome', () => {
|
||||
const session = new Session(SessionId('aborted'))
|
||||
const session = Session.create(SessionId('aborted'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
expect(replayed.events.slice(0, -1)).toEqual(session.events)
|
||||
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
@@ -129,12 +129,12 @@ describe('Session', () => {
|
||||
},
|
||||
] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('legacy-aborted'), legacy))
|
||||
expect(() => Session.create(SessionId('legacy-aborted'), legacy))
|
||||
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
|
||||
})
|
||||
|
||||
it('renders injected-context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
const session = Session.create(SessionId('s2'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
@@ -155,7 +155,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('keeps the exact identified context message in durable history and projection', () => {
|
||||
const session = new Session(SessionId('s2-raw'))
|
||||
const session = Session.create(SessionId('s2-raw'))
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
@@ -168,7 +168,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('replays identically from a seeded event log', () => {
|
||||
const original = new Session(SessionId('s3'))
|
||||
const original = Session.create(SessionId('s3'))
|
||||
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
@@ -186,7 +186,7 @@ describe('Session', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
const replayed = Session.create(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
// The seed verbatim, plus the end-seed event the constructor appends.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
@@ -195,16 +195,16 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('marks an explicitly empty seed without marking a fresh session', () => {
|
||||
const fresh = new Session(SessionId('fresh-empty'))
|
||||
const fresh = Session.create(SessionId('fresh-empty'))
|
||||
expect(fresh.events).toEqual([])
|
||||
|
||||
const resumed = new Session(SessionId('resumed-empty'), [])
|
||||
const resumed = Session.create(SessionId('resumed-empty'), [])
|
||||
expect(resumed.firstLiveSeq).toBe(0)
|
||||
expect(resumed.events).toMatchObject([
|
||||
{ type: 'session/end-seed', seq: 0, data: {} },
|
||||
])
|
||||
|
||||
const reopened = new Session(SessionId('reopened-empty'), resumed.events)
|
||||
const reopened = Session.create(SessionId('reopened-empty'), resumed.events)
|
||||
expect(reopened.firstLiveSeq).toBe(1)
|
||||
expect(reopened.events).toEqual(resumed.events)
|
||||
})
|
||||
@@ -214,7 +214,7 @@ describe('Session', () => {
|
||||
type: 'request/header', seq: 0, time: 1,
|
||||
data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
|
||||
} as unknown as SessionEvent
|
||||
expect(() => new Session(SessionId('old-header'), [requestHeader]))
|
||||
expect(() => Session.create(SessionId('old-header'), [requestHeader]))
|
||||
.toThrow('seed request/header at index 0 lacks provider/model')
|
||||
|
||||
const assistantMessage = {
|
||||
@@ -222,20 +222,20 @@ describe('Session', () => {
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent
|
||||
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
|
||||
expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
|
||||
.toThrow('seed assistant/message at index 0 lacks an identified message')
|
||||
|
||||
const malformedHeader = {
|
||||
type: 'request/header', seq: 0, time: 1,
|
||||
data: { header: 'old-header' },
|
||||
} as unknown as SessionEvent
|
||||
expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
|
||||
expect(() => Session.create(SessionId('malformed-header'), [malformedHeader]))
|
||||
.toThrow('seed request/header at index 0 lacks provider/model')
|
||||
|
||||
const unrelatedPrimitiveData = {
|
||||
type: 'plugin/event', seq: 0, time: 1, data: null,
|
||||
} as unknown as SessionEvent
|
||||
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
|
||||
expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
|
||||
.toEqual([unrelatedPrimitiveData])
|
||||
})
|
||||
|
||||
@@ -353,7 +353,7 @@ describe('Session', () => {
|
||||
|
||||
for (const { name, event, message } of invalid) {
|
||||
expect(
|
||||
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
|
||||
() => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
|
||||
name,
|
||||
).toThrow(message)
|
||||
}
|
||||
@@ -405,7 +405,7 @@ describe('Session', () => {
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
|
||||
expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0])
|
||||
.toEqual(valid)
|
||||
|
||||
for (const reasoningEffort of ['', 1]) {
|
||||
@@ -413,7 +413,7 @@ describe('Session', () => {
|
||||
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
||||
const config = invalid.data.header.config as unknown as Record<string, unknown>
|
||||
config.reasoningEffort = reasoningEffort
|
||||
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
|
||||
expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
|
||||
}
|
||||
})
|
||||
@@ -435,7 +435,7 @@ describe('Session', () => {
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
|
||||
expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
|
||||
|
||||
for (const adapterDefaults of [
|
||||
null,
|
||||
@@ -447,13 +447,13 @@ describe('Session', () => {
|
||||
const invalid = structuredClone(valid) as unknown as SessionEvent
|
||||
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
||||
invalid.data.header.adapterDefaults = adapterDefaults as never
|
||||
expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid]))
|
||||
expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has invalid adapterDefaults')
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
||||
const session = new Session(SessionId('s4'))
|
||||
const session = Session.create(SessionId('s4'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -487,7 +487,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
|
||||
const session = new Session(SessionId('s5'))
|
||||
const session = Session.create(SessionId('s5'))
|
||||
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
|
||||
expect(bad(1n)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
|
||||
@@ -514,7 +514,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
||||
const session = new Session(SessionId('s5b'))
|
||||
const session = Session.create(SessionId('s5b'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// A widened SessionEventType bypasses the overload's conditional requirement,
|
||||
// so the runtime guard must still reject the missing surface marker.
|
||||
@@ -528,7 +528,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('accepts dense arrays and nested plain objects', () => {
|
||||
const session = new Session(SessionId('s6'))
|
||||
const session = Session.create(SessionId('s6'))
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
@@ -539,7 +539,7 @@ describe('Session', () => {
|
||||
const badSeed = [
|
||||
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
|
||||
] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
|
||||
expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a non-contiguous seq', () => {
|
||||
@@ -547,7 +547,7 @@ describe('Session', () => {
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
|
||||
@@ -562,7 +562,7 @@ describe('Session', () => {
|
||||
}) },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
||||
expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
||||
})
|
||||
|
||||
it('accepts a well-formed contiguous serializable seed', () => {
|
||||
@@ -573,7 +573,7 @@ describe('Session', () => {
|
||||
}), surfaceOp: 'append' as const },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
const session = new Session(SessionId('seed-ok'), goodSeed)
|
||||
const session = Session.create(SessionId('seed-ok'), goodSeed)
|
||||
expect(session.events.slice(0, 3)).toEqual(goodSeed)
|
||||
expect(session.firstLiveSeq).toBe(3)
|
||||
})
|
||||
@@ -596,7 +596,7 @@ describe('Session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const session = new Session(SessionId('seed-entry-snapshot'), seed)
|
||||
const session = Session.create(SessionId('seed-entry-snapshot'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events.slice(0, 1)).toEqual([accepted])
|
||||
@@ -613,7 +613,7 @@ describe('Session', () => {
|
||||
})
|
||||
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-nested-drift'), seed)
|
||||
const session = Session.create(SessionId('seed-nested-drift'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
|
||||
@@ -630,7 +630,7 @@ describe('Session', () => {
|
||||
surfaceOp: { op: 'replace', start: 1n, end: 2 },
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
|
||||
expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
|
||||
.toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
@@ -650,7 +650,7 @@ describe('Session', () => {
|
||||
surfaceOp: new ReplaceOp(),
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
|
||||
expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
|
||||
.toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
@@ -663,7 +663,7 @@ describe('Session', () => {
|
||||
}
|
||||
const seed: SessionEvent[] = [new SeedEvent()]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
|
||||
expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
@@ -675,7 +675,7 @@ describe('Session', () => {
|
||||
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
||||
}) as unknown as SessionEvent
|
||||
|
||||
const session = new Session(SessionId('seed-null-prototype'), [event])
|
||||
const session = Session.create(SessionId('seed-null-prototype'), [event])
|
||||
|
||||
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
|
||||
})
|
||||
@@ -708,7 +708,7 @@ describe('Session', () => {
|
||||
sourceEventSeqs: [0],
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-unstable-metadata'), seed)
|
||||
const session = Session.create(SessionId('seed-unstable-metadata'), seed)
|
||||
const event = session.events[1]!
|
||||
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
|
||||
|
||||
@@ -745,7 +745,7 @@ describe('Session', () => {
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
try {
|
||||
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
.toThrow(`invalid seed event at index 1: ${expected}`)
|
||||
} finally {
|
||||
hasOwn.mockRestore()
|
||||
@@ -762,7 +762,7 @@ describe('Session', () => {
|
||||
}, surfaceOp: 'append' as const },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
const session = new Session(SessionId('seed-snapshot'), seed)
|
||||
const session = Session.create(SessionId('seed-snapshot'), seed)
|
||||
// Mutate the ORIGINAL seed objects after construction: a shared reference
|
||||
// would let this rewrite the forked log (or reintroduce non-serializable
|
||||
// data past validation). The snapshot must shield session.events.
|
||||
@@ -775,7 +775,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
|
||||
const session = new Session(SessionId('append-snapshot'))
|
||||
const session = Session.create(SessionId('append-snapshot'))
|
||||
const data = {
|
||||
id: MessageId('append-input'),
|
||||
role: 'user' as const,
|
||||
@@ -795,7 +795,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('reads a nested append-data getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-nested-drift'))
|
||||
const session = Session.create(SessionId('append-nested-drift'))
|
||||
let reads = 0
|
||||
const data = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
@@ -813,7 +813,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects non-JSON surface metadata before appending the event', () => {
|
||||
const session = new Session(SessionId('append-bad-metadata'))
|
||||
const session = Session.create(SessionId('append-bad-metadata'))
|
||||
|
||||
expect(() => session.append(
|
||||
'user/message',
|
||||
@@ -831,7 +831,7 @@ describe('Session', () => {
|
||||
readonly start = 0
|
||||
readonly end = 0
|
||||
}
|
||||
const session = new Session(SessionId('append-exotic-metadata'))
|
||||
const session = Session.create(SessionId('append-exotic-metadata'))
|
||||
|
||||
expect(() => session.append(
|
||||
'user/message',
|
||||
@@ -844,7 +844,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-unstable-metadata'))
|
||||
const session = Session.create(SessionId('append-unstable-metadata'))
|
||||
const source = session.append(
|
||||
'user/message',
|
||||
createUserMessage({
|
||||
@@ -875,7 +875,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects invalid plain surface metadata shapes at append', () => {
|
||||
const session = new Session(SessionId('append-invalid-surface-shape'))
|
||||
const session = Session.create(SessionId('append-invalid-surface-shape'))
|
||||
const appendRaw = session.append.bind(session) as unknown as (
|
||||
type: SessionEventType,
|
||||
data: unknown,
|
||||
@@ -896,7 +896,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects surface metadata on non-surface append and seed events', () => {
|
||||
const session = new Session(SessionId('non-surface-metadata'))
|
||||
const session = Session.create(SessionId('non-surface-metadata'))
|
||||
const appendRaw = session.append.bind(session) as unknown as (
|
||||
type: SessionEventType,
|
||||
data: unknown,
|
||||
@@ -908,7 +908,7 @@ describe('Session', () => {
|
||||
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
{ surfaceOp: 'append' },
|
||||
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
||||
expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
@@ -919,7 +919,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('deep-freezes seeded and appended event snapshots', () => {
|
||||
const seeded = new Session(SessionId('seed-frozen'), [{
|
||||
const seeded = Session.create(SessionId('seed-frozen'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
@@ -932,7 +932,7 @@ describe('Session', () => {
|
||||
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
|
||||
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
const appended = new Session(SessionId('append-frozen'))
|
||||
const appended = Session.create(SessionId('append-frozen'))
|
||||
const appendedEvent = appended.append('todo/write', {
|
||||
todos: [{ content: 'first', status: 'pending' }],
|
||||
})
|
||||
@@ -944,7 +944,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
||||
const session = new Session(SessionId('events-snapshot'))
|
||||
const session = Session.create(SessionId('events-snapshot'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const before = session.events
|
||||
const beforeEvent = before[0]!
|
||||
@@ -973,7 +973,7 @@ describe('Session', () => {
|
||||
seedLength: 2,
|
||||
}
|
||||
|
||||
const session = new Session(SessionId('header-owned'), undefined, input)
|
||||
const session = Session.create(SessionId('header-owned'), undefined, input)
|
||||
input.cwd = '/caller-mutated'
|
||||
|
||||
expect(session.header).toEqual({
|
||||
@@ -998,15 +998,15 @@ describe('Session', () => {
|
||||
readonly createdAt = 123
|
||||
}
|
||||
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('header-invalid'),
|
||||
createdAt: 123,
|
||||
parentSession: 1n,
|
||||
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('other'),
|
||||
createdAt: 123,
|
||||
@@ -1033,7 +1033,7 @@ describe('Session', () => {
|
||||
]
|
||||
|
||||
for (const { header, error } of cases) {
|
||||
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
|
||||
expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1057,7 +1057,7 @@ describe('Session', () => {
|
||||
]
|
||||
|
||||
for (const [index, event] of cases.entries()) {
|
||||
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
.toThrow(/invalid event envelope/)
|
||||
}
|
||||
})
|
||||
@@ -1145,7 +1145,7 @@ describe('SessionStore', () => {
|
||||
const secondCtx = new Context()
|
||||
await firstCtx.plugin(SessionStore)
|
||||
await secondCtx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('owned-key'))
|
||||
const session = Session.create(SessionId('owned-key'))
|
||||
const detachFirst = firstCtx.sessions.enter(session)
|
||||
|
||||
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
|
||||
@@ -1302,7 +1302,7 @@ describe('SessionStore', () => {
|
||||
})
|
||||
|
||||
it('a bare Session() constructed without the store still exposes a current-version header', () => {
|
||||
const session = new Session(SessionId('bare'))
|
||||
const session = Session.create(SessionId('bare'))
|
||||
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
})
|
||||
@@ -1626,7 +1626,7 @@ describe('SessionStore', () => {
|
||||
it('does not let internal dispatch replace the disposed callback tuple', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const replacement = new Session(SessionId('replacement-disposed'))
|
||||
const replacement = Session.create(SessionId('replacement-disposed'))
|
||||
const heard: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/disposed') args[0] = replacement
|
||||
@@ -1644,7 +1644,7 @@ describe('SessionStore', () => {
|
||||
|
||||
describe('todo/write event', () => {
|
||||
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
||||
const session = new Session(SessionId('t1'))
|
||||
const session = Session.create(SessionId('t1'))
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
@@ -1666,7 +1666,7 @@ describe('todo/write event', () => {
|
||||
})
|
||||
|
||||
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
||||
const session = new Session(SessionId('t2'))
|
||||
const session = Session.create(SessionId('t2'))
|
||||
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'first', status: 'completed' },
|
||||
@@ -1681,7 +1681,7 @@ describe('todo/write event', () => {
|
||||
})
|
||||
|
||||
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
|
||||
const session = new Session(SessionId('t3'))
|
||||
const session = Session.create(SessionId('t3'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -1694,12 +1694,12 @@ describe('todo/write event', () => {
|
||||
})
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
const original = new Session(SessionId('t4'))
|
||||
const original = Session.create(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Seeding a non-surface event with no surfaceOp must not throw.
|
||||
const replayed = new Session(SessionId('t4-replay'), [...original.events])
|
||||
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
|
||||
Reference in New Issue
Block a user