import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context, symbols, type EffectMeta, type Fiber } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return { ctx, loopFiber } } async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise { return (await harnessWithLoop(adapter)).ctx } function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose() resolve() } }) }) } const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] /** Throw an arbitrary callback value to exercise the public unknown-error boundary. */ function throwUnknown(value: unknown): never { throw value } /** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */ function disposeCurrentLifecycle(ownerCtx: Context): void { const lifecycle = [...ownerCtx.fiber._disposables] .find((dispose) => { const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect] return effect?.label.startsWith('agentLoop.lifecycle(') === true }) if (lifecycle === undefined) throw new Error('agent lifecycle effect not found') void lifecycle() } describe('agent scope lifecycle', () => { it('rejects an already-aborted creation signal before publishing either object', async () => { const ctx = await harness() const reason = new Error('cancelled before creation') const controller = new AbortController() controller.abort(reason) await expect(ctx.agents.create({ sessionId: SessionId('pre-aborted-s'), signal: controller.signal, })).rejects.toBe(reason) expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined() const valueController = new AbortController() valueController.abort('plain cancellation reason') await expect(ctx.agents.create({ sessionId: SessionId('pre-aborted-value-s'), signal: valueController.signal, })).rejects.toMatchObject({ message: 'agent "pre-aborted-value-s" creation aborted', cause: 'plain cancellation reason', }) expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('joins cleanup when an abort lands reentrantly during scope preparation', async () => { const ctx = await harness() 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: SessionId('prepare-abort-s'), signal: controller.signal, })).rejects.toBe(reason) expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('normalizes non-Error create failures for rollback while rethrowing the original value', async () => { const ctx = await harness() let thrown: unknown ctx.on('session/created', () => { if (thrown === undefined) return const value = thrown thrown = undefined throwUnknown(value) }) const createFailure = { source: 'create' } thrown = createFailure let createCaught: unknown try { ctx.agentLoop.create(SessionId('unknown-create')) } catch (error: unknown) { createCaught = error } expect(createCaught).toBe(createFailure) const ownedFailure = { source: 'createAgent' } thrown = ownedFailure await expect(ctx.agents.create({ sessionId: SessionId('unknown-owned-create-s'), })).rejects.toBe(ownedFailure) expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined() expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() await ctx.agents.get(SessionId('a1'))?.whenIdle() }) it('records agents created through an agent context as non-root runtime children', async () => { const ctx = await harness() const root = await ctx.agents.create({ sessionId: SessionId('runtime-root'), agentOptions: { model: 'mock' }, }) const child = await root.agent.ctx.agents.create({ sessionId: SessionId('runtime-child'), agentOptions: { model: 'mock' }, }) expect(ctx.agents.list()).toEqual([root.agent, child.agent]) expect(ctx.agents.roots()).toEqual([root.agent]) await child.dispose() await root.dispose() }) it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) agent.ctx.tools.register(defineContentToolFixture({ name: 'mine', description: 'scoped', parameters: {}, execute: () => Promise.resolve(text('ran')), })) const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.') expect(scopedAssembly.tools.map(t => t.name)).toContain('mine') // Other assemblies are untouched. const globalAssembly = await ctx.systemPrompt.assemble() expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.') expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine') await handle.dispose() // The scoped world unwound with the agent: nothing leaked into the registries. expect(ctx.tools.get('mine', agent)).toBeUndefined() const after = await ctx.systemPrompt.assemble(assembleContextFor(agent)) expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.') }) it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) a.ctx.on('session/event', (_s, event) => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) b.followup(createUserMessage({ content: text('for b'), source: { kind: 'user' } })) await waitForIdle(ctx, b) expect(heard).toEqual([]) // nothing of b's leaked into a's scope a.followup(createUserMessage({ content: text('for a'), source: { kind: 'user' } })) await waitForIdle(ctx, a) expect(heard).toContain('a-sees:a:running') expect(heard).toContain('a-sees:user-message') }) it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => { const ctx = await harness() const order: string[] = [] ctx.on('agent/session-start', (agent) => { order.push('session-start') // The scoped section is already registered by the time session-start fires. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`) }) }) const handle = await ctx.agents.create({ sessionId: SessionId('child-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { order.push('setup') await Promise.resolve() agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' }) }, }) await new Promise(resolve => setTimeout(resolve, 0)) expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.']) await handle.dispose() }) it('keeps both objects unpublished until async setup completes, then announces in order', async () => { const ctx = await harness() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const order: string[] = [] ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) expect(ctx.agents.get(session.id)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', () => void order.push('agent/created')) ctx.on('agent/session-start', () => void order.push('agent/session-start')) const acceptedOptions = { provider: 'mock', model: 'mock' } const creating = ctx.agents.create({ sessionId: SessionId('atomic'), agentOptions: acceptedOptions, setup: async (agentCtx) => { expect(agentCtx.agent?.id).toBe(SessionId('atomic')) 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') }, }) await setupStarted.promise expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() expect(order).toEqual(['setup:start']) gate.resolve(undefined) const handle = await creating expect(handle.agent.options).toBe(acceptedOptions) expect(order).toEqual([ 'setup:start', 'setup:end', 'session/created', 'setup-listener:session/created', 'agent/created', 'setup-listener:agent/created', 'agent/session-start', ]) await handle.dispose() }) it('lets the final enter arbitrate unsupported concurrent same-id creation and rolls the loser back', async () => { const ctx = await harness() const gate = Promise.withResolvers() const bothStarted = Promise.withResolvers() let started = 0 const setup = async (): Promise => { started += 1 if (started === 2) bothStarted.resolve(undefined) await gate.promise } const sessionId = SessionId('concurrent-final-enter') const first = ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup, }) const second = ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup, }) await bothStarted.promise expect(ctx.agents.list()).toEqual([]) expect(ctx.sessions.list()).toEqual([]) gate.resolve(undefined) const outcomes = await Promise.allSettled([first, second]) const fulfilled = outcomes.filter((outcome): outcome is PromiseFulfilledResult> => outcome.status === 'fulfilled') const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) expect(String(rejected[0]!.reason)).toMatch(/already exists/) expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent]) expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session]) await fulfilled[0]!.value.dispose() expect(ctx.agents.list()).toEqual([]) expect(ctx.sessions.list()).toEqual([]) }) it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => { const ctx = await harness() const pendingController = new AbortController() const setupStarted = Promise.withResolvers() const pending = ctx.agents.create({ sessionId: SessionId('signal-pending-s'), agentOptions: { provider: 'mock', model: 'mock' }, signal: pendingController.signal, setup: async () => { setupStarted.resolve(undefined) await new Promise(() => {}) }, }) await setupStarted.promise pendingController.abort(new Error('cancel pending creation')) await expect(pending).rejects.toThrow('cancel pending creation') expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined() const liveController = new AbortController() const live = await ctx.agents.create({ sessionId: SessionId('signal-live-s'), agentOptions: { provider: 'mock', model: 'mock' }, signal: liveController.signal, }) liveController.abort(new Error('too late')) await Promise.resolve() expect(ctx.agents.get(live.agent.id)).toBe(live.agent) expect(live.agent.status).toBe('idle') await live.dispose() }) it('owner unload aborts a pending setup and publishes nothing', async () => { const ctx = await harness() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ sessionId: SessionId('owner-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { setupStarted.resolve(undefined) await gate.promise }, }) }, { inject: ['agents'] })) await setupStarted.promise await owner.dispose() await expect(creating).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined() // Let the losing callback settle; Promise.race already observes it. gate.resolve(undefined) await Promise.resolve() // The other ordering in the same race: setup resolves first (its reaction // is queued), then owner disposal flips active before that continuation can // publish. The post-race active check must still reject. const gate2 = Promise.withResolvers() const setupStarted2 = Promise.withResolvers() let creating2!: ReturnType const owner2 = await ctx.plugin(Object.assign((inner: Context) => { creating2 = inner.agents.create({ sessionId: SessionId('owner-race-s-2'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { setupStarted2.resolve(undefined) await gate2.promise }, }) }, { inject: ['agents'] })) await setupStarted2.promise gate2.resolve(undefined) const unload2 = owner2.dispose() await expect(creating2).rejects.toThrow(/owner disposed during setup/) await unload2 expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() }) it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => { const { ctx, loopFiber } = await harnessWithLoop() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) const creating = ctx.agents.create({ sessionId: SessionId('factory-setup-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { setupStarted.resolve(undefined) await gate.promise }, }) await setupStarted.promise await loopFiber.dispose() await expect(creating).rejects.toThrow(/agent loop is not active/) expect(published).toEqual([]) expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() gate.resolve(undefined) await ctx.fiber.dispose() }) it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => { const { ctx, loopFiber } = await harnessWithLoop() let unloaded = false let setupCalls = 0 ctx.on('internal/plugin', (fiber) => { if (unloaded || fiber.name !== 'scope') return unloaded = true void loopFiber.dispose() }) const creating = ctx.agents.create({ sessionId: SessionId('factory-scope-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: () => { setupCalls += 1 }, }) await expect(creating).rejects.toThrow(/agent loop is not active/) await loopFiber.dispose() expect(setupCalls).toBe(1) expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('caller unload during scope minting owns and drains the half-built child', async () => { const ctx = await harness() const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() let ownerFiber!: Fiber let ownerDisposal!: Promise let scopeFiber: Fiber | undefined let creating!: ReturnType ctx.on('internal/plugin', (fiber) => { if (fiber.name !== 'scope' || scopeFiber !== undefined) return scopeFiber = fiber fiber.ctx.effect(() => async () => { cleanupStarted.resolve(undefined) await gate.promise }) ownerDisposal = ownerFiber.dispose() }) const owner = ctx.plugin(Object.assign((inner: Context) => { ownerFiber = inner.fiber creating = inner.agents.create({ sessionId: SessionId('caller-scope-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) }, { inject: ['agents'] })) await cleanupStarted.promise let ownerSettled = false void ownerDisposal.then(() => { ownerSettled = true }) await Promise.resolve() expect(ownerSettled).toBe(false) gate.resolve(undefined) await expect(creating).rejects.toThrow(/owner disposed during setup/) await ownerDisposal await owner expect(scopeFiber?.uid).toBeNull() expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined() await owner.dispose() await ctx.fiber.dispose() }) it('synchronous create rechecks provider liveness before its first publication edge', async () => { const { ctx, loopFiber } = await harnessWithLoop() const sessionsBefore = ctx.sessions.list().length let unloaded = false let unloading!: Promise ctx.on('internal/plugin', (fiber) => { if (unloaded || fiber.name !== 'scope') return unloaded = true unloading = loopFiber.dispose() }) ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }) await unloading expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true) expect(ctx.sessions.list().length).toBe(sessionsBefore) await ctx.fiber.dispose() }) it('synchronous create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() const id = SessionId('config-prepare-failure') expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' })) .toThrow(/absolute path/) const replacement = ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' }) expect(ctx.agents.get(id)).toBe(replacement) await replacement.whenIdle() await ctx.fiber.dispose() }) it('factory unload awaits provisional cleanup when scope preparation throws', async () => { const { ctx, loopFiber } = await harnessWithLoop() let triggered = false ctx.on('internal/plugin', (fiber) => { if (triggered || fiber.name !== 'scope') return triggered = true void loopFiber.dispose() throw new Error('scope preparation failed') }) await expect(ctx.agents.create({ sessionId: SessionId('factory-scope-throw-s'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('scope preparation failed') await loopFiber.dispose() expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => { const { ctx, loopFiber } = await harnessWithLoop() const loop = ctx.agentLoop const sessionId = SessionId('factory-live') const handle = await ctx.agents.create({ sessionId: SessionId('factory-live-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) await loopFiber.dispose() expect(handle.agent.status).toBe('idle') expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([]) // The consumer handle shares the provider's completed quiescence boundary. await handle.dispose() await expect(loop.createAgent(ctx, { sessionId: SessionId('factory-inactive-s'), })).rejects.toThrow(/agent loop is not active|inactive context/) await ctx.fiber.dispose() }) it('keeps AgentLoop dependencies available when the caller injects only agents', async () => { const ctx = await harness() let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ sessionId: SessionId('dependency-origin-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { agentCtx.tools.register(defineContentToolFixture({ name: 'dependency-origin-tool', description: 'proves AgentLoop dependency origin', parameters: {}, execute: () => Promise.resolve(text('ok')), })) agentCtx.systemPrompt.section({ name: 'dependency-origin-section', order: 1, text: 'factory dependency surface', }) }, }) }, { inject: ['agents'] })) const handle = await creating const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent)) expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool') expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section') await handle.dispose() await owner.dispose() await ctx.fiber.dispose() }) it('keeps both entries and the scope live through a reentrant session/created teardown', async () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType const lifecycle: string[] = [] ctx.on('session/created', (session) => { if (session.id !== SessionId('session-created-barrier-s')) return lifecycle.push('session-created:dispose') disposeCurrentLifecycle(ownerCtx) }) ctx.on('session/created', (session) => { if (session.id !== SessionId('session-created-barrier-s')) return const agent = ctx.agents.get(SessionId('session-created-barrier-s'))! expect(ctx.sessions.get(session.id)).toBe(session) expect(agent.session).toBe(session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('session-created:observer') }) ctx.on('agent/created', () => void lifecycle.push('agent-created')) ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed')) ctx.on('session/disposed', (session) => { if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed') }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ sessionId: SessionId('session-created-barrier-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) }, { inject: ['agents'] })) await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(lifecycle).toEqual([ 'session-created:dispose', 'session-created:observer', 'scope-disposed', 'session-disposed', ]) expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType const lifecycle: string[] = [] ctx.on('session/created', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) ctx.on('agent/created', (agent) => { if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/created', (agent) => { if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) ctx.on('agent/disposed', (agent) => { if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed') }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ sessionId: SessionId('agent-created-barrier-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) }, { inject: ['agents'] })) await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(lifecycle).toEqual([ 'session-created', 'agent-created:dispose', 'agent-created:observer', 'scope-disposed', 'agent-disposed', 'session-disposed', ]) expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('rechecks caller liveness after creation listeners before unlocking the driver', async () => { const ctx = await harness() const starts: string[] = [] let ownerCtx!: Context let creating!: ReturnType ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/created', (agent) => { if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx) }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ sessionId: SessionId('listener-dispose-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) }, { inject: ['agents'] })) await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(starts).toEqual([]) expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true) expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true) await ctx.fiber.dispose() }) it('rechecks caller liveness after session-start before starting the driver', async () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType let announced!: Agent const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false ctx.on('agent/status', (agent, status) => { if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) ctx.on('agent/session-start', (agent) => { if (agent.id !== SessionId('session-start-dispose-s')) return announced = agent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { scopeDisposed = true }) observerSawLive = true }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ sessionId: SessionId('session-start-dispose-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) }, { inject: ['agents'] })) await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(announced.status).toBe('idle') expect(statuses).toEqual([]) expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) expect(announced.session.events).toEqual([]) expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => { const ctx = await harness() 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.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { await Promise.resolve() throw new Error('boom setup') }, })).rejects.toThrow('boom setup') // Nothing leaked: no agent, no session, and the ids are reusable. expect(published).toEqual([]) expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) await retry.dispose() }) it('rejects an exotic durable seed before publishing either object', async () => { const ctx = await harness() const published: string[] = [] ctx.on('session/created', () => { published.push('session') }) ctx.on('agent/created', () => { published.push('agent') }) class ExoticData { readonly value = 'not durable JSON' } const seed = [{ seq: 0, type: 'test/exotic-seed', data: new ExoticData(), }] as unknown as SessionEvent[] await expect(ctx.agents.create({ sessionId: SessionId('exotic-seed-session'), agentOptions: { provider: 'mock', model: 'mock' }, seed, })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/) expect(published).toEqual([]) expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined() const retry = await ctx.agents.create({ sessionId: SessionId('exotic-seed-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) await retry.dispose() }) it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => { const ctx = await harness() let boom = true const disposed: string[] = [] ctx.on('agent/disposed', agent => void disposed.push(agent.id)) ctx.on('session/created', () => { if (boom) { boom = false; throw new Error('boom created') } }) await expect(ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('boom created') expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge // The rollback also disposed the scope fiber: re-creating works cleanly. const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) await retry.dispose() }) it('pairs session and agent announcements when agent creation aborts publication', async () => { const ctx = await harness() const lifecycle: string[] = [] ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) }) ctx.on('agent/created', (agent) => { lifecycle.push(`agent-created:${agent.id}`) throw new Error('agent observer failed') }) ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ sessionId: SessionId('partial-session'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('agent observer failed') expect(lifecycle).toEqual([ 'session-created:partial-session', 'agent-created:partial-session', 'agent-disposed:partial-session', 'session-disposed:partial-session', ]) expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() }) it('the synchronous config helper rolls back when publication throws', async () => { const ctx = await harness() const sessionsBefore = ctx.sessions.list().length let boom = true ctx.on('session/created', () => { if (boom) { boom = false throw new Error('config publish failed') } }) expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' })) .toThrow('config publish failed') await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true) await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore) }) it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) await handle.dispose() expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) }) it('agentEvents fuses carrier and subject for custom drivers', async () => { const ctx = await harness() const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1')) agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) expect(heard).toEqual(['a1:2']) }) it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) const { agent } = handle const order: string[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') order.push('turn-end') }) ctx.on('agent/disposed', () => { order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`) order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`) }) // Open a turn so disposal must drain real work before registry removal. // Waiting for turn/start avoids pre-step disposal dropping the queued prompt // before a turn opens. const turnOpen = new Promise((resolve) => { const off = ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') { off(); resolve() } }) }) agent.followup(createUserMessage({ content: text('work'), source: { kind: 'user' } })) await turnOpen await owner.dispose() expect(order).toEqual([ 'turn-end', 'disposed(listed=false)', 'session-still-stored=true', ]) expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined() }) it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) const teardownDone: string[] = [] ctx.on('agent/disposed', () => void teardownDone.push('unregistered')) // Owner unload begins FIRST (invokes the raw cordis wrapper)… const unload = owner.dispose() // …and a concurrent handle.dispose() must not resolve before the chain // actually finished (the raw wrapper returns undefined on a repeat call). await handle.dispose() expect(teardownDone).toContain('unregistered') expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) it('successful handle disposal retires its caller ownership effect', async () => { const ctx = await harness() const sessionId = SessionId('retired-owner-effect') const handle = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.lifecycle(${sessionId})`) await handle.dispose() expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([]) await ctx.fiber.dispose() }) it('owner unload after handle-first teardown follows the same in-flight boundary', async () => { const ctx = await harness() const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ sessionId: SessionId('manual-first-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup(agentCtx) { agentCtx.effect(() => async () => { cleanupStarted.resolve(undefined) await gate.promise }) }, }) }, { inject: ['agents'] })) const disposing = handle.dispose() await cleanupStarted.promise let ownerSettled = false const unloading = owner.dispose().then(() => { ownerSettled = true }) await Promise.resolve() expect(ownerSettled).toBe(false) gate.resolve(undefined) await Promise.all([disposing, unloading]) expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('reopens ids after the prior private scope finishes quiescing', async () => { const ctx = await harness() const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() const sessionId = SessionId('quiescent-reuse') const first = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup(agentCtx) { agentCtx.effect(() => async () => { cleanupStarted.resolve(undefined) await gate.promise }) }, }) const disposing = first.dispose() await cleanupStarted.promise expect(ctx.agents.get(sessionId)).toBe(first.agent) expect(ctx.sessions.get(sessionId)).toBe(first.agent.session) gate.resolve(undefined) await disposing expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) expect(ctx.agents.get(sessionId)).toBe(replacement.agent) expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session) await replacement.dispose() await ctx.fiber.dispose() }) it('drains a run re-entered by cancel\'s own idle transition before removing the scope', async () => { // Automation shaped like goal-session: the running→idle transition that // disposal's cancel produces immediately queues a follow-up prompt. The // teardown must drain that replacement run to true quiescence instead of // awaiting only the first captured done and unwinding under a live run. const adapter = new MockAdapter([textResponse('one'), textResponse('never awaited')]) const ctx = await harness(adapter) const handle = await ctx.agents.create({ sessionId: SessionId('drain-reentered-run'), agentOptions: { provider: 'mock', model: 'mock' }, }) const agent = handle.agent let reentered = false ctx.on('agent/status', (subject, status) => { if (subject !== agent || status !== 'idle' || reentered) return reentered = true agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })) }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(reentered).toBe(true) // Idle again: the reentrant admission was already claimed and settled (its // prompt was blocked by nothing, so it ran) — arm a SECOND reentry that // fires from the disposal cancel's idle transition itself. reentered = false await handle.dispose() // The reentrant run either never started or was drained: the registries // are empty and nothing still drives the detached session. expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.id)).toBeUndefined() const eventsAfter = agent.session.events.length await new Promise(resolve => setTimeout(resolve, 30)) expect(agent.session.events.length).toBe(eventsAfter) await ctx.fiber.dispose() }) })