Files
deepseek-harness/packages/core/tools/tests/scoped.spec.ts
T
Tianyi Cui 7c5133488a refactor(core): every registry register-method returns the exact effect disposer
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.

Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
2026-07-09 13:05:44 +08:00

174 lines
7.6 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(ToolRegistry)
return ctx
}
/** Mint a scope whose key doubles as a minimal Agent-like object. */
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
const key = { id: name as AgentId } as Agent
let scope!: Scope
// The scoped context resolves services through the MINTING plugin's
// dependency chain — the minter must inject what scope holders will reach
// (in production the agent loop's inject list plays this role).
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
{ inject: ['tools', 'systemPrompt'] }))
return { scope, key }
}
function tool(name: string, reply = `ran:${name}`): ToolDefinition {
return {
name,
description: `tool ${name}`,
parameters: { type: 'object', properties: {} },
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
}
}
async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
const result = await ctx.tools.execute({
callId: CallId('c1'),
name,
arguments: {},
...agent ? { agent } : {},
})
const first = result.content[0]
return first?.type === 'text' ? first.text : JSON.stringify(result.content)
}
describe('scoped tool registration', () => {
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
ctx.tools.register(tool('shared'))
scope.ctx.tools.register(tool('mine'))
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['mine', 'shared'])
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['shared'])
expect(ctx.tools.schemas(other).map(t => t.name)).toEqual(['shared'])
expect(await run(ctx, 'mine', key)).toBe('ran:mine')
// Out-of-view execution is indistinguishable from a nonexistent tool.
expect(await run(ctx, 'mine', other)).toBe('Error: unknown tool "mine"')
expect(await run(ctx, 'mine')).toBe('Error: unknown tool "mine"')
})
it('scoped shadows global on a name conflict, in either registration order', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
// scoped-then-global
scope.ctx.tools.register(tool('bash', 'restricted-bash'))
ctx.tools.register(tool('bash', 'global-bash'))
expect(await run(ctx, 'bash', key)).toBe('restricted-bash')
expect(await run(ctx, 'bash')).toBe('global-bash')
expect(ctx.tools.get('bash', key)?.description).toBe(ctx.tools.get('bash', key)?.description)
// Exactly one 'bash' in the scope's schema view (the shadow, not a double).
expect(ctx.tools.schemas(key).filter(t => t.name === 'bash')).toHaveLength(1)
})
it('rejects a duplicate name within one layer, naming agent.ctx for the global case', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('x'))
expect(() => ctx.tools.register(tool('x'))).toThrow(/agent\.ctx/)
scope.ctx.tools.register(tool('y'))
expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
})
it('disposing the scope unwinds its registrations and leaves no residue', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
scope.ctx.tools.register(tool('mine'))
expect(ctx.tools.get('mine', key)).toBeDefined()
await scope.dispose()
expect(ctx.tools.get('mine', key)).toBeUndefined()
expect(ctx.tools.knownNames(key)).toEqual([])
})
})
describe('restrict()', () => {
it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('read'))
ctx.tools.register(tool('bash'))
scope.ctx.tools.register(tool('capture'))
scope.ctx.tools.restrict({ allow: ['read'] })
// The scoped grant survives the allow-list; the unlisted global is gone.
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
expect(await run(ctx, 'read', key)).toBe('ran:read')
expect(await run(ctx, 'capture', key)).toBe('ran:capture')
// Other scopes and the global view are untouched.
expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
})
it('composes multiple restrictions by intersection and lifts each independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
for (const name of ['a', 'b', 'c']) ctx.tools.register(tool(name))
const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
scope.ctx.tools.restrict({ deny: ['b'] })
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
await liftAllow()
// The deny remains after the allow-list is lifted.
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
})
it('snapshots the filter at registration (caller mutation changes nothing)', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('a'))
ctx.tools.register(tool('b'))
const filter = { deny: ['a'] }
scope.ctx.tools.restrict(filter)
filter.deny.push('b')
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('real'))
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"/)
})
})
describe('scoped execution dispatch', () => {
it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
ctx.tools.register(tool('t'))
const seen: (string | undefined)[] = []
scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise<PreToolDecision>) => {
seen.push(exec.agent?.id)
return Promise.resolve<PreToolDecision>({ kind: 'deny', reason: 'scoped veto' })
})
expect(await run(ctx, 't', key)).toBe('Error: scoped veto')
expect(await run(ctx, 't', other)).toBe('ran:t')
expect(await run(ctx, 't')).toBe('ran:t')
expect(seen).toEqual(['a'])
})
})