Răsfoiți Sursa

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).
Tianyi Cui 2 luni în urmă
părinte
comite
e890a3373e

+ 1 - 1
CONTEXT.md

@@ -1,6 +1,6 @@
 # DeepSeek Harness
 
-Ubiquitous language for the harness. Started during the 2026-07-05 system-prompt redesign session; grows as terms crystallize. Decisions live in `docs/rfc/` (this repo's ADR equivalent), not here.
+Ubiquitous language for the harness; grows as terms crystallize. Decisions live in `docs/rfc/` (this repo's decision log), not here.
 
 ## Language — prompt assembly
 

+ 3 - 3
docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md

@@ -62,9 +62,9 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship
 - Further variables (`date`, platform, git state) — the registry makes each a one-line contribution by whichever plugin owns the fact; none is claimed here.
 - A config `cwd` for pre-created stdio agents (would let the stdio persona use `{{cwd}}` and partition persistence by real path) — deferred until the session-cwd story is revisited.
 
-## Acceptance criteria
+## Shipped invariants
 
-- `renderPrompt(assemble({agent}))` for the coding-agent example contains the persona FIRST (with the agent's model name interpolated), then fs/bash/web guidance sections; the loop contains no other prompt-composition path.
+- `renderPrompt(assemble({ agent }))` for the coding-agent example renders the persona FIRST (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path.
 - The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload.
 - Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw.
-- The gating runs (`test:coverage`, `test:snapshot`, `doc-sync`, `build`, `hygiene`) are green; no golden re-record is needed (replay never re-verifies the outgoing request).
+- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request.

+ 7 - 0
packages/subagent/tool-subagent/src/index.ts

@@ -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)
   })

+ 23 - 0
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -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)