| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482 |
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- /**
- * Shared write-path orchestration contract for backends using {@link PersistenceCoordinator}.
- * Unlike the public storage-semantics suite in `contract.ts`, it covers SessionStore event wiring,
- * lazy creation, fork seed persistence, four adoption/collision cases, crash-tail repair, reload,
- * flush, and disposal quiescence through public APIs rather than storage primitives.
- *
- * Each real backend supplies a shared storage scope and optional torn-tail injector; backend specs
- * retain only storage-mechanics tests, while these scenarios run once per backend.
- * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract
- */
- import { describe, expect, it, vi } from 'vitest'
- import { Context, type Fiber } from '@deepseek-ai/cordis'
- import { scopeTarget } from '@deepseek-ai/dsh-scope'
- import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
- import { meta, oneTurnLog, appendLog } from './contract.ts'
- /**
- * The backend-specific capabilities the orchestration suite needs beyond the
- * public service API. A fresh fixture is created per test (isolated storage);
- * the suite mounts/disposes backend instances on it and cleans it up at the end.
- */
- export interface CoordinatorFixture {
- /** Mount the real backend through `ctx.plugin` over shared storage and return only that fiber. */
- mount: (ctx: Context) => Promise<Fiber>
- /**
- * Inject a never-committed partial record after the durable region so `loadCore` reaches
- * `commitRepair`. Omit only when the backend structurally cannot produce torn tails.
- */
- corruptTail?: (id: SessionId, cwd: string | undefined) => Promise<void>
- /** Tear down the storage scope (remove the temp dir / file). */
- cleanup: () => Promise<void>
- }
- /** A constant absolute cwd; jsonl keys directories off it, memory/sqlite ignore it. */
- const WORK = '/w'
- const OTHER = '/other'
- /** Append a whole event log to a live session, event by event (drives session/event). */
- function send(session: Session, events: readonly SessionEvent[]): void {
- appendLog(session, events)
- }
- /** A valid persisted log from immediately before messages gained wrappers and identities. */
- function legacyMessageLog(): SessionEvent[] {
- return [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- {
- type: 'user/message',
- seq: 1,
- time: 2,
- data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
- surfaceOp: 'append',
- },
- { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
- {
- type: 'assistant/message',
- seq: 3,
- time: 4,
- data: {
- turn: 1,
- step: 1,
- content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
- provenance: { provider: 'mock', model: 'mock' },
- },
- surfaceOp: 'append',
- },
- {
- type: 'tool/call',
- seq: 4,
- time: 5,
- data: { turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}' },
- },
- {
- type: 'tool/result',
- seq: 5,
- time: 6,
- data: {
- turn: 1,
- step: 1,
- callId: 'call-1',
- content: [{ type: 'text', text: 'full result' }],
- isError: false,
- },
- sourceEventSeqs: [4],
- surfaceOp: 'append',
- },
- {
- type: 'tool/result',
- seq: 6,
- time: 8,
- data: {
- turn: 1,
- step: 1,
- callId: 'call-1',
- content: [{ type: 'text', text: 'pruned' }],
- isError: false,
- },
- sourceEventSeqs: [5],
- surfaceOp: { op: 'replace', start: 5, end: 5 },
- },
- { type: 'step/end', seq: 7, time: 9, data: { turn: 1, step: 1 } },
- { type: 'turn/end', seq: 8, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
- ] as unknown as SessionEvent[]
- }
- /** A complete log in the durable event vocabulary of the react-loop refactor base. */
- function preReactLoopLog(): SessionEvent[] {
- const prompt = createUserMessage({
- content: [{ type: 'text', text: 'old prompt' }],
- source: { kind: 'user' },
- })
- const steering = createUserMessage({
- content: [{ type: 'text', text: 'old steering' }],
- source: { kind: 'user' },
- })
- return [
- {
- type: 'turn/start', seq: 0, time: 1,
- data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- { type: 'user/message', seq: 1, time: 2, data: prompt, surfaceOp: 'append' },
- { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
- {
- type: 'steering/message', seq: 3, time: 4,
- data: { turn: 1, message: steering },
- surfaceOp: 'append',
- },
- { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
- { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'retry' } } },
- { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
- { type: 'step/end', seq: 8, time: 9, data: { turn: 2, step: 1 } },
- {
- type: 'turn/end', seq: 9, time: 10,
- data: {
- turn: 2,
- reason: {
- kind: 'error',
- step: 1,
- failure: { message: 'old provider failure', code: 'SERVER' },
- },
- },
- },
- {
- type: 'turn/start', seq: 10, time: 11,
- data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'aborted' } } },
- {
- type: 'turn/start', seq: 12, time: 13,
- data: { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- { type: 'turn/end', seq: 13, time: 14, data: { turn: 4, reason: { kind: 'disposed' } } },
- {
- type: 'turn/start', seq: 14, time: 15,
- data: { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- { type: 'step/start', seq: 15, time: 16, data: { turn: 5, step: 1 } },
- { type: 'step/end', seq: 16, time: 17, data: { turn: 5, step: 1 } },
- {
- type: 'turn/end', seq: 17, time: 18,
- data: { turn: 5, reason: { kind: 'error', step: 1, message: 'old thrown value' } },
- },
- {
- type: 'turn/start', seq: 18, time: 19,
- data: { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- {
- type: 'turn/end', seq: 19, time: 20,
- data: {
- turn: 6,
- reason: {
- kind: 'error',
- step: 0,
- failure: {
- message: 'old detailed provider failure',
- code: 'RATE_LIMIT',
- status: 429,
- providerRetryAfterMs: 1000,
- requestId: 'request-1',
- },
- },
- },
- },
- {
- type: 'turn/start', seq: 20, time: 21,
- data: { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- {
- type: 'turn/end', seq: 21, time: 22,
- data: { turn: 7, reason: { kind: 'error', step: 0, message: 'old coded error', code: 'CODED' } },
- },
- ] as unknown as SessionEvent[]
- }
- /** A live session created inside its OWN fiber, so it survives a backend reload. */
- async function liveSessionInFiber(
- ctx: Context, id: string, cwd: string | undefined,
- ): Promise<Session> {
- let session!: Session
- await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined)
- }, { inject: ['sessions'] }))
- return session
- }
- /**
- * Run the coordinator orchestration suite against a backend. `makeFixture()`
- * MUST return a fresh fixture (isolated storage) each call.
- */
- export function runCoordinatorContract(name: string, makeFixture: () => Promise<CoordinatorFixture>): void {
- describe(`PersistenceCoordinator orchestration: ${name}`, () => {
- /** Mount SessionStore + a backend instance on a fresh context over the fixture's storage. */
- async function freshCtx(fix: CoordinatorFixture): Promise<{ ctx: Context; fiber: Fiber }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await fix.mount(ctx)
- return { ctx, fiber }
- }
- // --- write path: live session → flush → reload ---
- it('persists a live session driven through the store, surviving reload', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } })
- send(session, oneTurnLog())
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('live'))
- expect(loaded.events).toHaveLength(6)
- expect(loaded.meta.cwd).toBe(WORK)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('rejects crash-repair load while a live session owns the persisted prefix', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- let session!: Session
- const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId('live-load'), { meta: { cwd: WORK } })
- }, { inject: ['sessions'] }))
- try {
- session.append('turn/start', { turn: 1 })
- await ctx.sessions.flush(session)
- await expect(ctx.sessionPersistence.load(session.id))
- .rejects.toThrow(`cannot load session "${session.id}" while its live turn is open`)
- send(session, oneTurnLog().slice(1))
- await ctx.sessions.flush(session)
- await sessionFiber.dispose()
- await vi.waitFor(async () => {
- const loaded = await ctx.sessionPersistence.load(session.id)
- expect(loaded.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type))
- expect(loaded.events.at(-1)).toMatchObject({
- type: 'turn/end',
- data: { reason: { kind: 'completed' } },
- })
- })
- } finally {
- await sessionFiber.dispose()
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('rechecks live ownership after a cold load enters the per-id chain', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const id = SessionId('queued-load-live-race')
- const header = meta(id, WORK)
- const start: SessionEvent = {
- type: 'turn/start',
- seq: 0,
- time: 1,
- data: { turn: 1 },
- }
- await ctx.sessionPersistence.create(header)
- await ctx.sessionPersistence.append(id, [start])
- const loading = ctx.sessionPersistence.load(id)
- const live = ctx.sessions.create(id, { seed: [start], meta: header })
- await expect(loading).rejects.toThrow(/live turn is open/)
- live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(live)
- const loaded = await ctx.sessionPersistence.load(id)
- // The constructor's end-seed event persisted between the stored
- // turn/start and the turn/end appended live.
- expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed', 'turn/end'])
- expect(loaded.events.at(-1)).toMatchObject({
- type: 'turn/end',
- data: { reason: { kind: 'completed' } },
- })
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('does not load an unmaterialized empty live session', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const session = ctx.sessions.create(SessionId('empty-live'), { meta: { cwd: WORK } })
- await expect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(/not found/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('round-trips the seed boundary (seedLength) through persistence', async () => {
- // A forked child records how many leading events were inherited via the seed; the
- // boundary must survive a reload (so a resume/replay can tell the inherited prefix from
- // the child's own events). JSONL stores it in the header; SQLite uses `seed_length`.
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- let session!: Session
- const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } })
- }, { inject: ['sessions'] }))
- send(session, oneTurnLog())
- await ctx.sessions.flush(session)
- await sessionFiber.dispose()
- const loaded = await ctx.sessionPersistence.load(SessionId('forked-child'))
- expect(loaded.meta.seedLength).toBe(3)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('round-trips the delegation depth through persistence', async () => {
- // A subagent child's recursion budget lives in its header; a reload that
- // dropped it would reset the child to top-level and un-bound maxDepth
- // (JSONL stores it in the header line; SQLite uses `delegation_depth`).
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- let session!: Session
- const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId('delegated-child'), {
- meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 },
- })
- }, { inject: ['sessions'] }))
- send(session, oneTurnLog())
- await ctx.parallel('session/flush', session)
- await sessionFiber.dispose()
- const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child'))
- expect(loaded.meta.delegationDepth).toBe(2)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
- session.append('turn/start', { turn: 1 })
- const ev = session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- expect(() => {
- ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
- }).toThrow(TypeError)
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
- const message = loaded.events.find(event => event.type === 'user/message')
- expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original')
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('load and inspect return immutable identified-message snapshots', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const id = SessionId('immutable-read')
- const session = ctx.sessions.create(id, { meta: { cwd: WORK } })
- send(session, oneTurnLog())
- await ctx.sessions.flush(session)
- for (const snapshot of [
- await ctx.sessionPersistence.load(id),
- await ctx.sessionPersistence.inspect(id),
- ]) {
- const event = snapshot.events.find(candidate => candidate.type === 'user/message')
- if (event?.type !== 'user/message') throw new Error('fixture lacks user/message')
- expect(Object.isFrozen(event.data)).toBe(true)
- expect(Object.isFrozen(event.data.content)).toBe(true)
- expect(() => {
- ;(event.data as { id: string }).id = 'rewritten'
- }).toThrow(TypeError)
- expect(() => {
- ;(event.data.content[0] as { type: 'text'; text: string }).text = 'rewritten'
- }).toThrow(TypeError)
- }
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('loads pre-identity message logs into resumable current sessions', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const id = SessionId('legacy-message-load')
- await ctx.sessionPersistence.create(meta(id, WORK))
- await ctx.sessionPersistence.append(id, legacyMessageLog())
- for (const snapshot of [
- await ctx.sessionPersistence.inspect(id),
- await ctx.sessionPersistence.load(id),
- ]) {
- const messages: { id: string }[] = []
- for (const event of snapshot.events) {
- if (event.type === 'user/message') messages.push(event.data)
- else if (event.type === 'assistant/message'
- || event.type === 'tool/result') messages.push(event.data.message)
- }
- expect(messages.map(message => message.id)).toEqual([
- `legacy-message:${id}:1`,
- `legacy-message:${id}:3`,
- `legacy-message:${id}:5`,
- `legacy-message:${id}:5`,
- ])
- expect(messages.every(message => Object.isFrozen(message))).toBe(true)
- const resumed = Session.create(id, snapshot.events, snapshot.meta)
- expect(resumed.deriveMessages().map(message => message.id)).toEqual([
- `legacy-message:${id}:1`,
- `legacy-message:${id}:3`,
- `legacy-message:${id}:5`,
- ])
- }
- const replacementSuffix = await ctx.sessionPersistence.readFrom(id, 6)
- expect(replacementSuffix.events[0]).toMatchObject({
- type: 'tool/result',
- seq: 6,
- data: { message: { id: `legacy-message:${id}:5` } },
- })
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('loads pre-react-loop session logs into resumable current sessions', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const id = SessionId('pre-react-loop-load')
- const log = preReactLoopLog()
- const legacySteering = log[3] as unknown as { data: { message: { id: string } } }
- await ctx.sessionPersistence.create(meta(id, WORK))
- await ctx.sessionPersistence.append(id, log)
- const snapshots = [
- await ctx.sessionPersistence.inspect(id),
- await ctx.sessionPersistence.readFrom(id, 0),
- await ctx.sessionPersistence.load(id),
- ]
- for (const snapshot of snapshots) {
- expect(snapshot.events.some(event => (event.type as string) === 'steering/message')).toBe(false)
- expect(snapshot.events.filter(event => event.type === 'turn/start').map(event => event.data))
- .toEqual([
- { turn: 1 }, { turn: 2 }, { turn: 3 }, { turn: 4 }, { turn: 5 }, { turn: 6 }, { turn: 7 },
- ])
- expect(snapshot.events.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([
- { turn: 1, reason: { kind: 'completed' } },
- {
- turn: 2,
- reason: { kind: 'error', error: { message: 'old provider failure', code: 'SERVER' } },
- },
- { turn: 3, reason: { kind: 'aborted', reason: { kind: 'legacy' } } },
- { turn: 4, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
- {
- turn: 5,
- reason: { kind: 'error', error: { message: 'old thrown value', code: 'UNKNOWN' } },
- },
- {
- turn: 6,
- reason: {
- kind: 'error',
- error: {
- message: 'old detailed provider failure',
- code: 'RATE_LIMIT',
- status: 429,
- providerRetryAfterMs: 1000,
- requestId: 'request-1',
- },
- },
- },
- {
- turn: 7,
- reason: { kind: 'error', error: { message: 'old coded error', code: 'CODED' } },
- },
- ])
- const resumed = Session.create(id, snapshot.events, snapshot.meta)
- expect(resumed.deriveMessages().map(message => message.content)).toEqual([
- [{ type: 'text', text: 'old prompt' }],
- [{ type: 'text', text: 'old steering' }],
- ])
- }
- const suffix = await ctx.sessionPersistence.readFrom(id, 3)
- expect(suffix.events[0]).toMatchObject({
- type: 'user/message',
- seq: 3,
- data: { id: legacySteering.data.message.id },
- })
- expect(suffix.events.filter(event => event.type === 'turn/end')
- .every(event => !Object.hasOwn(event.data, 'step'))).toBe(true)
- const flatId = SessionId('pre-react-loop-flat-steering')
- await ctx.sessionPersistence.create(meta(flatId, WORK))
- await ctx.sessionPersistence.append(flatId, [{
- type: 'steering/message',
- seq: 0,
- time: 1,
- data: {
- turn: 1,
- content: [{ type: 'text', text: 'flat steering' }],
- source: { kind: 'user' },
- },
- surfaceOp: 'append',
- } as unknown as SessionEvent])
- expect((await ctx.sessionPersistence.inspect(flatId)).events[0]).toMatchObject({
- type: 'user/message',
- data: {
- id: `legacy-message:${flatId}:0`,
- role: 'user',
- content: [{ type: 'text', text: 'flat steering' }],
- },
- })
- const extendedId = SessionId('current-extended-turn-end')
- await ctx.sessionPersistence.create(meta(extendedId, WORK))
- await ctx.sessionPersistence.append(extendedId, [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- {
- type: 'turn/end', seq: 1, time: 2,
- data: { turn: 1, reason: { kind: 'extension-reason' } },
- } as unknown as SessionEvent,
- ])
- expect((await ctx.sessionPersistence.inspect(extendedId)).events[1]).toMatchObject({
- type: 'turn/end',
- data: { reason: { kind: 'extension-reason' } },
- })
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('rejects malformed persisted message events before returning them', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const id = SessionId('invalid-message-read')
- await ctx.sessionPersistence.create(meta(id, WORK))
- await ctx.sessionPersistence.append(id, [{
- type: 'user/message',
- seq: 0,
- time: 1,
- surfaceOp: 'append',
- data: {
- id: 'wrong-role',
- role: 'assistant',
- content: [{ type: 'text', text: 'wrong' }],
- source: { kind: 'user' },
- },
- } as unknown as SessionEvent])
- await expect(ctx.sessionPersistence.inspect(id))
- .rejects.toThrow('message must have role "user"')
- await expect(ctx.sessionPersistence.load(id))
- .rejects.toThrow('message must have role "user"')
- const malformedLegacy: { id: string; event: SessionEvent; message: string }[] = [
- {
- id: 'invalid-old-turn-start',
- event: {
- type: 'turn/start', seq: 0, time: 1,
- data: { turn: 1, trigger: null },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/start',
- },
- {
- id: 'invalid-old-steering',
- event: {
- type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
- data: { turn: 1, content: [], source: { kind: 'user' }, extra: true },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop steering/message',
- },
- {
- id: 'invalid-old-steering-data',
- event: {
- type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', data: null,
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop steering/message',
- },
- {
- id: 'invalid-old-turn-end',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, reason: { kind: 'completed', extra: true } },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- {
- id: 'invalid-old-turn-end-reason',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, reason: null },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- {
- id: 'unsupported-intermediate-turn-end-step',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, step: 1, reason: { kind: 'completed' } },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- {
- id: 'invalid-old-turn-end-aborted',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, reason: { kind: 'aborted', extra: true } },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- {
- id: 'invalid-old-turn-end-disposed',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, reason: { kind: 'disposed', extra: true } },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- {
- id: 'invalid-old-turn-end-error-step',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, reason: { kind: 'error', step: -1, message: 'bad step' } },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- {
- id: 'invalid-old-turn-end-error-code',
- event: {
- type: 'turn/end', seq: 0, time: 1,
- data: { turn: 1, reason: { kind: 'error', step: 0, message: 'bad code', code: 1 } },
- } as unknown as SessionEvent,
- message: 'malformed pre-react-loop turn/end',
- },
- ]
- for (const malformed of malformedLegacy) {
- const malformedId = SessionId(malformed.id)
- await ctx.sessionPersistence.create(meta(malformedId, WORK))
- await ctx.sessionPersistence.append(malformedId, [malformed.event])
- await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message)
- await expect(ctx.sessionPersistence.readFrom(malformedId, 0)).rejects.toThrow(malformed.message)
- }
- for (const type of ['tool/result'] as const) {
- const malformedId = SessionId(`invalid-${type}`)
- await ctx.sessionPersistence.create(meta(malformedId, WORK))
- await ctx.sessionPersistence.append(malformedId, [{
- type,
- seq: 0,
- time: 1,
- surfaceOp: 'append',
- data: { message: null },
- } as unknown as SessionEvent])
- await expect(ctx.sessionPersistence.inspect(malformedId))
- .rejects.toThrow('lacks an identified message')
- }
- // An out-of-repo event type passes only with the envelope's ignorable
- // marker (unknown-type refusal otherwise), and its non-object data is
- // not message-validated.
- const pluginId = SessionId('non-object-plugin-event')
- await ctx.sessionPersistence.create(meta(pluginId, WORK))
- await ctx.sessionPersistence.append(pluginId, [{
- type: 'plugin/test',
- seq: 0,
- time: 1,
- data: null,
- ignorable: true,
- } as unknown as SessionEvent])
- await expect(ctx.sessionPersistence.inspect(pluginId))
- .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
- await expect(ctx.sessionPersistence.readFrom(pluginId, 0))
- .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
- for (const type of ['user/message', 'assistant/message'] as const) {
- const missingContentId = SessionId(`invalid-${type}-without-content`)
- await ctx.sessionPersistence.create(meta(missingContentId, WORK))
- await ctx.sessionPersistence.append(missingContentId, [{
- type,
- seq: 0,
- time: 1,
- surfaceOp: 'append',
- data: {},
- } as unknown as SessionEvent])
- await expect(ctx.sessionPersistence.readFrom(missingContentId, 0))
- .rejects.toThrow('lacks an identified message')
- }
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('append snapshots the batch: mutating the caller array/events after the call is ignored', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const m = meta('snapshot', WORK)
- await ctx.sessionPersistence.create(m)
- const events = structuredClone(oneTurnLog()) // seqs 0..5
- const userMsg = events[1] // the user/message event
- const p = ctx.sessionPersistence.append(m.id, events)
- // Mutate the caller's array AND an event object after the call but before
- // the queued op runs: the snapshot taken at call time must shield the copy.
- events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2 } })
- if (userMsg?.type === 'user/message') {
- (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'MUTATED' }]
- }
- await p
- const loaded = await ctx.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) // not 0..6
- const persisted = JSON.stringify(loaded.events)
- expect(persisted).toContain('hi') // original content
- expect(persisted).not.toContain('MUTATED')
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- fork / resume ---
- it('fork: a seeded new session persists its seed once (no double-write on a no-op flush)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const seed = oneTurnLog()
- // A fork: a brand-new id whose seed came from elsewhere.
- const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
- await ctx.sessions.flush(forked) // onCreated persisted the seed
- const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
- // Fork is where the marker earns its keep: the inherited prefix may
- // carry a bracket the still-running parent owns.
- expect(loaded.events.slice(0, seed.length)).toEqual(seed)
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: seed.length })
- // A flush with no NEW events must not double-write.
- await ctx.sessions.flush(forked)
- const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
- expect(reloaded.events).toEqual(loaded.events)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => {
- // Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation.
- const fix = await makeFixture()
- const first = await freshCtx(fix)
- try {
- const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
- send(s1, oneTurnLog())
- await first.ctx.sessions.flush(s1)
- } finally {
- await first.fiber.dispose()
- }
- const second = await freshCtx(fix)
- try {
- const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
- const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
- await second.ctx.sessions.flush(s2) // let onCreated adopt
- s2.append('turn/start', { turn: 2 })
- s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
- await second.ctx.sessions.flush(s2)
- const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
- // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn.
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
- expect(reloaded.events[6]).toMatchObject({ type: 'session/end-seed' })
- } finally {
- await second.fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- HMR ---
- it('HMR: applying the plugin seeds existing live sessions', async () => {
- const fix = await makeFixture()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- // A session exists BEFORE the persistence plugin is applied.
- const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- const fiber = await fix.mount(ctx)
- try {
- // The plugin seeded it on apply; a subsequent flush persists its events.
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing'))
- expect(loaded.events.length).toBeGreaterThanOrEqual(2)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('HMR: dispose drains remaining buffers', async () => {
- const fix = await makeFixture()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await fix.mount(ctx)
- const session = await liveSessionInFiber(ctx, 'drain', WORK)
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- // No explicit flush — dispose must drain.
- await fiber.dispose()
- // A fresh backend instance reads what the disposed one drained.
- const second = await freshCtx(fix)
- try {
- const loaded = await second.ctx.sessionPersistence.load(SessionId('drain'))
- expect(loaded.events.length).toBeGreaterThanOrEqual(2)
- } finally {
- await second.fiber.dispose()
- await fix.cleanup()
- }
- })
- it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => {
- const fix = await makeFixture()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- // The session lives in its OWN fiber so it survives the backend reload.
- const session = await liveSessionInFiber(ctx, 'hmr-adopt', WORK)
- try {
- // Backend instance 1 materializes the session.
- const backend1 = await fix.mount(ctx)
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
- // session stays live. The new instance has no coordinator state but must adopt the
- // materialized prefix, then persist another turn rather than rejecting it as a collision.
- await backend1.dispose()
- await fix.mount(ctx)
- session.append('turn/start', { turn: 2 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'again' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
- await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
- const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
- expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
- } finally {
- await ctx.fiber.dispose()
- await fix.cleanup()
- }
- })
- it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => {
- const fix = await makeFixture()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const session = await liveSessionInFiber(ctx, 'hmr-suffix', WORK)
- try {
- // Instance 1 flushes turn 1.
- const backend1 = await fix.mount(ctx)
- session.append('turn/start', { turn: 1 })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
- // flushing turn 2: it is now ONLY in the live session's events; the new
- // backend never buffered it via session/event.
- await backend1.dispose()
- session.append('turn/start', { turn: 2 })
- session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
- // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
- // live suffix (turn 2) carried in the session's events.
- await fix.mount(ctx)
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
- expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
- } finally {
- await ctx.fiber.dispose()
- await fix.cleanup()
- }
- })
- it('HMR adoption does NOT crash-repair an active open turn as interrupted (truncate without closers)', async () => {
- const fix = await makeFixture()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const session = await liveSessionInFiber(ctx, 'hmr-open', WORK)
- try {
- const first = await fix.mount(ctx)
- session.append('turn/start', { turn: 1 })
- session.append('step/start', { turn: 1, step: 1 })
- await ctx.sessions.flush(session)
- // Crash-tail a torn fragment past the (open) committed turn, then reload.
- await first.dispose()
- if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK)
- const second = await fix.mount(ctx)
- // The live session is still the authority: it appends the REAL step/turn
- // end. Adoption must truncate the torn tail but NOT synthesize closers.
- session.append('step/end', { turn: 1, step: 1 })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
- expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
- expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
- await second.dispose()
- } finally {
- await ctx.fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- collision / id reuse ---
- it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => {
- const fix = await makeFixture()
- const first = await freshCtx(fix)
- try {
- const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
- send(s1, oneTurnLog())
- await first.ctx.sessions.flush(s1)
- } finally {
- await first.fiber.dispose()
- }
- // A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated
- // treats it as new; create() rejects because a log already exists, and `flush()` surfaces
- // that initialization rejection.
- const second = await freshCtx(fix)
- try {
- const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
- s2.append('turn/start', { turn: 1 })
- await expect(second.ctx.sessions.flush(s2))
- .rejects.toThrow(/already has a persisted log|id collision/)
- } finally {
- await second.fiber.dispose()
- await fix.cleanup()
- }
- })
- it('an abandoned lazy session (never materialized) releases its id for reuse', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // A live session created then disposed BEFORE its first append: cursor 0,
- // never materialized. A new live session reusing the id must reclaim it.
- let firstSession!: Session
- const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
- firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
- }, { inject: ['sessions'] }))
- await ctx.sessions.flush(firstSession) // register the lazy state
- await firstFiber.dispose() // disposed before any append → never materialized
- let reuse!: Session
- await ctx.plugin(Object.assign((inner: Context) => {
- reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
- }, { inject: ['sessions'] }))
- await expect(ctx.sessions.flush(reuse)).resolves.toBe(true)
- reuse.append('turn/start', { turn: 1 })
- reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(reuse)
- const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('session disposal drains buffered events before retiring ownership', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- let first!: Session
- const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
- first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
- }, { inject: ['sessions'] }))
- await ctx.sessions.flush(first)
- // Append a turn but do NOT flush — events sit in the write-behind buffer.
- first.append('turn/start', { turn: 1 })
- first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await firstFiber.dispose()
- // Disposal is an observe-only notification. Poll storage rather than
- // assuming the owning fiber awaits the coordinator's detached drain.
- await vi.waitFor(async () => {
- expect((await ctx.sessionPersistence.list()).map(meta => meta.id)).toContain(SessionId('buffered'))
- })
- expect((await ctx.sessionPersistence.load(SessionId('buffered'))).events.map(event => event.seq)).toEqual([0, 1])
- let reuse!: Session
- await ctx.plugin(Object.assign((inner: Context) => {
- reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
- }, { inject: ['sessions'] }))
- await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- // Re-emit session/created for the SAME live session (idempotent initFor).
- ctx.emit(scopeTarget(session, undefined), 'session/created', session)
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('idem'))
- expect(loaded.events).toHaveLength(3) // not doubled
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- ownerless-state claim (public create()/load() then a live session arrives) ---
- it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // create() registers ownerless state with cursor 0 (lazy, nothing persisted).
- await ctx.sessionPersistence.create(meta('lazy-claim', WORK))
- // A live session with that id arrives and claims it (cursor 0 matches
- // trivially), persisting its seed.
- const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
- await expect(ctx.sessions.flush(live)).resolves.toBe(true)
- const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
- // Seeded 0-5 plus the constructor's end-seed event at 6.
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // Materialize a log, then load() it WITHOUT a live session — ownerless
- // state, cursor at the persisted length.
- await ctx.sessionPersistence.create(meta('preview', WORK))
- await ctx.sessionPersistence.append(SessionId('preview'), oneTurnLog())
- await ctx.sessionPersistence.load(SessionId('preview'))
- // A FRESH (empty-seed) live session reusing that id must be rejected: its
- // seq 0..cursor-1 events would otherwise be filtered as already-persisted.
- let fresh!: Session
- await ctx.plugin(Object.assign((inner: Context) => {
- fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } })
- }, { inject: ['sessions'] }))
- await expect(ctx.sessions.flush(fresh))
- .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('a live session whose complete seed matches loaded ownerless state claims it without appending', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const id = SessionId('claim-exact')
- const completeSeed = [
- ...oneTurnLog(),
- { type: 'session/end-seed', seq: 6, time: 7, data: {} },
- ] as SessionEvent[]
- await ctx.sessionPersistence.create(meta(id, WORK))
- await ctx.sessionPersistence.append(id, completeSeed)
- const { events } = await ctx.sessionPersistence.load(id)
- const live = ctx.sessions.create(id, { seed: events, meta: { cwd: WORK } })
- await expect(ctx.sessions.flush(live)).resolves.toBe(true)
- expect((await ctx.sessionPersistence.load(id)).events).toEqual(events)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // Materialize and load (ownerless, cursor = 6).
- const storedMeta = meta('claim', WORK)
- await ctx.sessionPersistence.create(storedMeta)
- await ctx.sessionPersistence.append(SessionId('claim'), oneTurnLog())
- const { events, meta: durableMeta } = await ctx.sessionPersistence.load(SessionId('claim'))
- // A live session SEEDED with the loaded log PLUS a new turn claims the
- // ownerless state and persists only the suffix.
- let cont!: Session
- const contFiber = await ctx.plugin(Object.assign((inner: Context) => {
- cont = inner.sessions.create(SessionId('claim'), { seed: [
- ...events,
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
- ], meta: { cwd: WORK, createdAt: 2000 } })
- }, { inject: ['sessions'] }))
- await ctx.sessions.flush(cont)
- const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
- // 6-7 the claimed suffix; 8 end-seed after the whole seed.
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
- expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
- expect(loaded.meta).toEqual(durableMeta)
- expect(loaded.meta.createdAt).toBe(1000)
- await contFiber.dispose()
- await vi.waitFor(async () => {
- expect((await ctx.sessionPersistence.load(SessionId('claim'))).meta).toEqual(durableMeta)
- })
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // create() registers ownerless state at cwd /a (cursor 0 — claims would
- // otherwise match trivially on the seed).
- await ctx.sessionPersistence.create(meta('wrong-cwd-claim', OTHER))
- // A live session reusing the id but at cwd WORK must NOT claim it — the
- // cwd scope is the fence (without it, WORK events would append under the
- // OTHER header). Rejected as a collision.
- const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
- await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // Materialize + load at cwd OTHER (ownerless, cursor = 6).
- await ctx.sessionPersistence.create(meta('wrong-cwd-load', OTHER))
- await ctx.sessionPersistence.append(SessionId('wrong-cwd-load'), oneTurnLog())
- const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load'))
- // A live session whose SEED matches the loaded prefix but whose cwd is
- // WORK must still be rejected — the cwd guard runs before the seed check.
- const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } })
- await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // Ownerless state created WITHOUT a cwd (the `_no-cwd` project directory).
- await ctx.sessionPersistence.create(meta('no-cwd-state'))
- // A live session reusing the id but WITH cwd WORK is a cwd mismatch
- // (undefined vs WORK) and must be rejected.
- const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } })
- await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- append adopts a storage-only session (fresh instance, no prior create/load) ---
- it('append adopts a storage-only session (fresh instance) and continues the seq', async () => {
- const fix = await makeFixture()
- const first = await freshCtx(fix)
- try {
- const m = meta('adopt-append', WORK)
- await first.ctx.sessionPersistence.create(m)
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
- } finally {
- await first.fiber.dispose()
- }
- // A fresh instance appends a second turn WITHOUT a prior create/load: append
- // must adopt the stored session (cursor = stored length) and continue.
- const second = await freshCtx(fix)
- try {
- await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
- ])
- const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append'))
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- } finally {
- await second.fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- small public-API edges that the coordinator owns uniformly ---
- it('append of an empty batch is a no-op (stays lazy)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const m = meta('empty-batch', WORK)
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, [])
- expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('load and inspect reject a missing session', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/)
- await expect(ctx.sessionPersistence.inspect(SessionId('nope'))).rejects.toThrow(/not found/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('create rejects a duplicate id (in memory and on a persisted log)', async () => {
- const fix = await makeFixture()
- const first = await freshCtx(fix)
- try {
- const m = meta('dup', WORK)
- await first.ctx.sessionPersistence.create(m)
- // Same in-memory state.
- await expect(first.ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists in this backend/)
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
- } finally {
- await first.fiber.dispose()
- }
- // A fresh instance over the same storage sees the persisted log.
- const second = await freshCtx(fix)
- try {
- await expect(second.ctx.sessionPersistence.create(meta('dup', WORK)))
- .rejects.toThrow(/already has a persisted log on disk/)
- } finally {
- await second.fiber.dispose()
- await fix.cleanup()
- }
- })
- it('rejects a newer format version on load, naming the upgrade direction', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK }
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
- const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
- expect(failure?.name).toBe('SessionFormatUnsupportedError')
- expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('rejects an older format version on load without claiming an upgrade path', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK }
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
- const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
- expect(failure?.name).toBe('SessionFormatUnsupportedError')
- expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('rejects an unknown event type on load unless the event is marked ignorable', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const required = meta('unknown-required', WORK)
- await ctx.sessionPersistence.create(required)
- await ctx.sessionPersistence.append(required.id, [
- ...oneTurnLog(),
- { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent,
- ])
- const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
- expect(failure?.name).toBe('SessionFormatUnsupportedError')
- expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/)
- const skippable = meta('unknown-ignorable', WORK)
- await ctx.sessionPersistence.create(skippable)
- await ctx.sessionPersistence.append(skippable.id, [
- ...oneTurnLog(),
- { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent,
- ])
- const loaded = await ctx.sessionPersistence.load(skippable.id)
- expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('round-trips a header with parentSession (fork lineage)', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- const m = { version: SESSION_FORMAT_VERSION, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') }
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
- const loaded = await ctx.sessionPersistence.load(m.id)
- expect(loaded.meta.parentSession).toBe('the-parent')
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- it('flush before init resolves uses cursor 0', async () => {
- const fix = await makeFixture()
- const { ctx, fiber } = await freshCtx(fix)
- try {
- // Append directly to a live session and flush IMMEDIATELY, before the
- // async onCreated init has necessarily set state (exercises the
- // state-undefined cursor path).
- const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
- expect(loaded.events).toHaveLength(3)
- } finally {
- await fiber.dispose()
- await fix.cleanup()
- }
- })
- // --- crash-tail repair THROUGH the coordinator (real storage torn tail) ---
- it('torn-tail load: a never-committed tail is truncated and the open turn closed during load (commitRepair w/ tornMarker)', async () => {
- const fix = await makeFixture()
- if (!fix.corruptTail) {
- // A memory-style store has no torn tails (every write is atomic in RAM),
- // so there is no tornMarker path to exercise. Assert that explicitly
- // instead of silently skipping, then bail.
- expect(fix.corruptTail).toBeUndefined()
- await fix.cleanup()
- return
- }
- const first = await freshCtx(fix)
- try {
- const m = meta('torn', WORK)
- await first.ctx.sessionPersistence.create(m)
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced)
- // A second turn whose real events are durable but never closed (open turn).
- await first.ctx.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
- ])
- } finally {
- await first.fiber.dispose()
- }
- // Inject a torn fragment past the committed region (never-committed tail).
- await fix.corruptTail(SessionId('torn'), WORK)
- // A FRESH instance loads: the torn tail is truncated (tornMarker !==
- // undefined) AND the open turn 2 is closed with synthetic step/end +
- // turn/end {interrupted} — commitRepair runs with BOTH a torn marker and
- // closers. The preserved real events (0..7) are never truncated.
- const second = await freshCtx(fix)
- try {
- const loaded = await second.ctx.sessionPersistence.load(SessionId('torn'))
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
- expect(loaded.events.map(e => e.type)).toEqual([
- 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
- 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real + synthetic closers
- ])
- const last = loaded.events.at(-1)!
- expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
- // The repair is durable: the next append continues at the balanced length
- // (seq 10) and a reload round-trips identically.
- await second.ctx.sessionPersistence.append(SessionId('torn'), [
- { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
- { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
- ])
- const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn'))
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
- } finally {
- await second.fiber.dispose()
- await fix.cleanup()
- }
- })
- })
- }
|