Files
deepseek-harness/packages/agent/tests/agent.spec.ts
Tianyi Cui 2df41ee1d3 fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.

Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.

Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 01:06:13 +08:00

77 lines
2.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',
send() {},
steer() {},
inject() {},
abort() {},
}
}
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('a1')).toBe(agent)
expect(ctx.agents.list()).toEqual([agent])
dispose()
expect(disposed).toEqual(['a1'])
expect(ctx.agents.get('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('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'])
dispose()
expect(ctx.agents.get('main')).toBeUndefined()
})
})