فهرست منبع

test: close the per-file coverage gaps for the scoping surface

Every subject-extractor row of the invariants carrier table is exercised
with a matching and a foreign-keyed carrier; the HMR re-apply seed path
(sessions of agents that predate the plugin are marked started) is pinned;
the scoped tool-provider disposal, plural restrict() validation, singular
scopeHost absentee, tool-subagent passthrough, stale-stage drop, and
disposing-parent spawn (INACTIVE_EFFECT, no orphan) each gain their test.
Two genuinely defensive branches carry justified v8-ignore markers.
Tianyi Cui 2 ماه پیش
والد
کامیت
e7b712453a

+ 5 - 1
packages/core/scope/src/index.ts

@@ -283,7 +283,11 @@ export async function scopeHost(ctx: Context, services: string[]): Promise<Scope
     // callback. Name the absentees and unwind the pending fiber.
     const missing = services.filter(name => ctx.get(name) === undefined)
     await fiber.dispose()
-    throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${missing.map(name => `"${name}"`).join(', ') || '(unknown)'} not available on this context — load the providing plugin(s) before minting scopes`)
+    /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending
+     * fiber with zero absent services cannot occur (an all-present inject
+     * list runs the callback) */
+    const named = missing.map(name => `"${name}"`).join(', ') || '(unknown)'
+    throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`)
   }
   const host = hostCtx
   return {

+ 5 - 0
packages/core/scope/tests/scope.spec.ts

@@ -227,4 +227,9 @@ describe('scopeHost', () => {
     await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
       .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
   })
+
+  it('names a single absent service in the singular', async () => {
+    const ctx = new Context()
+    await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')
+  })
 })

+ 13 - 0
packages/core/system-prompt/tests/scoped.spec.ts

@@ -100,6 +100,19 @@ describe('scoped tool providers and toolOrder × restriction', () => {
     expect(global.tools.map(t => t.name)).toEqual(['global_tool'])
   })
 
+  it('disposing a scoped tool provider empties its layer without residue', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'child')
+    const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
+    dispose()
+    const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
+    expect(after.tools.map(t => t.name)).toEqual([])
+    // Re-registering through the same scope starts a fresh layer.
+    scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('again')] }))
+    const again = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
+    expect(again.tools.map(t => t.name)).toEqual(['again'])
+  })
+
   it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => {
     const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] })
     // A provider mimicking the registry's restriction split: bash exists

+ 1 - 0
packages/core/tools/tests/scoped.spec.ts

@@ -148,6 +148,7 @@ describe('restrict()', () => {
     expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
     expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
     expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
+    expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
   })
 })
 

+ 2 - 0
packages/subagent/subagent-inprocess/src/index.ts

@@ -203,6 +203,8 @@ export function startInProcessRun(
   try {
     unlink = request.parent.ctx.effect(() => () => handle.dispose())
   } catch (error: unknown) {
+    // Fire-and-forget: start() must rethrow synchronously; the child's
+    // teardown (stop → unregister → detach) reaches quiescence on its own.
     void handle.dispose()
     throw error
   }

+ 3 - 0
packages/subagent/subagent-inprocess/src/structured.ts

@@ -184,6 +184,9 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
       if (decision.kind === 'accept') captured = { value: staged.value }
       return decision
     } finally {
+      /* v8 ignore next -- defensive false branch: a concurrent re-stage
+       * would need a second capture call INSIDE the first's post-execute
+       * chain */
       if (pending === staged) pending = undefined
     }
   }, { prepend: true })

+ 41 - 0
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -489,4 +489,45 @@ describe('in-process structured output', () => {
     expect(result.isError).toBe(true)
     expect(result.error?.code).toBe('UNKNOWN_TOOL')
   })
+
+  it('drops a stale stage from a short-circuited chain: a later call never promotes it (call-keyed commit)', async () => {
+    const { ctx, parent } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
+    ])
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const child = ctx.agents.get(run.id)!
+    // An OUTER post-execute listener (registered after attach, prepend ⇒
+    // outermost) that BLOCKS the first capture WITHOUT delegating: the commit
+    // listener never runs for c1, so its staged value would linger.
+    let blocks = 1
+    ctx.on('tools/post-execute', (exec, _result, next) => {
+      if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
+        blocks -= 1
+        return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
+      }
+      return next()
+    }, { prepend: true })
+    const result = await run.result
+    // The blocked capture must NOT surface as structured success…
+    expect(result.stopReason).toBe('error')
+    expect(result.structured).toBeUndefined()
+    // …and a LATER invalid call (its own body staged nothing) must not
+    // resurrect c1's orphaned value: drive the pipeline directly.
+    const invalid = await ctx.tools.execute({
+      callId: 'c2' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 'not-a-number' },
+      agent: child,
+    })
+    expect(invalid.isError).toBe(true)
+    // A fresh valid call still captures ITS OWN value.
+    const valid = await ctx.tools.execute({
+      callId: 'c3' as never,
+      name: STRUCTURED_OUTPUT_TOOL,
+      arguments: { answer: 9 },
+      agent: child,
+    })
+    expect(valid.isError).toBeFalsy()
+    await run.dispose()
+  })
 })

