The exact-disposer fix (5fbac8be B1) repaired agents.register but left the same wrapper (return () => void dispose()) at seven sibling sites: tools.register, tools.restrict, systemPrompt.section/tools/variable, agents.setFactory, and subagents.registerProvider. A wrapper makes correct composite usage unrepresentable — the exact disposer cannot be recovered, so a generator effect yielding it leaves the inner effect disposing as a CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering corruption. The exact disposer serves both usages (composite-nestable AND fire-and-forget callable); all seven now return it, typed () => Promise<void> | void, with the convention pinned by a discriminating test: an async-link composite probe that passes with the exact disposer and observes the sibling unregistration firing mid-drain with a wrapper. Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it changed register()'s return type without updating cross-file consumers (agent.spec.ts dispose() statements, tool-bash's disposer list), which the staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD. Those three sites and this change's own fallout are fixed together: tests now await disposers (stronger — they observe the full unwind), sync paths void them, and the two annotation sites carry the honest union type. agents.register's README line had drifted the same way (B1 updated the JSDoc, not the README) — all seven README signatures now match; services catalog regenerated.
142 lines
5.6 KiB
TypeScript
142 lines
5.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
|
|
|
function stubAgent(rawId: string): Agent {
|
|
const id = AgentId(rawId)
|
|
return {
|
|
id,
|
|
options: {},
|
|
session: new Session(SessionId(`${id}-session`)),
|
|
status: 'idle',
|
|
// A bare context stands in for the agent scope: registry tests never
|
|
// register through it, they only need the field present.
|
|
ctx: new Context(),
|
|
send() {},
|
|
steer() {},
|
|
inject() {},
|
|
cancel() {},
|
|
whenIdle() { return Promise.resolve() },
|
|
}
|
|
}
|
|
|
|
describe('AgentRegistry', () => {
|
|
it('registers agents and emits created/disposed events', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
const created: string[] = []
|
|
const disposed: string[] = []
|
|
ctx.on('agent/created', agent => void created.push(agent.id))
|
|
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
|
|
|
|
const agent = stubAgent('a1')
|
|
const dispose = ctx.agents.register(agent)
|
|
expect(created).toEqual(['a1'])
|
|
expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
|
|
expect(ctx.agents.list()).toEqual([agent])
|
|
|
|
await dispose()
|
|
expect(disposed).toEqual(['a1'])
|
|
expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
|
|
})
|
|
|
|
it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
ctx.agents.register(stubAgent('main'))
|
|
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered')
|
|
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
inner.agents.register(stubAgent('scoped'))
|
|
}, { inject: ['agents'] }))
|
|
expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped'])
|
|
|
|
await fiber.dispose()
|
|
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
|
|
})
|
|
|
|
it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
let threw = false
|
|
ctx.on('agent/created', () => {
|
|
if (!threw) { threw = true; throw new Error('boom created listener') }
|
|
})
|
|
|
|
// The throwing emit must roll the entry back, not leak it.
|
|
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
|
|
expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
|
|
|
|
// A subsequent listener-free register of the SAME id succeeds and is
|
|
// tracked exactly once (the duplicate-id check is not wedged).
|
|
const dispose = ctx.agents.register(stubAgent('main'))
|
|
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
|
|
await dispose()
|
|
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('AgentRegistry factory seam', () => {
|
|
/** A stub AgentFactory that records calls and returns a stub agent. */
|
|
function stubFactory() {
|
|
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
|
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
|
createAgent(options) {
|
|
calls.create.push(options)
|
|
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
|
},
|
|
resume(options) {
|
|
calls.resume.push(options)
|
|
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
|
},
|
|
}
|
|
return { factory, calls }
|
|
}
|
|
|
|
it('create()/resume() throw when no factory is registered', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
|
|
await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
|
})
|
|
|
|
it('setFactory registers a factory; create/resume delegate to it', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
const { factory, calls } = stubFactory()
|
|
ctx.agents.setFactory(factory)
|
|
|
|
const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
|
|
expect(created.agent.id).toBe('c1')
|
|
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
|
|
|
|
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
|
|
expect(resumed.agent.id).toBe('r1')
|
|
expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
|
|
})
|
|
|
|
it('setFactory rejects a second factory', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
ctx.agents.setFactory(stubFactory().factory)
|
|
expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
|
})
|
|
|
|
it('disposing the setFactory fiber clears the factory (HMR safety)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
let dispose!: () => Promise<void> | void
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
dispose = inner.agents.setFactory(stubFactory().factory)
|
|
}, { inject: ['agents'] }))
|
|
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
|
|
void dispose
|
|
await fiber.dispose()
|
|
// factory slot cleared → create throws again
|
|
expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
|
|
})
|
|
})
|