refactor(core): simplify scoped agent lifecycles
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user