| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340 |
- import { ToolCallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
- import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import { appendFile, mkdtemp, readdir, rm } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import LlmRuntime from '@deepseek-ai/dsh-llm'
- import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRuntime from '@deepseek-ai/dsh-tools'
- import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
- import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
- import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
- import AgentLoop from '@deepseek-ai/dsh-agent-loop'
- import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
- import { MockAdapter, textResponse } from './mock-adapter.ts'
- const dirs: string[] = []
- afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
- async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
- const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
- dirs.push(root)
- return { ctx: await mountPersistentHarness(root, adapter), root }
- }
- async function mountPersistentHarness(root: string, adapter: MockAdapter, compression?: 'none'): Promise<Context> {
- const ctx = new Context()
- await ctx.plugin(LlmRuntime)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- // The backend mounts BEFORE the loop so root teardown unwinds the loop
- // first: live agents drain their writers into still-open handles.
- await ctx.plugin(JsonlSessionPersistence, { root, ...compression === undefined ? {} : { compression } })
- await ctx.plugin(AgentLoop, { agents: [] })
- ctx.llm.registerAdapter(['mock'], adapter)
- return ctx
- }
- /** Remove every `session.lock` under the root: the POSIX forfeit-by-unlink escape hatch, without importing backend internals. */
- async function removeSessionLocks(dir: string): Promise<void> {
- for (const entry of await readdir(dir, { withFileTypes: true })) {
- const path = join(dir, entry.name)
- if (entry.isDirectory()) await removeSessionLocks(path)
- else if (entry.name === 'session.lock') await rm(path, { force: true })
- }
- }
- /** Seed one stored session through the persistence seam (header minted by the store). */
- async function seedStoredSession(ctx: Context, sessionId: SessionId, events: readonly SessionEvent[]): Promise<void> {
- const detached = ctx.sessions.prepare(sessionId)
- const handle = await ctx.sessionPersistence.create(detached.header)
- await handle.append(events)
- await handle.close()
- }
- /** Read one stored session's physical validated log through a read handle. */
- async function readStoredEvents(ctx: Context, sessionId: SessionId): Promise<readonly SessionEvent[]> {
- const handle = await ctx.sessionPersistence.open(sessionId, 'read')
- try {
- return (await handle.read()).events
- } finally {
- await handle.close()
- }
- }
- async function persistSession(sessionId: SessionId): Promise<string> {
- const { ctx, root } = await persistentHarness(new MockAdapter([]))
- // Persistence deliberately has no artifact for a truly empty session. A
- // balanced completed turn is the smallest resumable log and avoids running
- // the model merely to construct this lifecycle fixture.
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- await seedStoredSession(ctx, sessionId, seed)
- await ctx.fiber.dispose()
- return root
- }
- /** A handle stand-in for abandoned-open races; only `close()` is ever reachable. */
- function abandonedHandleStub(): { handle: SessionHandle; close: ReturnType<typeof vi.fn> } {
- const close = vi.fn(async () => {})
- return { handle: { close } as unknown as SessionHandle, close }
- }
- function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
- return new Promise((resolve) => {
- const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
- if (subject === agent && status === 'idle') { dispose(); resolve() }
- })
- })
- }
- /** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
- async function promptly<T>(job: Promise<T>): Promise<T> {
- const timeout = Promise.withResolvers<never>()
- const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
- try {
- return await Promise.race([job, timeout.promise])
- } finally {
- clearTimeout(timer)
- }
- }
- /** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
- function throwUnknown(value: unknown): never {
- throw value
- }
- describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
- it('normalizes a non-Error resume publication failure for rollback and releases the write handle', async () => {
- const sessionId = SessionId('unknown-resume-failure-s')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const failure = { source: 'resume' }
- ctx.on('session/created', () => throwUnknown(failure))
- await expect(ctx.agents.resume({
- resumeSessionId: sessionId,
- })).rejects.toBe(failure)
- expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- // Rollback closed the write handle: write ownership is claimable again.
- const reopened = await ctx.sessionPersistence.open(sessionId, 'write')
- await reopened.close()
- await ctx.fiber.dispose()
- })
- it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
- const adapter = new MockAdapter([textResponse('hi')])
- const { ctx } = await persistentHarness(adapter)
- const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
- expect(agent.session.id).toBe('custom-session')
- expect(agent.session.header.cwd).toBe('/w')
- await ctx.fiber.dispose()
- })
- it('createAgent rejects a duplicate identity without orphaning a session', async () => {
- const adapter = new MockAdapter([textResponse('hi')])
- const { ctx } = await persistentHarness(adapter)
- const sessionId = SessionId('sess-a')
- await ctx.agents.create({ sessionId })
- await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/)
- expect(ctx.sessions.list()).toHaveLength(1)
- await ctx.fiber.dispose()
- })
- it('a created agent stores its seed and live turn durably through its handle', async () => {
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- const { ctx } = await persistentHarness(new MockAdapter([textResponse('stored')]))
- const sessionId = SessionId('durable-create')
- const handle = await ctx.agents.create({
- sessionId,
- seed,
- meta: { cwd: '/w' },
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx, handle.agent)
- await handle.dispose()
- const stored = await readStoredEvents(ctx, sessionId)
- const seqs = stored.map(event => event.seq)
- expect(seqs).toEqual(seqs.map((_, index) => index))
- // The constructor seed (turn 1 + its end-seed marker) precedes the live turn 2.
- expect(stored.slice(0, 2).map(event => event.type)).toEqual(['turn/start', 'turn/end'])
- expect(stored[2]?.type).toBe('session/end-seed')
- const turnStarts = stored.filter(event => event.type === 'turn/start')
- expect(turnStarts.map(event => event.type === 'turn/start' && event.data.turn)).toEqual([1, 2])
- expect(stored.some(event => event.type === 'turn/end' && event.data.turn === 2)).toBe(true)
- expect(JSON.stringify(stored)).toContain('stored')
- await ctx.fiber.dispose()
- })
- it('a rejecting final writer close releases the registries, then rejects disposal', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')]))
- const sessionId = SessionId('drain-close-fails')
- const handle = await ctx.agents.create({ sessionId })
- const persisted = await ctx.sessionPersistence.open(sessionId, 'read')
- await persisted.close()
- const stored = [...(ctx.sessionPersistence as unknown as {
- tracker: { openHandles: Set<SessionHandle> }
- }).tracker.openHandles].find(open => open.id === sessionId && open.access === 'write')
- if (stored === undefined) throw new Error('missing owned write handle')
- // The real close still runs (releasing write ownership); the injected
- // failure models a drain that reports a durability error at close.
- const realClose = stored.close.bind(stored)
- vi.spyOn(stored, 'close').mockImplementation(async () => {
- await realClose()
- throw new Error('close exploded')
- })
- await expect(handle.dispose()).rejects.toThrow('close exploded')
- // Teardown reached quiescence before the rejection: the agent and session
- // are unregistered, and write ownership is released — the never-flushed
- // session reports absence, not an ownership conflict.
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- await expect(ctx.sessionPersistence.open(sessionId, 'write')).rejects.toThrow('not found')
- await ctx.fiber.dispose()
- })
- it('combines a machine-teardown failure with a close failure into one rejection', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')]))
- const sessionId = SessionId('drain-both-fail')
- const handle = await ctx.agents.create({ sessionId })
- const stored = [...(ctx.sessionPersistence as unknown as {
- tracker: { openHandles: Set<SessionHandle> }
- }).tracker.openHandles].find(open => open.id === sessionId && open.access === 'write')
- if (stored === undefined) throw new Error('missing owned write handle')
- const machine = handle.agent as Agent & { scope: { dispose: () => Promise<void> } }
- vi.spyOn(machine.scope, 'dispose').mockRejectedValue(new Error('scope exploded'))
- vi.spyOn(stored, 'close').mockRejectedValue(new Error('close exploded'))
- const failure = await handle.dispose().then(() => undefined, (error: unknown) => error)
- if (!(failure instanceof AggregateError)) throw new Error('expected an AggregateError rejection')
- expect(failure.message).toContain(`agent "${sessionId}" disposal failed`)
- expect(failure.errors.map(error => (error as Error).message)).toEqual(['scope exploded', 'close exploded'])
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('agent dispose releases write ownership of its stored session', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')]))
- const sessionId = SessionId('ownership-release')
- const handle = await ctx.agents.create({ sessionId })
- await expect(ctx.sessionPersistence.open(sessionId, 'write'))
- .rejects.toThrow(/already owned by an active write handle/)
- // Materialize the log so the session outlives its creator handle.
- handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx, handle.agent)
- await handle.dispose()
- const reopened = await ctx.sessionPersistence.open(sessionId, 'write')
- expect(reopened.access).toBe('write')
- await reopened.close()
- await ctx.fiber.dispose()
- })
- it('a stored-session create failure rolls the fresh identity back', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const sessionId = SessionId('create-backend-fail')
- vi.spyOn(ctx.sessionPersistence, 'create').mockRejectedValueOnce(new Error('backend create failed'))
- await expect(ctx.agents.create({ sessionId })).rejects.toThrow('backend create failed')
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- // The identity was fully released: the same id creates cleanly afterwards.
- const retry = await ctx.agents.create({ sessionId })
- await retry.dispose()
- await ctx.fiber.dispose()
- })
- it('a seed append failure closes the fresh write handle and rethrows', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const sessionId = SessionId('seed-append-fail')
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence)
- let closeSpy: MockInstance<() => Promise<void>> | undefined
- ctx.sessionPersistence.create = async (header, options) => {
- const handle = await originalCreate(header, options)
- vi.spyOn(handle, 'append').mockRejectedValue(new Error('seed append failed'))
- closeSpy = vi.spyOn(handle, 'close')
- return handle
- }
- await expect(ctx.agents.create({ sessionId, seed })).rejects.toThrow('seed append failed')
- expect(closeSpy).toHaveBeenCalled()
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- // The closed never-materialized creation left no stored session behind.
- await expect(ctx.sessionPersistence.open(sessionId, 'write'))
- .rejects.toThrow(/not found/)
- await ctx.fiber.dispose()
- })
- it('a setup failure before publication leaves no stored residue; the id creates again', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const sessionId = SessionId('setup-fail-no-residue')
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- await expect(ctx.agents.create({
- sessionId,
- seed,
- setup: () => { throw new Error('setup refused') },
- })).rejects.toThrow('setup refused')
- // The seed is stored only at the publication commit point, so the failed
- // attempt materialized nothing and released the identity completely.
- await expect(ctx.sessionPersistence.stat(sessionId)).resolves.toBeUndefined()
- const retry = await ctx.agents.create({ sessionId, seed })
- await expect(ctx.sessionPersistence.stat(sessionId)).resolves.toBeDefined()
- await retry.dispose()
- await ctx.fiber.dispose()
- })
- it('rollback swallows a rejecting handle close after a prepare failure (create and createAgent)', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence)
- const closeSpies: Array<{ mockRestore: () => void }> = []
- ctx.sessionPersistence.create = async (header, options) => {
- const handle = await originalCreate(header, options)
- closeSpies.push(vi.spyOn(handle, 'close').mockRejectedValue(new Error('close failed')))
- return handle
- }
- // Config create path: prepare's option validation throws after the handle exists.
- await expect(ctx.agentLoop.create(SessionId('close-reject-config'), { maxTokens: -1 }))
- .rejects.toThrow('agent maxTokens must be a positive safe integer')
- expect(ctx.agents.get(SessionId('close-reject-config'))).toBeUndefined()
- // Owned createAgent path: the same validation failure after the handle exists.
- await expect(ctx.agents.create({
- sessionId: SessionId('close-reject-owned'),
- agentOptions: { maxTokens: -1 },
- })).rejects.toThrow('agent maxTokens must be a positive safe integer')
- expect(ctx.agents.get(SessionId('close-reject-owned'))).toBeUndefined()
- for (const spy of closeSpies.splice(0)) spy.mockRestore()
- await ctx.fiber.dispose()
- })
- it('a reentrant abort during preparation swallows a rejecting handle close', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const sessionId = SessionId('prepare-abort-close-reject')
- const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence)
- const spies: Array<{ mockRestore: () => void }> = []
- let closed: Promise<void> | undefined
- ctx.sessionPersistence.create = async (header, options) => {
- const handle = await originalCreate(header, options)
- const realClose = handle.close.bind(handle)
- spies.push(vi.spyOn(handle, 'close').mockImplementation(async () => {
- closed = realClose()
- await closed
- throw new Error('close failed')
- }))
- return handle
- }
- const reason = new Error('cancelled while preparing')
- const controller = new AbortController()
- let aborted = false
- ctx.on('internal/plugin', (fiber) => {
- if (aborted || fiber.name !== 'scope') return
- aborted = true
- controller.abort(reason)
- })
- await expect(ctx.agents.create({ sessionId, signal: controller.signal })).rejects.toBe(reason)
- // The rejecting close stays swallowed by the rollback; wait for the
- // rollback's real close so factory teardown finds a quiescent lifecycle.
- await vi.waitFor(() => { if (closed === undefined) throw new Error('close not reached') })
- await closed
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- for (const spy of spies.splice(0)) spy.mockRestore()
- await ctx.fiber.dispose()
- })
- it('config create rollback swallows a rejecting close after a publish failure', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const sessionId = SessionId('config-publish-close-reject')
- const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence)
- const spies: Array<{ mockRestore: () => void }> = []
- let closed: Promise<void> | undefined
- ctx.sessionPersistence.create = async (header, options) => {
- const handle = await originalCreate(header, options)
- const realClose = handle.close.bind(handle)
- spies.push(vi.spyOn(handle, 'close').mockImplementation(async () => {
- closed = realClose()
- await closed
- throw new Error('close failed')
- }))
- return handle
- }
- const announce = vi.spyOn(ctx.agents, 'announce').mockImplementation(() => {
- throw new Error('announce failed')
- })
- await expect(ctx.agentLoop.create(sessionId)).rejects.toThrow('announce failed')
- announce.mockRestore()
- await vi.waitFor(() => { if (closed === undefined) throw new Error('close not reached') })
- await closed
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- for (const spy of spies.splice(0)) spy.mockRestore()
- await ctx.fiber.dispose()
- })
- it('a seed append failure swallows a rejecting handle close', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const sessionId = SessionId('seed-append-close-reject')
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- const originalCreate = ctx.sessionPersistence.create.bind(ctx.sessionPersistence)
- const spies: Array<{ mockRestore: () => void }> = []
- ctx.sessionPersistence.create = async (header, options) => {
- const handle = await originalCreate(header, options)
- spies.push(vi.spyOn(handle, 'append').mockRejectedValue(new Error('seed append failed')))
- spies.push(vi.spyOn(handle, 'close').mockRejectedValue(new Error('close failed')))
- return handle
- }
- await expect(ctx.agents.create({ sessionId, seed })).rejects.toThrow('seed append failed')
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- for (const spy of spies.splice(0)) spy.mockRestore()
- await ctx.fiber.dispose()
- })
- it('a resume read failure swallows a rejecting handle close during rollback', async () => {
- const sessionId = SessionId('resume-read-close-reject')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence)
- const spies: Array<{ mockRestore: () => void }> = []
- ctx.sessionPersistence.open = async (id, access, options) => {
- const handle = await originalOpen(id, access, options)
- spies.push(vi.spyOn(handle, 'read').mockRejectedValue(new Error('stored read failed')))
- spies.push(vi.spyOn(handle, 'close').mockRejectedValue(new Error('close failed')))
- return handle
- }
- await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
- .rejects.toThrow('stored read failed')
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- for (const spy of spies.splice(0)) spy.mockRestore()
- await ctx.fiber.dispose()
- })
- it('resume cannot crash-repair a turn owned by a live agent', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
- const sessionId = SessionId('live-resume-race')
- const first = (await ctx.agents.create({ sessionId })).agent
- first.session.append('turn/start', { turn: 1 })
- await ctx.sessions.flush(first.session)
- await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
- .rejects.toThrow(/already owned by an active write handle/)
- first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(first.session)
- const stored = await readStoredEvents(ctx, sessionId)
- expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
- expect(stored.at(-1)).toMatchObject({
- type: 'turn/end',
- data: { reason: { kind: 'completed' } },
- })
- await ctx.fiber.dispose()
- })
- it('resume appends interrupted-turn closers durably through the handle', async () => {
- // Lifecycle 1: store an interrupted log — an open turn with no turn/end.
- const sessionId = SessionId('interrupted-resume')
- const { ctx: ctx1, root } = await persistentHarness(new MockAdapter([]))
- await seedStoredSession(ctx1, sessionId, [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- ])
- // A read returns the PHYSICAL validated log: no synthetic closers.
- const raw = await readStoredEvents(ctx1, sessionId)
- expect(raw.map(event => event.type)).toEqual(['turn/start'])
- await ctx1.fiber.dispose()
- // Lifecycle 2: resume repairs the tail and stores the repair durably.
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([]))
- const handle = await ctx2.agents.resume({ resumeSessionId: sessionId })
- expect(handle.agent.session.snapshotEvents().map(event => event.type))
- .toEqual(['turn/start', 'turn/end', 'session/end-seed'])
- await handle.dispose()
- const stored = await readStoredEvents(ctx2, sessionId)
- expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed'])
- expect(stored[1]).toMatchObject({ data: { reason: { kind: 'interrupted' } } })
- await ctx2.fiber.dispose()
- })
- it('resume closes an interrupted tool call durably: tool/result, step/end, turn/end', async () => {
- const sessionId = SessionId('interrupted-tool-resume')
- const { ctx: ctx1, root } = await persistentHarness(new MockAdapter([]))
- await seedStoredSession(ctx1, sessionId, [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'step/start', seq: SessionSeq(1), time: 1, data: { turn: 1, step: 1 } },
- { type: 'assistant/message', seq: SessionSeq(2), time: 2, surfaceOp: 'append', data: {
- turn: 1, step: 1,
- stream: [],
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' }],
- source: { kind: 'model', provider: 'mock', model: 'mock' },
- }),
- } },
- { type: 'tool/call', seq: SessionSeq(3), time: 2, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } },
- ] as SessionEvent[])
- await ctx1.fiber.dispose()
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([]))
- const handle = await ctx2.agents.resume({ resumeSessionId: sessionId })
- await handle.dispose()
- // The multi-closer set lands durably in one contiguous batch, and the
- // synthetic tool/result cites the recorded tool/call seq.
- const stored = await readStoredEvents(ctx2, sessionId)
- expect(stored.map(event => event.type)).toEqual([
- 'turn/start', 'step/start', 'assistant/message', 'tool/call',
- 'tool/result', 'step/end', 'turn/end', 'session/end-seed',
- ])
- expect(stored.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(stored[4]).toMatchObject({
- sourceEventSeqs: [3],
- data: { error: { code: TOOL_OUTCOME_UNKNOWN } },
- })
- expect(stored[6]).toMatchObject({ data: { reason: { kind: 'interrupted' } } })
- await ctx2.fiber.dispose()
- })
- it('resume over a torn physical tail continues from the committed prefix', async () => {
- const sessionId = SessionId('torn-tail-resume')
- const root = await mkdtemp(join(tmpdir(), 'dsh-resume-torn-'))
- dirs.push(root)
- const ctx1 = await mountPersistentHarness(root, new MockAdapter([]), 'none')
- await seedStoredSession(ctx1, sessionId, [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- ])
- await ctx1.fiber.dispose()
- // Crash artifact: a half-written record with no trailing newline.
- const logs = (await readdir(root, { recursive: true })).filter(name => name.endsWith('.jsonl'))
- expect(logs).toHaveLength(1)
- await appendFile(join(root, logs[0] as string), '{"type":"assistant/chunk","seq":1,"ti')
- // Resume truncates the torn tail under its write open, then appends the
- // closers immediately after the committed prefix — no gap, no fragment.
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([]), 'none')
- const handle = await ctx2.agents.resume({ resumeSessionId: sessionId })
- expect(handle.agent.session.snapshotEvents().map(event => event.type))
- .toEqual(['turn/start', 'turn/end', 'session/end-seed'])
- await handle.dispose()
- const stored = await readStoredEvents(ctx2, sessionId)
- expect(stored.map(event => `${event.type}@${event.seq}`))
- .toEqual(['turn/start@0', 'turn/end@1', 'session/end-seed@2'])
- await ctx2.fiber.dispose()
- })
- it('createAgent works without meta (no cwd)', async () => {
- const adapter = new MockAdapter([textResponse('hi')])
- const { ctx } = await persistentHarness(adapter)
- const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') })
- expect(agent.session.id).toBe('nometa-session')
- expect(agent.session.header.cwd).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('resume of a session with no cwd carries an undefined cwd header', async () => {
- // Lifecycle 1: create a no-cwd session and run a turn.
- const adapter1 = new MockAdapter([textResponse('a')])
- const { ctx: ctx1, root } = await persistentHarness(adapter1)
- const h1 = await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })
- h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, h1.agent)
- // Agent disposal drains the writer through the still-open handle.
- await h1.dispose()
- await ctx1.fiber.dispose()
- // Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch).
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')]))
- const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent
- expect(a2.session.header.cwd).toBeUndefined()
- await ctx2.fiber.dispose()
- })
- it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
- // Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
- const adapter1 = new MockAdapter([textResponse('a')])
- const { ctx: ctx1, root } = await persistentHarness(adapter1)
- const sources1: string[] = []
- ctx1.on('agent/session-start', ({ source }) => void sources1.push(source))
- const h1 = await ctx1.agents.create({ sessionId: SessionId('start-sess') })
- expect(sources1).toEqual(['startup'])
- h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, h1.agent)
- await h1.dispose()
- await ctx1.fiber.dispose()
- // Lifecycle 2: resuming the persisted session emits session-start 'resume'.
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')]))
- const sources2: string[] = []
- ctx2.on('agent/session-start', ({ source }) => void sources2.push(source))
- await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
- expect(sources2).toEqual(['resume'])
- await ctx2.fiber.dispose()
- })
- it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
- const sessionId = SessionId('resume-setup-success')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const gate = Promise.withResolvers<undefined>()
- const setupStarted = Promise.withResolvers<undefined>()
- const order: string[] = []
- ctx.on('session/created', (session) => {
- expect(ctx.sessions.get(session.id)).toBe(session)
- expect(ctx.agents.get(sessionId)?.session).toBe(session)
- order.push('session/created')
- })
- ctx.on('agent/created', ({ agent }) => {
- expect(agent.status).toBe('idle')
- order.push('agent/created')
- })
- ctx.on('agent/session-start', ({ agent }) => {
- expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
- order.push('agent/session-start')
- })
- const resuming = ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- setup: async (agentCtx, agent) => {
- expect(agent.id).toBe(sessionId)
- // The two persisted events plus the end-seed marker.
- expect(agent.session.snapshotEvents()).toHaveLength(3)
- agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
- agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
- order.push('setup:start')
- setupStarted.resolve(undefined)
- await gate.promise
- order.push('setup:end')
- return {
- commit: () => {
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- order.push('setup:commit')
- },
- }
- },
- })
- await setupStarted.promise
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- expect(order).toEqual(['setup:start'])
- gate.resolve(undefined)
- const handle = await resuming
- expect(order).toEqual([
- 'setup:start',
- 'setup:end',
- 'setup:commit',
- 'session/created',
- 'setup-listener:session/created',
- 'agent/created',
- 'setup-listener:agent/created',
- 'agent/session-start',
- ])
- await handle.dispose()
- await ctx.fiber.dispose()
- })
- it('a resumed session stores its repair suffix durably before publication', async () => {
- // The stored fixture (two events, no end-seed) gains the end-seed marker
- // through the resume handle: after one resume lifecycle the STORED log
- // carries it, so the next resume reads it back without re-marking.
- const sessionId = SessionId('resume-suffix-durable')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const first = await ctx.agents.resume({ resumeSessionId: sessionId })
- await first.dispose()
- const stored = await readStoredEvents(ctx, sessionId)
- expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed'])
- const second = await ctx.agents.resume({ resumeSessionId: sessionId })
- expect(second.agent.session.snapshotEvents().map(event => event.type))
- .toEqual(['turn/start', 'turn/end', 'session/end-seed'])
- await second.dispose()
- const restored = await readStoredEvents(ctx, sessionId)
- expect(restored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed'])
- await ctx.fiber.dispose()
- })
- it('successful resume disposal retires its caller-owned transaction effects', async () => {
- const sessionId = SessionId('resume-retired-effects-s')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const handle = await ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- const transactionLabels = [`agentLoop.lifecycle(${sessionId})`]
- expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
- await handle.dispose()
- expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
- await ctx.fiber.dispose()
- })
- it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => {
- const sessionId = SessionId('resume-setup-reject')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const published: string[] = []
- ctx.on('session/created', () => void published.push('session/created'))
- ctx.on('agent/created', () => void published.push('agent/created'))
- ctx.on('agent/session-start', () => void published.push('agent/session-start'))
- await expect(ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- setup: async () => {
- await Promise.resolve()
- throw new Error('resume setup failed')
- },
- })).rejects.toThrow('resume setup failed')
- expect(published).toEqual([])
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- const retry = await ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- await retry.dispose()
- await ctx.fiber.dispose()
- })
- it('resume setup commit rejection publishes nothing and releases the identity', async () => {
- const sessionId = SessionId('resume-setup-commit-reject')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const published: string[] = []
- ctx.on('session/created', () => void published.push('session/created'))
- ctx.on('agent/created', () => void published.push('agent/created'))
- await expect(ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- setup: () => ({
- commit: () => { throw new Error('resume setup commit failed') },
- }),
- })).rejects.toThrow('resume setup commit failed')
- expect(published).toEqual([])
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- const retry = await ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- await retry.dispose()
- await ctx.fiber.dispose()
- })
- it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
- const sessionId = SessionId('resume-setup-owner-unload')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const gate = Promise.withResolvers<undefined>()
- const setupStarted = Promise.withResolvers<undefined>()
- const published: string[] = []
- ctx.on('session/created', () => void published.push('session/created'))
- ctx.on('agent/created', () => void published.push('agent/created'))
- let resuming!: ReturnType<typeof ctx.agents.resume>
- const owner = await ctx.plugin(Object.assign((inner: Context) => {
- resuming = inner.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- setup: async () => {
- setupStarted.resolve(undefined)
- await gate.promise
- },
- })
- }, { inject: ['agents'] }))
- await setupStarted.promise
- await owner.dispose()
- await expect(resuming).rejects.toThrow(/owner disposed during setup/)
- expect(published).toEqual([])
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- gate.resolve(undefined)
- await Promise.resolve()
- expect(published).toEqual([])
- await ctx.fiber.dispose()
- })
- it('owner unload aborts a never-settling persistence open, releases the identity, and blocks late publication', async () => {
- const sessionId = SessionId('resume-load-owner-unload')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const lateOpen = Promise.withResolvers<SessionHandle>()
- const openStarted = Promise.withResolvers<undefined>()
- const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence)
- let opens = 0
- ctx.sessionPersistence.open = (id, access, options) => {
- expect(id).toBe(sessionId)
- opens += 1
- if (opens === 1) {
- openStarted.resolve(undefined)
- return lateOpen.promise
- }
- return originalOpen(id, access, options)
- }
- const published: string[] = []
- ctx.on('session/created', () => void published.push('session/created'))
- ctx.on('agent/created', () => void published.push('agent/created'))
- ctx.on('agent/session-start', () => void published.push('agent/session-start'))
- let resuming!: ReturnType<typeof ctx.agents.resume>
- const owner = await ctx.plugin(Object.assign((inner: Context) => {
- resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
- }, { inject: ['agents'] }))
- await openStarted.promise
- const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
- await promptly(owner.dispose())
- expect(published).toEqual([])
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- // owner.dispose() awaited transaction settlement, so the same identities
- // can be reused before awaiting the public rejection.
- const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
- await rejection
- expect(opens).toBe(2)
- expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
- // Settlement of the abandoned backend open cannot resume the old
- // transaction: the late handle is closed, and no second publication lands
- // after the retry owns the ids.
- const abandoned = abandonedHandleStub()
- lateOpen.resolve(abandoned.handle)
- await expect.poll(() => abandoned.close.mock.calls.length).toBe(1)
- expect(ctx.agents.get(sessionId)).toBe(retry.agent)
- expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
- expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
- await retry.dispose()
- await ctx.fiber.dispose()
- })
- it('AgentLoop unload aborts a never-settling persistence open and awaits wrapper settlement', async () => {
- const sessionId = SessionId('resume-load-factory-unload')
- const root = await persistSession(sessionId)
- const ctx = new Context()
- await ctx.plugin(LlmRuntime)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(JsonlSessionPersistence, { root })
- ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
- const lateOpen = Promise.withResolvers<SessionHandle>()
- const openStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.open = (id) => {
- expect(id).toBe(sessionId)
- openStarted.resolve(undefined)
- return lateOpen.promise
- }
- const published: string[] = []
- ctx.on('session/created', () => void published.push('session/created'))
- ctx.on('agent/created', () => void published.push('agent/created'))
- const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
- await openStarted.promise
- const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
- await promptly(loopFiber.dispose())
- await rejection
- expect(published).toEqual([])
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- // The abandoned handle is closed once the hung open finally settles.
- const abandoned = abandonedHandleStub()
- lateOpen.resolve(abandoned.handle)
- await expect.poll(() => abandoned.close.mock.calls.length).toBe(1)
- expect(published).toEqual([])
- await ctx.fiber.dispose()
- })
- it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => {
- // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
- // in its header) through createAgent with a complete-turn seed — the
- // factory stores the header and seed through its write handle.
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- const { ctx: ctx1, root } = await persistentHarness(new MockAdapter([]))
- const forked = await ctx1.agents.create({
- sessionId: SessionId('forked-sess'),
- seed,
- meta: { cwd: '/w', parentSession: SessionId('parent-sess'), isSeeded: true, delegationDepth: 1 },
- inheritedEventCount: SessionLogOffset(seed.length),
- })
- await forked.dispose()
- await ctx1.fiber.dispose()
- // Lifecycle 2: resume it; the parentSession + seedLength header survives the
- // round-trip (exercises resume's parentSession- and seedLength-present
- // branches). seedLength must come from the PERSISTED header, not from the
- // resume seed length (which is the whole stored log, not the original
- // boundary).
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')]))
- const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent
- expect(a2.session.header.parentSession).toBe('parent-sess')
- expect(a2.session.header.cwd).toBe('/w')
- expect(a2.session.header.isSeeded).toBe(true)
- expect(a2.session.inheritedEventCount).toBe(seed.length)
- // The recursion budget survives resume — a dropped depth would let a
- // resumed child delegate as if it were top-level.
- expect(a2.session.header.delegationDepth).toBe(1)
- await ctx2.fiber.dispose()
- })
- // The crash simulation removes the wedged lifecycle's lock file, which only
- // POSIX's orphan-inode forfeit honors; Windows pins the name until the
- // process exits, and cross-process crash release is pinned by the jsonl
- // two-process e2e.
- it.skipIf(process.platform === 'win32')('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
- const adapter1 = new MockAdapter([textResponse('answer')])
- const { ctx: ctx1, root } = await persistentHarness(adapter1)
- const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' }, agentOptions: { provider: 'mock', model: 'mock' } })).agent
- a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, a1)
- a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
- await a1.whenIdle()
- await ctx1.sessions.flush(a1.session)
- // Simulate a wedged first lifecycle: a graceful dispose would durably
- // discard the pending inject, and the still-open kernel write lock would
- // otherwise exclude the second lifecycle. Removing the lock file orphans
- // the held inode so the resumer locks a fresh one (the documented
- // forfeit-by-unlink escape hatch).
- await removeSessionLocks(root)
- // Lifecycle 2: resume; the injected context is still pending and becomes
- // model-visible when the next turn admits it.
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
- const stored = await readStoredEvents(ctx2, SessionId('inject-sess'))
- expect(stored.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
- expect(JSON.stringify(stored)).toContain('background job 42 finished')
- const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess'), agentOptions: { provider: 'mock', model: 'mock' } })).agent
- expect(JSON.stringify(a2.inbox.nextStep)).toContain('background job 42 finished')
- a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
- await waitForIdle(ctx2, a2)
- const flat = JSON.stringify(a2.session.deriveMessages())
- expect(flat).toContain('background job 42 finished')
- await ctx2.fiber.dispose()
- await ctx1.fiber.dispose()
- })
- it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
- // Lifecycle 1: run one full turn, persisting it.
- const adapter1 = new MockAdapter([textResponse('first answer')])
- const { ctx: ctx1, root } = await persistentHarness(adapter1)
- const h1 = await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })
- const a1 = h1.agent
- a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, a1)
- const events1 = a1.session.snapshotEvents()
- const seqs1 = events1.map(e => e.seq)
- expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous
- await h1.dispose()
- await ctx1.fiber.dispose()
- // Lifecycle 2: a brand-new context over the SAME root; resume the session.
- const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('second answer')]))
- const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
- // The resumed session carries the prior history…
- expect(a2.session.id).toBe('sess-resume')
- // …followed by one end-seed event marking the constructor seed.
- expect(a2.session.snapshotEvents().length).toBe(events1.length + 1)
- expect(a2.session.firstLiveSeq).toBe(events1.length)
- expect(a2.session.snapshotEvents().at(-1)?.type).toBe('session/end-seed')
- const replay = Session.create(SessionId('replay'), events1)
- expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
- // …and a new turn continues numbering (turn 2) with contiguous seqs.
- a2.followup(createUserMessage({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } }))
- await waitForIdle(ctx2, a2)
- const allSeqs = a2.session.snapshotEvents().map(e => e.seq)
- expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
- const turnStarts = a2.session.snapshotEvents().filter(e => e.type === 'turn/start')
- expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2])
- await ctx2.fiber.dispose()
- })
- it('resume rejects when session persistence is not configured', async () => {
- // A harness WITHOUT the persistence plugin.
- const adapter = new MockAdapter([textResponse('x')])
- const ctx = new Context()
- await ctx.plugin(LlmRuntime)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(AgentLoop, { agents: [] })
- ctx.llm.registerAdapter(['mock'], adapter)
- await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') }))
- .rejects.toThrow(/session persistence is not configured/)
- await ctx.fiber.dispose()
- })
- })
- describe('creation and resume cancellation edges', () => {
- it('rejects create() with a pre-aborted signal, including a non-Error reason', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const errorReason = new AbortController()
- errorReason.abort(new Error('caller gave up'))
- await expect(promptly(ctx.agents.create({
- sessionId: SessionId('pre-aborted-error'),
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: errorReason.signal,
- }))).rejects.toThrow('caller gave up')
- // A non-Error reason is wrapped into the creation-aborted error.
- const stringReason = new AbortController()
- stringReason.abort('operator string reason')
- await expect(promptly(ctx.agents.create({
- sessionId: SessionId('pre-aborted-string'),
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: stringReason.signal,
- }))).rejects.toThrow(/creation aborted/)
- expect(ctx.agents.get(SessionId('pre-aborted-error'))).toBeUndefined()
- expect(ctx.agents.get(SessionId('pre-aborted-string'))).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('a non-Error abort reason arriving during setup is wrapped for the caller', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const controller = new AbortController()
- const setupEntered = Promise.withResolvers<undefined>()
- const setupGate = Promise.withResolvers<undefined>()
- const creating = ctx.agents.create({
- sessionId: SessionId('setup-string-abort'),
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: controller.signal,
- async setup() {
- setupEntered.resolve(undefined)
- await setupGate.promise
- },
- })
- await setupEntered.promise
- controller.abort('mid-setup string reason')
- setupGate.resolve(undefined)
- await expect(promptly(creating)).rejects.toThrow(/creation aborted/)
- expect(ctx.agents.get(SessionId('setup-string-abort'))).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('rejects when setup synchronously aborts its caller signal', async () => {
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const controller = new AbortController()
- const creating = ctx.agents.create({
- sessionId: SessionId('setup-synchronous-abort'),
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: controller.signal,
- setup() {
- controller.abort(new Error('setup synchronously cancelled'))
- },
- })
- await expect(promptly(creating)).rejects.toThrow('setup synchronously cancelled')
- expect(ctx.agents.get(SessionId('setup-synchronous-abort'))).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('resume with a pre-aborted caller signal rejects out of the load race', async () => {
- const sessionId = SessionId('resume-pre-aborted')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const controller = new AbortController()
- controller.abort(new Error('resume abandoned'))
- await expect(promptly(ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: controller.signal,
- }))).rejects.toThrow('resume abandoned')
- const stringReason = new AbortController()
- stringReason.abort('resume string reason')
- await expect(promptly(ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: stringReason.signal,
- }))).rejects.toThrow(/creation aborted/)
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('an abort landing between crash repair and publication refuses resume, normalized', async () => {
- // Cover the publication-time abort backstop: the signal fires AFTER the
- // raced open settled (during the closer append), so only the final
- // pre-publication check can refuse.
- const run = async (suffix: string, reason: unknown): Promise<unknown> => {
- const sessionId = SessionId(`late-abort-${suffix}`)
- const { ctx: seedCtx, root } = await persistentHarness(new MockAdapter([]))
- await seedStoredSession(seedCtx, sessionId, [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- ])
- await seedCtx.fiber.dispose()
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const controller = new AbortController()
- const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence)
- ctx.sessionPersistence.open = async (id, access, options) => {
- const handle = await originalOpen(id, access, options)
- const realAppend = handle.append.bind(handle)
- Object.defineProperty(handle, 'append', {
- value: (events: readonly SessionEvent[]) => {
- controller.abort(reason)
- return realAppend(events)
- },
- })
- return handle
- }
- const rejection = await ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: controller.signal,
- }).then(() => undefined, (error: unknown) => error)
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- await ctx.fiber.dispose()
- return rejection
- }
- expect(await run('error', new Error('late abort error'))).toMatchObject({ message: 'late abort error' })
- expect(await run('string', 'late abort string')).toMatchObject({ message: expect.stringMatching(/creation aborted/) as unknown })
- })
- it('an aborted create closes the write handle its abandoned backend create later resolves', async () => {
- const sessionId = SessionId('create-abandoned-handle')
- const { ctx } = await persistentHarness(new MockAdapter([]))
- const lateCreate = Promise.withResolvers<SessionHandle>()
- const createStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.create = () => {
- createStarted.resolve(undefined)
- return lateCreate.promise
- }
- const controller = new AbortController()
- const creating = ctx.agents.create({ sessionId, signal: controller.signal })
- await createStarted.promise
- controller.abort(new Error('caller aborted create'))
- await expect(creating).rejects.toThrow('caller aborted create')
- expect(ctx.sessions.get(sessionId)).toBeUndefined()
- // The abandoned backend create still resolves a real write handle later;
- // the loop closes it so ownership is not leaked, and a rejecting close is
- // swallowed — there is no owner left to observe it.
- const close = vi.fn(async () => { throw new Error('abandoned close failed') })
- lateCreate.resolve({ append: async () => {}, close } as unknown as SessionHandle)
- await expect.poll(() => close.mock.calls.length).toBe(1)
- await ctx.fiber.dispose()
- })
- it('closes the resume write handle when the loop becomes inactive before setup', async () => {
- const sessionId = SessionId('resume-loop-inactive-after-open')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const loop = ctx.agentLoop as unknown as {
- ownership: { isActive: () => boolean }
- }
- vi.spyOn(loop.ownership, 'isActive').mockReturnValueOnce(false)
- await expect(ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })).rejects.toThrow('agent loop is not active')
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- // The already-open handle was closed on the inactive path: a retry can
- // claim write ownership again.
- const retry = await ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- await retry.dispose()
- await ctx.fiber.dispose()
- })
- it('factory teardown during a hung resume open rejects promptly and closes the late handle', async () => {
- const sessionId = SessionId('resume-loop-teardown')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const gate = Promise.withResolvers<SessionHandle>()
- const openStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.open = () => {
- openStarted.resolve(undefined)
- return gate.promise
- }
- const resuming = ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- await openStarted.promise
- // Resolve the open only after teardown began: the abandoned handle must
- // still be released even though the wrapper already rejected.
- const rejection = expect(promptly(resuming)).rejects.toThrow()
- const disposal = ctx.fiber.dispose()
- const abandoned = abandonedHandleStub()
- gate.resolve(abandoned.handle)
- await rejection
- await disposal
- await expect.poll(() => abandoned.close.mock.calls.length).toBe(1)
- })
- })
- describe('configured-start failure edges', () => {
- it('a non-Error mid-open abort reason is wrapped for the resume caller', async () => {
- const sessionId = SessionId('resume-string-mid-abort')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const gate = Promise.withResolvers<never>()
- gate.promise.catch(() => undefined)
- const openStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.open = () => {
- openStarted.resolve(undefined)
- return gate.promise
- }
- const controller = new AbortController()
- const resuming = ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- signal: controller.signal,
- })
- await openStarted.promise
- controller.abort('operator string reason')
- await expect(promptly(resuming)).rejects.toThrow(/creation aborted/)
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('a failing exact-id restore over an existing artifact stays loud', async () => {
- const sessionId = SessionId('config-existing-corrupt')
- const root = await persistSession(sessionId)
- const configured = new Context()
- await configured.plugin(LlmRuntime)
- await configured.plugin(SessionStore)
- await configured.plugin(SessionProjectionRegistry)
- await configured.plugin(SystemPrompt)
- await configured.plugin(ToolRuntime)
- await configured.plugin(AgentRegistry)
- await configured.plugin(JsonlSessionPersistence, { root })
- configured.llm.registerAdapter(['mock'], new MockAdapter([]))
- // The artifact exists but its open fails with a NON-NotFound error: this is
- // corruption, not first creation — the failure must be reported, and no
- // fresh same-id session may shadow the broken one.
- configured.sessionPersistence.open = () => Promise.reject(new Error('artifact corrupt'))
- const configFailures: unknown[] = []
- configured.on('agent-loop/config-start-failed', ({ error }) => { configFailures.push(error) })
- const warn = vi.spyOn(configured.logger, 'warn').mockImplementation(() => undefined)
- const loop = await configured.plugin(AgentLoop, {
- agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }],
- })
- await expect.poll(() => configFailures.length).toBe(1)
- expect(configFailures[0]).toBeInstanceOf(Error)
- expect((configFailures[0] as Error).message).toBe('artifact corrupt')
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven restore'))
- expect(configured.agents.get(sessionId)).toBeUndefined()
- warn.mockRestore()
- await loop.dispose()
- await configured.fiber.dispose()
- })
- it('suppresses a configured-resume failure that lands after teardown', async () => {
- const sessionId = SessionId('config-late-resume-failure')
- const root = await persistSession(sessionId)
- const configured = new Context()
- await configured.plugin(LlmRuntime)
- await configured.plugin(SessionStore)
- await configured.plugin(SessionProjectionRegistry)
- await configured.plugin(SystemPrompt)
- await configured.plugin(ToolRuntime)
- await configured.plugin(AgentRegistry)
- await configured.plugin(JsonlSessionPersistence, { root })
- configured.llm.registerAdapter(['mock'], new MockAdapter([]))
- const gate = Promise.withResolvers<never>()
- gate.promise.catch(() => undefined)
- const openStarted = Promise.withResolvers<undefined>()
- configured.sessionPersistence.open = () => {
- openStarted.resolve(undefined)
- return gate.promise
- }
- const failures: unknown[] = []
- configured.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
- const loop = await configured.plugin(AgentLoop, {
- agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
- })
- await openStarted.promise
- const disposal = loop.dispose()
- gate.reject(new Error('late backend failure'))
- await disposal
- await new Promise(r => setTimeout(r, 20))
- // Ownership deactivated before the failure landed: the report is dropped.
- expect(failures).toEqual([])
- await configured.fiber.dispose()
- })
- })
|