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.
This commit is contained in:
@@ -34,6 +34,44 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
// Throw on the first emit only. Note the rollback path itself emits
|
||||
// system-prompt/change, so a multi-shot guard would also fire on rollback;
|
||||
// a single-shot guard isolates the register's own emit.
|
||||
let threw = false
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) // nothing leaked
|
||||
|
||||
// Subsequent listener-free register contributes exactly once.
|
||||
off()
|
||||
ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
let threw = false
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
|
||||
|
||||
off()
|
||||
ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
Reference in New Issue
Block a user