| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882 |
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- import { afterEach, describe, expect, it } from 'vitest'
- import { Context } from 'cordis'
- import { mkdtemp, rm } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import LlmService from '@deepseek-ai/dsh-llm'
- import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRegistry from '@deepseek-ai/dsh-tools'
- import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
- import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
- import AgentLoop from '@deepseek-ai/dsh-agent-loop'
- 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): Promise<Context> {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(SessionPersistenceJsonl, { root })
- ctx.llm.registerAdapter(['mock'], adapter)
- return ctx
- }
- async function persistSession(sessionId: SessionId): Promise<string> {
- const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
- // 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: 0, time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- const session = ctx.sessions.create(sessionId, { seed })
- await ctx.sessions.flush(session)
- await ctx.fiber.dispose()
- return root
- }
- function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
- return new Promise((resolve) => {
- const dispose = ctx.on('agent/status', (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>(task: 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([task, 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('resumes a pre-react-loop session including pre-identity message events', async () => {
- const sessionId = SessionId('pre-identity-resume')
- const first = await persistentHarness(new MockAdapter([]))
- await first.ctx.sessionPersistence.create({
- version: SESSION_FORMAT_VERSION,
- id: sessionId,
- createdAt: 1,
- })
- await first.ctx.sessionPersistence.append(sessionId, [
- {
- type: 'turn/start', seq: 0, time: 1,
- data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
- },
- {
- type: 'user/message',
- seq: 1,
- time: 2,
- data: { content: [{ type: 'text', text: 'old question' }], 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: 'text', text: 'old answer' }],
- provenance: { provider: 'mock', model: 'mock' },
- },
- surfaceOp: 'append',
- },
- {
- type: 'steering/message',
- seq: 4,
- time: 5,
- data: {
- turn: 1,
- content: [{ type: 'text', text: 'old steering' }],
- source: { kind: 'user' },
- },
- surfaceOp: 'append',
- },
- { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
- { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
- ] as unknown as SessionEvent[])
- await first.ctx.fiber.dispose()
- const ctx = await mountPersistentHarness(first.root, new MockAdapter([textResponse('new answer')]))
- const handle = await ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- expect(handle.agent.session.deriveMessages()).toMatchObject([
- { id: `legacy-message:${sessionId}:1`, role: 'user' },
- { id: `legacy-message:${sessionId}:3`, role: 'assistant' },
- { id: `legacy-message:${sessionId}:4`, role: 'user' },
- ])
- expect(handle.agent.inbox.nextTurn).toEqual([])
- expect(handle.agent.inbox.nextStep).toEqual([])
- handle.agent.followup(createUserMessage({
- content: [{ type: 'text', text: 'new question' }],
- source: { kind: 'user' },
- }))
- await waitForIdle(ctx, handle.agent)
- expect(handle.agent.session.deriveMessages()).toHaveLength(5)
- expect(handle.agent.session.events.at(-1)).toMatchObject({
- type: 'turn/end',
- data: { reason: { kind: 'completed' } },
- })
- await handle.dispose()
- await ctx.fiber.dispose()
- })
- it('normalizes a non-Error resume publication failure for rollback and rethrows it', 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()
- 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('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(/live turn is open/)
- first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(first.session)
- const loaded = await ctx.sessionPersistence.load(sessionId)
- expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
- expect(loaded.events.at(-1)).toMatchObject({
- type: 'turn/end',
- data: { reason: { kind: 'completed' } },
- })
- await ctx.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 a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
- a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, a1)
- await ctx1.fiber.dispose()
- // Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch).
- const adapter2 = new MockAdapter([textResponse('b')])
- const ctx2 = new Context()
- await ctx2.plugin(LlmService)
- await ctx2.plugin(SessionStore)
- await ctx2.plugin(SystemPrompt)
- await ctx2.plugin(ToolRegistry)
- await ctx2.plugin(AgentRegistry)
- await ctx2.plugin(AgentLoop, { agents: [] })
- await ctx2.plugin(SessionPersistenceJsonl, { root })
- ctx2.llm.registerAdapter(['mock'], adapter2)
- 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', (_agent, source) => void sources1.push(source))
- const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
- expect(sources1).toEqual(['startup'])
- a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, a1)
- await ctx1.fiber.dispose()
- // Lifecycle 2: resuming the persisted session emits session-start 'resume'.
- const adapter2 = new MockAdapter([textResponse('b')])
- const ctx2 = new Context()
- await ctx2.plugin(LlmService)
- await ctx2.plugin(SessionStore)
- await ctx2.plugin(SystemPrompt)
- await ctx2.plugin(ToolRegistry)
- await ctx2.plugin(AgentRegistry)
- await ctx2.plugin(AgentLoop, { agents: [] })
- await ctx2.plugin(SessionPersistenceJsonl, { root })
- ctx2.llm.registerAdapter(['mock'], adapter2)
- const sources2: string[] = []
- ctx2.on('agent/session-start', (_agent, 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) => {
- expect(agentCtx.agent?.id).toBe(sessionId)
- // The two persisted events plus the end-seed marker.
- expect(agentCtx.agent?.session.events).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('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 load, 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 snapshot = await ctx.sessionPersistence.load(sessionId)
- const lateLoad = Promise.withResolvers<typeof snapshot>()
- const loadStarted = Promise.withResolvers<undefined>()
- let loads = 0
- ctx.sessionPersistence.load = (id) => {
- expect(id).toBe(sessionId)
- loads += 1
- if (loads === 1) {
- loadStarted.resolve(undefined)
- return lateLoad.promise
- }
- return Promise.resolve(structuredClone(snapshot))
- }
- 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 loadStarted.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(loads).toBe(2)
- expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
- // Settlement of the abandoned backend promise cannot resume the old
- // transaction or emit a second publication after the retry owns the ids.
- lateLoad.resolve(structuredClone(snapshot))
- await Promise.resolve()
- await Promise.resolve()
- 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 persistence load and awaits wrapper settlement', async () => {
- const sessionId = SessionId('resume-load-factory-unload')
- const root = await persistSession(sessionId)
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(AgentRegistry)
- const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(SessionPersistenceJsonl, { root })
- ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
- const snapshot = await ctx.sessionPersistence.load(sessionId)
- const lateLoad = Promise.withResolvers<typeof snapshot>()
- const loadStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.load = (id) => {
- expect(id).toBe(sessionId)
- loadStarted.resolve(undefined)
- return lateLoad.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 loadStarted.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()
- lateLoad.resolve(structuredClone(snapshot))
- await Promise.resolve()
- await Promise.resolve()
- 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) by creating it with a complete-turn seed — the write path
- // materializes the fork (header + seed) on disk.
- const seed: SessionEvent[] = [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- const adapter1 = new MockAdapter([textResponse('a')])
- const { ctx: ctx1, root } = await persistentHarness(adapter1)
- const forked = ctx1.sessions.create(SessionId('forked-sess'), {
- seed,
- meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
- })
- await ctx1.sessions.flush(forked)
- 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 adapter2 = new MockAdapter([textResponse('b')])
- const ctx2 = new Context()
- await ctx2.plugin(LlmService)
- await ctx2.plugin(SessionStore)
- await ctx2.plugin(SystemPrompt)
- await ctx2.plugin(ToolRegistry)
- await ctx2.plugin(AgentRegistry)
- await ctx2.plugin(AgentLoop, { agents: [] })
- await ctx2.plugin(SessionPersistenceJsonl, { root })
- ctx2.llm.registerAdapter(['mock'], adapter2)
- 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.seedLength).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()
- })
- it('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' } })).agent
- a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, a1)
- a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
- await a1.whenIdle()
- await ctx1.sessions.flush(a1.session)
- // Lifecycle 2: resume; the injected context is still pending and becomes
- // model-visible when the next turn admits it.
- const adapter2 = new MockAdapter([textResponse('next')])
- const ctx2 = new Context()
- await ctx2.plugin(LlmService)
- await ctx2.plugin(SessionStore)
- await ctx2.plugin(SystemPrompt)
- await ctx2.plugin(ToolRegistry)
- await ctx2.plugin(AgentRegistry)
- await ctx2.plugin(AgentLoop, { agents: [] })
- await ctx2.plugin(SessionPersistenceJsonl, { root })
- ctx2.llm.registerAdapter(['mock'], adapter2)
- const loaded = await ctx2.sessionPersistence.load(SessionId('inject-sess'))
- expect(loaded.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
- expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
- const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
- expect(JSON.stringify(a2.inbox.nextStep)).toContain('background task 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 task 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 a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
- a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } }))
- await waitForIdle(ctx1, a1)
- const events1 = [...a1.session.events]
- const seqs1 = events1.map(e => e.seq)
- expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous
- await ctx1.fiber.dispose()
- // Lifecycle 2: a brand-new context over the SAME root; resume the session.
- const adapter2 = new MockAdapter([textResponse('second answer')])
- const ctx2 = new Context()
- await ctx2.plugin(LlmService)
- await ctx2.plugin(SessionStore)
- await ctx2.plugin(SystemPrompt)
- await ctx2.plugin(ToolRegistry)
- await ctx2.plugin(AgentRegistry)
- await ctx2.plugin(AgentLoop, { agents: [] })
- await ctx2.plugin(SessionPersistenceJsonl, { root })
- ctx2.llm.registerAdapter(['mock'], adapter2)
- 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.events.length).toBe(events1.length + 1)
- expect(a2.session.firstLiveSeq).toBe(events1.length)
- expect(a2.session.events.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.events.map(e => e.seq)
- expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
- const turnStarts = a2.session.events.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(LlmService)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- 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('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')
- expect(ctx.agents.get(sessionId)).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('factory teardown during a hung resume load rejects with loop-inactive', async () => {
- const sessionId = SessionId('resume-loop-teardown')
- const root = await persistSession(sessionId)
- const ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const snapshot = await ctx.sessionPersistence.load(sessionId)
- const gate = Promise.withResolvers<typeof snapshot>()
- const loadStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.load = () => {
- loadStarted.resolve(undefined)
- return gate.promise
- }
- const resuming = ctx.agents.resume({
- resumeSessionId: sessionId,
- agentOptions: { provider: 'mock', model: 'mock' },
- })
- await loadStarted.promise
- // Resolve the load only after teardown began: the post-load ownership
- // check, not the abort race, must reject the wrapper.
- const rejection = expect(promptly(resuming)).rejects.toThrow()
- const disposal = ctx.fiber.dispose()
- gate.resolve(structuredClone(snapshot))
- await rejection
- await disposal
- })
- })
- describe('configured-start failure edges', () => {
- it('a non-Error mid-load 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 loadStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.load = () => {
- loadStarted.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 loadStarted.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 ctx = await mountPersistentHarness(root, new MockAdapter([]))
- // The artifact exists (list reports it) but its load fails: this is
- // corruption, not first creation — the failure must be reported, and no
- // fresh same-id session may shadow the broken one.
- ctx.sessionPersistence.load = () => Promise.reject(new Error('artifact corrupt'))
- const configured = new Context()
- await configured.plugin(LlmService)
- await configured.plugin(SessionStore)
- await configured.plugin(SystemPrompt)
- await configured.plugin(ToolRegistry)
- await configured.plugin(AgentRegistry)
- await configured.plugin(SessionPersistenceJsonl, { root })
- configured.llm.registerAdapter(['mock'], new MockAdapter([]))
- configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
- const configFailures: unknown[] = []
- configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) })
- const configWarnings: string[] = []
- const configWarn = configured.logger.warn.bind(configured.logger)
- configured.logger.warn = ((...args: unknown[]) => {
- if (typeof args[0] === 'string') configWarnings.push(args[0])
- return (configWarn as (...a: unknown[]) => unknown)(...args)
- }) as typeof configured.logger.warn
- 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(configWarnings.some(w => w.includes('config-driven restore'))).toBe(true)
- expect(configured.agents.get(sessionId)).toBeUndefined()
- await loop.dispose()
- await configured.fiber.dispose()
- await ctx.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 ctx = await mountPersistentHarness(root, new MockAdapter([]))
- const gate = Promise.withResolvers<never>()
- gate.promise.catch(() => undefined)
- const loadStarted = Promise.withResolvers<undefined>()
- ctx.sessionPersistence.load = () => {
- loadStarted.resolve(undefined)
- return gate.promise
- }
- const failures: unknown[] = []
- ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
- const configured = new Context()
- await configured.plugin(LlmService)
- await configured.plugin(SessionStore)
- await configured.plugin(SystemPrompt)
- await configured.plugin(ToolRegistry)
- await configured.plugin(AgentRegistry)
- await configured.plugin(SessionPersistenceJsonl, { root })
- configured.llm.registerAdapter(['mock'], new MockAdapter([]))
- configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
- configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
- const loop = await configured.plugin(AgentLoop, {
- agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
- })
- await loadStarted.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()
- await ctx.fiber.dispose()
- })
- })
|