refactor(core): simplify scoped agent lifecycles

This commit is contained in:
Tianyi Cui
2026-07-12 22:36:04 +08:00
parent e8fed4fb66
commit 28e04ff4fb
24 changed files with 1080 additions and 4097 deletions

View File

@@ -49,30 +49,15 @@ function send(agent: ReactLoopAgent, text: string) {
}
describe('ReactLoopAgent', () => {
it('owns immutable runtime bindings for id, options, session, and scoped context', async () => {
it('borrows caller options and binds its scoped context exactly once', async () => {
const ctx = await harness(new MockAdapter([textResponse('unused')]))
const options = { model: 'mock' }
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
const acceptedSession = agent.session
const acceptedContext = agent.ctx
options.model = 'caller-mutated'
expect(agent.options).toEqual({ model: 'mock' })
expect(Object.isFrozen(agent.options)).toBe(true)
expect(Reflect.set(agent, 'id', AgentId('redirected'))).toBe(false)
expect(Reflect.set(agent, 'options', { model: 'other' })).toBe(false)
expect(Reflect.set(agent, 'session', ctx.sessions.create(SessionId('other')))).toBe(false)
expect(Reflect.set(agent, 'ctx', new Context())).toBe(false)
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session).toBe(acceptedSession)
expect(agent.ctx).toBe(acceptedContext)
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
for (const name of ['id', 'options', 'session', 'ctx']) {
expect(Object.getOwnPropertyDescriptor(agent, name)).toMatchObject({
configurable: false,
writable: false,
})
}
await ctx.fiber.dispose()
})
@@ -223,23 +208,6 @@ describe('ReactLoopAgent', () => {
warn.mockRestore()
})
it('idle inject() safely renders a hostile non-Error flush failure', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const hostile = { [Symbol.toPrimitive]() { throw new Error('no coercion') } }
ctx.on('session/flush', () => { throw hostile })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('hostile-flush'), { model: 'mock' })
const errors: string[] = []
ctx.on('agent/error', (_a, _turn, _step, error) => void errors.push(error.message))
agent.inject([{ type: 'text', text: 'notice' }])
await new Promise(r => setTimeout(r, 20))
expect(errors).toEqual(['<unrenderable thrown value>'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('<unrenderable thrown value>'))
warn.mockRestore()
})
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -281,7 +249,7 @@ describe('ReactLoopAgent', () => {
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
prepared.enableDrive()
prepared.markPublished()
const dispose = prepared.startDriver()
// First dispose
@@ -309,24 +277,6 @@ describe('ReactLoopAgent', () => {
await ctx.fiber.dispose()
})
it('does not claim a session when concrete-agent construction rejects options', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('constructor-retry'))
const badOptions = {
get model(): string {
throw new Error('bad model getter')
},
}
expect(() => prepareReactLoopAgent(ctx, AgentId('bad-constructor'), badOptions, session))
.toThrow('bad model getter')
const prepared = prepareReactLoopAgent(ctx, AgentId('constructor-retry'), { model: 'mock' }, session)
await prepared.dispose()
expect(prepared.agent.status).toBe('disposed')
await ctx.fiber.dispose()
})
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -417,7 +367,7 @@ describe('ReactLoopAgent', () => {
const session = ctx.sessions.create(SessionId('bare'))
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const { agent } = prepared
prepared.enableDrive()
prepared.markPublished()
const dispose = prepared.startDriver()
agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))

View File

@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } 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, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -99,22 +99,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('createAgent sends raw metadata to the session validator before cloning can sanitize it', async () => {
class ExoticMeta {
readonly cwd = '/accepted'
}
const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')]))
await expect(ctx.agents.create({
agentId: AgentId('exotic-meta-agent'),
sessionId: SessionId('exotic-meta-session'),
meta: new ExoticMeta(),
})).rejects.toThrow(/session metadata is not a plain JSON record/)
expect(ctx.agents.get(AgentId('exotic-meta-agent'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('exotic-meta-session'))).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')])
@@ -184,7 +168,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
order.push('session/created')
})
ctx.on('agent/created', (agent) => {
expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
expect(agent.status).toBe('idle')
order.push('agent/created')
})
ctx.on('agent/session-start', (agent) => {
@@ -228,9 +212,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('successful resume disposal retires both caller ownership sentinels', async () => {
const sessionId = SessionId('resume-retired-sentinels-s')
const agentId = AgentId('resume-retired-sentinels')
it('successful resume disposal retires its caller-owned transaction effects', async () => {
const sessionId = SessionId('resume-retired-effects-s')
const agentId = AgentId('resume-retired-effects')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const handle = await ctx.agents.resume({
@@ -238,14 +222,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
const sentinelLabels = [
`agentLoop.resumeLoad(${agentId})`,
`agentLoop.ownerLifecycle(${agentId})`,
const transactionLabels = [
`agentLoop.owner(${agentId})`,
`agentLoop.lifecycle(${agentId})`,
]
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(sentinelLabels))
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => sentinelLabels.includes(effect.label))).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
await ctx.fiber.dispose()
})
@@ -346,14 +330,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
}, { inject: ['agents'] }))
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
// owner.dispose() itself awaited transaction settlement and reservation
// release: reuse the same identities BEFORE awaiting the resume rejection.
// owner.dispose() awaited transaction settlement, so the same identities
// can be reused before awaiting the public rejection.
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
await rejection
expect(loads).toBe(2)
@@ -372,7 +356,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('AgentLoop unload aborts persistence load and awaits reservation release', async () => {
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const agentId = AgentId('resume-load-factory-race')
const root = await persistSession(sessionId)
@@ -400,18 +384,13 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
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(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const agentReservation = ctx.agents.reserve(agentId)
const sessionReservation = ctx.sessions.reserve(sessionId)
sessionReservation.release()
agentReservation.release()
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
await Promise.resolve()
@@ -419,83 +398,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('option snapshot reentrancy cannot install a resume sentinel after factory unload begins', async () => {
const sessionId = SessionId('resume-snapshot-factory-unload')
const agentId = AgentId('resume-snapshot-factory-race')
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')]))
let loads = 0
const load = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
ctx.sessionPersistence.load = (id) => {
loads += 1
return load(id)
}
const options = {
agentId,
resumeSessionId: sessionId,
get agentOptions() {
void loopFiber.dispose()
return { model: 'mock' }
},
}
await expect(ctx.agents.resume(options)).rejects.toThrow('agent loop is not active')
await loopFiber.dispose()
expect(loads).toBe(0)
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.resumeLoad(${agentId})`)).toEqual([])
const agentReservation = ctx.agents.reserve(agentId)
const sessionReservation = ctx.sessions.reserve(sessionId)
sessionReservation.release()
agentReservation.release()
await ctx.fiber.dispose()
})
it('snapshots resume identities and agent options before persistence load', async () => {
const sessionId = SessionId('resume-snapshot-source')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const loaded = await ctx.sessionPersistence.load(sessionId)
const loadGate = Promise.withResolvers<typeof loaded>()
ctx.sessionPersistence.load = () => loadGate.promise
const occupied = await ctx.agents.create({
agentId: AgentId('occupied-agent'),
sessionId: SessionId('occupied-session'),
agentOptions: { model: 'mock' },
})
const options = {
agentId: AgentId('accepted-agent'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
}
const resuming = ctx.agents.resume(options)
options.agentId = AgentId('occupied-agent')
options.resumeSessionId = SessionId('occupied-session')
options.agentOptions.model = 'mutated-model'
loadGate.resolve(structuredClone(loaded))
const resumed = await resuming
expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
expect(resumed.agent.session.id).toBe(sessionId)
expect(resumed.agent.options.model).toBe('mock')
expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)
await resumed.dispose()
await occupied.dispose()
await ctx.fiber.dispose()
})
it('resume of a forked session preserves the parentSession lineage and seed boundary 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
@@ -535,54 +437,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.fiber.dispose()
})
it('reads each loaded metadata field once before reconstructing a resumed session', async () => {
const sessionId = SessionId('resume-loaded-meta-once')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const loaded = await ctx.sessionPersistence.load(sessionId)
const reads = { createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
const meta = Object.defineProperties({
version: loaded.meta.version,
id: loaded.meta.id,
}, {
createdAt: {
enumerable: true,
get: () => { reads.createdAt += 1; return reads.createdAt === 1 ? loaded.meta.createdAt : 1n },
},
cwd: {
enumerable: true,
get: () => { reads.cwd += 1; return reads.cwd === 1 ? '/loaded' : 'relative' },
},
parentSession: {
enumerable: true,
get: () => { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
},
seedLength: {
enumerable: true,
get: () => { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
},
}) as unknown as SessionHeader
ctx.sessionPersistence.load = () => Promise.resolve({ meta, events: loaded.events })
const resumed = await ctx.agents.resume({
agentId: AgentId('resume-loaded-meta-once'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
expect(reads).toEqual({ createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
expect(resumed.agent.session.header).toEqual({
version: loaded.meta.version,
id: sessionId,
createdAt: loaded.meta.createdAt,
cwd: '/loaded',
parentSession: 'parent',
seedLength: 0,
})
await resumed.dispose()
await ctx.fiber.dispose()
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)

View File

@@ -443,14 +443,12 @@ describe('MEDIUM: misc registry and config fixes', () => {
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
let notifiedInfoFrozen = false
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || info.steering) return
// Retain the exact notification references: cloning here would test the
// listener's copy rather than the event/inbox ownership boundary.
notifiedContent = acceptedContent
notifiedSource = info.source
notifiedInfoFrozen = Object.isFrozen(info)
})
agent.send(content, { source })
@@ -463,7 +461,6 @@ describe('MEDIUM: misc registry and config fixes', () => {
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
expect(notifiedInfoFrozen).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
expect(recorded).toContainEqual({
content: [{ type: 'text', text: 'accepted-send' }],
@@ -474,33 +471,6 @@ describe('MEDIUM: misc registry and config fixes', () => {
expect(request).not.toContain('caller-mutated-send')
})
it('send() rechecks disposal after materializing caller getters', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
agentId: AgentId('reentrant-send-dispose'),
sessionId: SessionId('reentrant-send-dispose-session'),
agentOptions: { model: 'mock' },
})
const { agent } = handle
let queued = 0
ctx.on('agent/queued', subject => void (queued += Number(subject === agent)))
const content = [{
type: 'text' as const,
get text() {
void handle.dispose()
return 'accepted-after-dispose'
},
}]
expect(() => { agent.send(content) }).toThrow(/agent "reentrant-send-dispose" is disposed/)
await handle.dispose()
expect(queued).toBe(0)
expect(agent.session.events).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
})
it('running steer() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
const ctx = await harness(adapter)
@@ -519,12 +489,10 @@ describe('MEDIUM: misc registry and config fixes', () => {
}))
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
let notifiedInfoFrozen = false
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || !info.steering) return
notifiedContent = acceptedContent
notifiedSource = info.source
notifiedInfoFrozen = Object.isFrozen(info)
})
agent.send([{ type: 'text', text: 'start' }])
@@ -544,7 +512,6 @@ describe('MEDIUM: misc registry and config fixes', () => {
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
expect(notifiedInfoFrozen).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
expect(recorded).toContainEqual({
turn: 1,
@@ -579,7 +546,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const forked = prepared.agent
prepared.enableDrive()
prepared.markPublished()
ctx2.effect(() => prepared.startDriver())
const turns: number[] = []

View File

@@ -8,7 +8,6 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as concreteAgentModule from '../src/agent.ts'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -46,7 +45,7 @@ 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 === 'agentLoop.lifecycle()'
return effect?.label.startsWith('agentLoop.lifecycle(') === true
})
if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
void lifecycle()
@@ -167,11 +166,9 @@ describe('agent scope lifecycle', () => {
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
expect(order).toEqual(['setup:start'])
acceptedOptions.model = 'mutated while setup was pending'
gate.resolve(undefined)
const handle = await creating
expect(handle.agent.options.model).toBe('mock')
expect(handle.agent.options).toBe(acceptedOptions)
expect(order).toEqual([
'setup:start',
'setup:end',
@@ -184,90 +181,80 @@ describe('agent scope lifecycle', () => {
await handle.dispose()
})
it('reserves agent and session ids across concurrent async setup', async () => {
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<undefined>()
const bothStarted = Promise.withResolvers<undefined>()
let started = 0
const setup = async (): Promise<void> => {
started += 1
if (started === 2) bothStarted.resolve(undefined)
await gate.promise
}
const agentId = AgentId('concurrent-final-enter')
const first = ctx.agents.create({
agentId: AgentId('reserved'),
sessionId: SessionId('reserved-s'),
agentId,
sessionId: SessionId('concurrent-final-enter-a'),
agentOptions: { model: 'mock' },
setup: () => gate.promise,
setup,
})
await expect(ctx.agents.create({
agentId: AgentId('reserved'),
sessionId: SessionId('other-s'),
const second = ctx.agents.create({
agentId,
sessionId: SessionId('concurrent-final-enter-b'),
agentOptions: { model: 'mock' },
})).rejects.toThrow(/already registered/)
await expect(ctx.agents.create({
agentId: AgentId('other'),
sessionId: SessionId('reserved-s'),
agentOptions: { model: 'mock' },
})).rejects.toThrow(/already exists/)
setup,
})
await bothStarted.promise
expect(ctx.agents.list()).toEqual([])
expect(ctx.sessions.list()).toEqual([])
gate.resolve(undefined)
const handle = await first
await handle.dispose()
const outcomes = await Promise.allSettled([first, second])
const fulfilled = outcomes.filter((outcome): outcome is PromiseFulfilledResult<Awaited<typeof first>> => 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 registered/)
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('makes setup-time publication structurally impossible through public stores', async () => {
it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => {
const ctx = await harness()
const lifecycle: string[] = []
ctx.on('session/created', () => void lifecycle.push('session'))
ctx.on('agent/created', () => void lifecycle.push('agent'))
const handle = await ctx.agents.create({
agentId: AgentId('guarded-publication'),
sessionId: SessionId('guarded-publication-s'),
const pendingController = new AbortController()
const setupStarted = Promise.withResolvers<undefined>()
const pending = ctx.agents.create({
agentId: AgentId('signal-pending'),
sessionId: SessionId('signal-pending-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
const agent = agentCtx.agent!
expect(() => agentCtx.agents.enter(agent)).toThrow(/reserved for unpublished creation/)
expect(() => agentCtx.agents.register(agent)).toThrow(/reserved for unpublished creation/)
expect(() => agentCtx.sessions.enter(agent.session)).toThrow(/reserved for unpublished creation/)
expect(() => agentCtx.sessions.prepare(agent.session.id)).toThrow(/reserved for unpublished creation/)
expect(() => agentCtx.sessions.create(agent.session.id)).toThrow(/reserved for unpublished creation/)
expect(lifecycle).toEqual([])
expect(ctx.agents.get(agent.id)).toBeUndefined()
expect(ctx.sessions.get(agent.session.id)).toBeUndefined()
signal: pendingController.signal,
setup: async () => {
setupStarted.resolve(undefined)
await new Promise<never>(() => {})
},
})
await setupStarted.promise
pendingController.abort(new Error('cancel pending creation'))
await expect(pending).rejects.toThrow('cancel pending creation')
expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
expect(lifecycle).toEqual(['session', 'agent'])
expect(ctx.agents.get(handle.agent.id)).toBe(handle.agent)
expect(ctx.sessions.get(handle.agent.session.id)).toBe(handle.agent.session)
await handle.dispose()
})
it('structurally rejects every driving verb during setup', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
agentId: AgentId('no-drive'),
sessionId: SessionId('no-drive-s'),
const liveController = new AbortController()
const live = await ctx.agents.create({
agentId: AgentId('signal-live'),
sessionId: SessionId('signal-live-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
const agent = agentCtx.agent!
// Even JavaScript or a cast to the exported concrete class cannot name
// a public start method. Driver startup is behind a module-private
// symbol used only by AgentLoop after rollback-covered publication.
expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
expect(() => concreteAgentModule.prepareReactLoopAgent(
agentCtx, agent.id, agent.options, agent.session,
)).toThrow(/already has a concrete agent driver/)
expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
expect(agent.session.events).toEqual([])
},
signal: liveController.signal,
})
expect(handle.agent.session.events).toEqual([])
await handle.dispose()
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 () => {
@@ -347,16 +334,11 @@ describe('agent scope lifecycle', () => {
await setupStarted.promise
await loopFiber.dispose()
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await expect(creating).rejects.toThrow(/agent loop is not active/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
// Factory unload itself reached the reservation-release boundary.
const agentReservation = ctx.agents.reserve(AgentId('factory-setup-race'))
const sessionReservation = ctx.sessions.reserve(SessionId('factory-setup-race-s'))
sessionReservation.release()
agentReservation.release()
gate.resolve(undefined)
await ctx.fiber.dispose()
})
@@ -377,16 +359,12 @@ describe('agent scope lifecycle', () => {
agentOptions: { model: 'mock' },
setup: () => { setupCalls += 1 },
})
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await expect(creating).rejects.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(setupCalls).toBe(0)
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-race'))
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-race-s'))
sessionReservation.release()
agentReservation.release()
await ctx.fiber.dispose()
})
@@ -444,14 +422,14 @@ describe('agent scope lifecycle', () => {
})
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' }))
.toThrow(/owner disposed during setup/)
.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
await ctx.fiber.dispose()
})
it('synchronous create releases both reservations when session preparation fails', async () => {
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
const ctx = await harness()
const id = AgentId('config-prepare-failure')
@@ -463,44 +441,7 @@ describe('agent scope lifecycle', () => {
await ctx.fiber.dispose()
})
it('turns owner disposal from the caller association getter into a rollback boundary', async () => {
const ctx = await harness()
let creating!: ReturnType<typeof ctx.agents.create>
let getterCalls = 0
const creationStarted = Promise.withResolvers<undefined>()
const owner = ctx.plugin(Object.assign((inner: Context) => {
Object.defineProperty(inner, 'agent', {
configurable: true,
get() {
getterCalls += 1
void inner.fiber.dispose()
return undefined
},
})
creating = inner.agents.create({
agentId: AgentId('association-dispose'),
sessionId: SessionId('association-dispose-s'),
agentOptions: { model: 'mock' },
})
creationStarted.resolve(undefined)
}, { inject: ['agents'] }))
await creationStarted.promise
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner
expect(getterCalls).toBe(1)
expect(ctx.agents.get(AgentId('association-dispose'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('association-dispose-s'))).toBeUndefined()
const replacement = await ctx.agents.create({
agentId: AgentId('association-dispose'),
sessionId: SessionId('association-dispose-s'),
agentOptions: { model: 'mock' },
})
await replacement.dispose()
await ctx.fiber.dispose()
})
it('factory unload awaits reservations when reentrant scope preparation throws', async () => {
it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
let triggered = false
ctx.on('internal/plugin', (fiber) => {
@@ -519,36 +460,6 @@ describe('agent scope lifecycle', () => {
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-throw'))
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-throw-s'))
sessionReservation.release()
agentReservation.release()
await ctx.fiber.dispose()
})
it('factory unload during session preparation awaits create reservation release', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
let unloading!: Promise<void>
const meta = {
get cwd() {
unloading = loopFiber.dispose()
return '/factory-unload'
},
}
const creating = ctx.agents.create({
agentId: AgentId('factory-prepare-race'),
sessionId: SessionId('factory-prepare-race-s'),
agentOptions: { model: 'mock' },
meta,
})
await unloading
await expect(creating).rejects.toThrow('agent loop is not active')
const agentReservation = ctx.agents.reserve(AgentId('factory-prepare-race'))
const sessionReservation = ctx.sessions.reserve(SessionId('factory-prepare-race-s'))
sessionReservation.release()
agentReservation.release()
await ctx.fiber.dispose()
})
@@ -566,14 +477,10 @@ describe('agent scope lifecycle', () => {
expect(handle.agent.status).toBe('disposed')
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
// The consumer handle shares the provider's completed quiescence boundary.
await handle.dispose()
const agentReservation = ctx.agents.reserve(agentId)
const sessionReservation = ctx.sessions.reserve(SessionId('factory-live-s'))
sessionReservation.release()
agentReservation.release()
await expect(loop.createAgent(ctx, {
agentId: AgentId('factory-inactive'),
sessionId: SessionId('factory-inactive-s'),
@@ -647,7 +554,7 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created:dispose',
@@ -696,7 +603,7 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created',
@@ -711,52 +618,6 @@ describe('agent scope lifecycle', () => {
await ctx.fiber.dispose()
})
it('rechecks owner liveness after carrier capture before the first creation edge', async () => {
const ctx = await harness()
let ownerCtx!: Context
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
const agentId = AgentId('carrier-owner-race')
const sessionId = SessionId('carrier-owner-race-s')
const lifecycle: string[] = []
let filterReads = 0
ctx.on('session/created', (session) => {
if (session.id === sessionId) lifecycle.push('session-created')
})
ctx.on('session/disposed', (session) => {
if (session.id === sessionId) lifecycle.push('session-disposed')
})
ctx.on('agent/created', (agent) => {
if (agent.id === agentId) lifecycle.push('agent-created')
})
ctx.on('agent/disposed', (agent) => {
if (agent.id === agentId) lifecycle.push('agent-disposed')
})
const creating = ownerCtx.agents.create({
agentId,
sessionId,
agentOptions: { model: 'mock' },
setup(agentCtx) {
Object.defineProperty(agentCtx.agent!.session, Context.filter, {
configurable: true,
get() {
filterReads += 1
void owner.dispose()
return undefined
},
})
},
})
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(filterReads).toBe(1)
expect(lifecycle).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
const ctx = await harness()
const starts: string[] = []
@@ -817,7 +678,7 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await owner.dispose()
expect(announced.status).toBe('disposed')
expect(statuses).toEqual(['disposed'])
@@ -853,7 +714,7 @@ describe('agent scope lifecycle', () => {
await retry.dispose()
})
it('rejects an exotic seed before publishing either reserved identity', async () => {
it('rejects an exotic durable seed before publishing either identity', async () => {
const ctx = await harness()
const published: string[] = []
ctx.on('session/created', () => { published.push('session') })
@@ -966,29 +827,6 @@ describe('agent scope lifecycle', () => {
expect(heard).toEqual(['a1:2'])
})
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
// ds-review-bot regression: agent/* listeners are typed
// `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
// native-private #carrier — a proxy-receiver carrier made
// `this.send(...)` throw TypeError. The carrier binds methods to the real
// agent, so driving through the event `this` is a working supported shape.
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let followUpSent = false
ctx.on('agent/session-start', function (this: Agent) {
// Deliberately through `this`, not the args subject.
this.send(text('driven through this'))
followUpSent = true
})
const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
expect(followUpSent).toBe(true)
await second.whenIdle()
// The send actually reached the loop: the prompt ran a turn.
expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true)
await agent.whenIdle()
})
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
const ctx = await harness()
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
@@ -1044,18 +882,18 @@ describe('agent scope lifecycle', () => {
await unload
})
it('successful handle disposal retires its caller ownership sentinel', async () => {
it('successful handle disposal retires its caller ownership effect', async () => {
const ctx = await harness()
const agentId = AgentId('retired-owner-sentinel')
const agentId = AgentId('retired-owner-effect')
const handle = await ctx.agents.create({
agentId,
sessionId: SessionId('retired-owner-sentinel-s'),
sessionId: SessionId('retired-owner-effect-s'),
agentOptions: { model: 'mock' },
})
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.ownerLifecycle(${agentId})`)
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`)
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
await ctx.fiber.dispose()
})
@@ -1091,13 +929,13 @@ describe('agent scope lifecycle', () => {
await ctx.fiber.dispose()
})
it('retains both identity reservations until scope teardown reaches quiescence', async () => {
it('reopens ids after detach while the prior private scope finishes quiescing', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
const sessionDisposed = Promise.withResolvers<undefined>()
const agentId = AgentId('quiescent-reservation')
const sessionId = SessionId('quiescent-reservation-s')
const agentId = AgentId('quiescent-reuse')
const sessionId = SessionId('quiescent-reuse-s')
ctx.on('session/disposed', (session) => {
if (session.id === sessionId) sessionDisposed.resolve(undefined)
})
@@ -1117,12 +955,12 @@ describe('agent scope lifecycle', () => {
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await expect(ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }))
.rejects.toThrow(/reserved/)
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
expect(ctx.agents.get(agentId)).toBe(replacement.agent)
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
gate.resolve(undefined)
await disposing
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
await replacement.dispose()
await ctx.fiber.dispose()
})

View File

@@ -156,12 +156,9 @@ describe('agent/turn-stop', () => {
expect(adapter.requests).toHaveLength(3)
})
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
it('fails a throwing terminal policy closed while the driver survives', async () => {
const adapter = new MockAdapter([
textResponse('throwing policy'),
textResponse('malformed continue policy'),
textResponse('malformed false policy'),
textResponse('malformed null policy'),
textResponse('healthy later turn'),
])
const ctx = await harness(adapter)
@@ -179,21 +176,10 @@ describe('agent/turn-stop', () => {
await send(agent, 'first')
disposeThrowing()
for (const [index, malformed] of [
{ action: 'continue' },
false,
null,
].entries()) {
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
await send(agent, `malformed ${index}`)
disposeMalformed()
}
await send(agent, 'healthy')
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
expect(errors).toContain('terminal policy exploded')
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
expect(adapter.requests).toHaveLength(5)
expect(adapter.requests).toHaveLength(2)
})
})