test(review): pin the tool-subagent plugin fiber's lifecycle ownership; doc nits

Codex round-2 findings:

- A committed test now proves the REAL plugin fiber (not a direct apply)
  owns the provider-lifecycle listeners: disposing a mounted tool's fiber
  unmounts the tool and leaves the provider intact, and a fiber disposed
  while WAITING never zombie-mounts when its provider arrives later.
- TODO(subagent-dup-toolname) records the invalid-config blast radius of
  two waiting fibers sharing a toolName (the duplicate throw propagates
  through subagent/provider-added and rolls back the provider).
- CONTEXT.md drops its creation-history sentence; the RFC's acceptance
  checklist becomes present-tense shipped invariants (docs/AGENTS.md
  writing rules).
This commit is contained in:
Tianyi Cui
2026-07-05 03:40:22 +08:00
parent e85e21c8b0
commit e890a3373e
4 changed files with 34 additions and 4 deletions

View File

@@ -213,6 +213,13 @@ export function apply(ctx: Context, config: Config): void {
// Listeners first, then the presence check: both run synchronously, so no
// registration can slip between them; the `disposeTool === undefined` guard
// makes a same-tick added-event after a successful mount a no-op.
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
// toolName collide only when their provider finally arrives — the duplicate
// tool-name throw then propagates through `subagent/provider-added` and
// rolls back the PROVIDER registration, so an invalid config blasts the
// backend's fiber instead of the misconfigured tool's. Config-time detection
// would need a cross-fiber registry of intended tool names; revisit if a
// real deployment ever hits it.
ctx.on('subagent/provider-added', (provider) => {
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
})

View File

@@ -231,6 +231,29 @@ describe('dsh-tool-subagent', () => {
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation')
})
it('the tool PLUGIN fiber owns its lifecycle listeners: disposal unmounts, and a disposed fiber never zombie-mounts', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
// Arm 1: a mounted tool dies with its plugin fiber; the provider survives.
await ctx.plugin(mock, { name: 'mock' })
const mounted = await ctx.plugin(tool, { provider: 'mock' })
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
await mounted.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
expect(ctx.subagents.getProvider('mock')).toBeDefined()
// Arm 2: a fiber disposed while WAITING must not react to the provider
// arriving later — a surviving listener would re-register a tool that no
// live plugin owns (the zombie mount).
const waiting = await ctx.plugin(tool, { provider: 'later', toolName: 'subagent_later' })
await waiting.dispose()
await ctx.plugin(mock, { name: 'later' })
expect(ctx.tools.schemas().some(s => s.name === 'subagent_later')).toBe(false)
})
it('ignores lifecycle events for OTHER providers', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)