+ 20 - 1
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import LlmService from '@deepseek-ai/dsh-llm'
@@ -377,4 +377,23 @@ describe('dsh-subagent-spawn', () => {
       expect(ctx.agents.list().length).toBe(before)
     })
   })
+
+  it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => {
+    const { ctx } = await setup([])
+    // A handle-owned parent we can dispose (config agents dispose with the loop fiber).
+    const parentHandle = ctx.agents.create({
+      agentId: AgentId('doomed-parent'),
+      sessionId: SessionId('doomed-s'),
+      agentOptions: { model: 'mock' },
+    })
+    await parentHandle.dispose()
+    const before = ctx.agents.list().length
+    expect(() => ctx.subagents.start('spawn', {
+      prompt: [{ type: 'text', text: 'do X' }],
+      parent: parentHandle.agent,
+    })).toThrow(/inactive context/)
+    // The freshly created child's disposal was initiated before the rethrow
+    // (fire-and-forget — start() throws synchronously); quiescence follows.
+    await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) })
+  })
 })

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

@@ -451,4 +451,37 @@ describe('dsh-tool-subagent', () => {
     expect(typeof unwrapped.apply).toBe('function')
     expect(unwrapped.Config).toBeDefined()
   })
+
+  it('passes persona/toolFilter/maxDepth config through to the start request', async () => {
+    let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(SubagentService)
+    ctx.subagents.registerProvider({
+      name: 'capture2',
+      capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
+      inheritsParentContext: false,
+      start: (request) => {
+        seen = request
+        return {
+          id: AgentId('capture2-child'),
+          result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
+          cancel() {},
+          dispose: async () => {},
+        }
+      },
+    })
+    await ctx.plugin(tool, {
+      provider: 'capture2',
+      persona: 'You are the child.',
+      toolFilter: { deny: ['subagent'] },
+      maxDepth: 2,
+    })
+
+    await callSubagent(ctx, { description: 'd', prompt: 'p' })
+    expect(seen?.persona).toBe('You are the child.')
+    expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] })
+    expect(seen?.maxDepth).toBe(2)
+  })
 })

+ 46 - 1
packages/support/invariants/tests/invariants.spec.ts

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import { InvariantError } from '@deepseek-ai/dsh-invariants'
 
@@ -797,6 +797,37 @@ describe('scoped-dispatch invariants', () => {
       .toThrow(/dispatched without a scope carrier/)
   })
 
+  it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => {
+    const ctx = await scopedCtx()
+    // Real Session objects: the session-start tracker WeakSet-keys them.
+    const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent
+    const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent
+    // One dispatch per table row keeps every subject extractor covered: the
+    // matching carrier passes, the foreign-keyed one throws.
+    const rows: [string, unknown[]][] = [
+      ['agent/created', [agent]],
+      ['agent/disposed', [agent]],
+      ['agent/status', [agent, 'idle']],
+      ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]],
+      ['agent/session-start', [agent, 'startup']],
+      ['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]],
+      ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
+      ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
+      ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
+      ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
+      ['agent/error', [agent, 1, 0, new Error('x')]],
+      ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
+      ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
+    ]
+    for (const [event, args] of rows) {
+      const subject = event.startsWith('tools/') ? agent : agent
+      expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) },
+        `${event} with matching carrier`).not.toThrow()
+      expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) },
+        `${event} with foreign carrier`).toThrow(/DIFFERENT subject/)
+    }
+  })
+
   it('rejects a carrier keyed to a different subject than the arguments name', async () => {
     const ctx = await scopedCtx()
     const agent = { id: 'a1' } as unknown as Agent
@@ -843,4 +874,18 @@ describe('scoped-dispatch invariants', () => {
       session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
     }).not.toThrow()
   })
+
+  it('marks sessions of agents that predate the plugin as started (HMR re-apply safety)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const session = ctx.sessions.create(SessionId('pre-s'))
+    const agent = { id: 'pre', session } as unknown as Agent
+    ctx.root.provide('agents', { list: () => [agent] } as never)
+    // Invariants apply AFTER the agent exists: its ordering is unknowable, so
+    // a turn opening without an observed session-start must NOT false-positive.
+    await ctx.plugin(Invariants)
+    expect(() => {
+      session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    }).not.toThrow()
+  })
 })