import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Events } 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, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { 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 SessionId } 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: {} }, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }], }, execute: (): Promise => Promise.resolve(reply), } } async function run(ctx: Context, name: string, agent?: Agent): Promise { const result = await ctx.tools.execute({ signal: testToolSignal, 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('keeps final-result observers synchronous', () => { type ToolResultListener = Events['tools/result'] type AsyncToolResultListener = () => Promise expectTypeOf().not.toExtend() expectTypeOf>().toEqualTypeOf() }) 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 SessionId } 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.schemas(key)).toEqual([]) }) }) describe('restrict()', () => { it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', 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 scope-local registration 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('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => { const ctx = await mount() const denied = await mintAgentScope(ctx, 'denied') const allowed = await mintAgentScope(ctx, 'allowed') ctx.tools.register(tool('read')) ctx.tools.register(tool('bash')) denied.scope.ctx.tools.restrict({ deny: ['bash'] }) allowed.scope.ctx.tools.restrict({ allow: ['read'] }) ctx.tools.register(tool('web')) denied.scope.ctx.tools.register(tool('denied-local')) allowed.scope.ctx.tools.register(tool('allowed-local')) expect(ctx.tools.schemas(denied.key).map(t => t.name).sort()) .toEqual(['denied-local', 'read', 'web']) expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort()) .toEqual(['allowed-local', 'read']) expect(await run(ctx, 'web', denied.key)).toBe('ran:web') expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"') expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local') expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local') }) 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']) liftAllow() // The deny remains after the allow-list is lifted. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c']) }) it('compiles the readonly filter values at registration', 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 non-global names', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('real')) scope.ctx.tools.register(tool('local')) 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: ['local'] })).toThrow(/unknown global tool "local"/) expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/) expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/) const emptyCtx = await mount() const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) .toThrow(/known global tools: \(none\)/) }) }) 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 SessionId } as Agent ctx.tools.register(tool('t')) const seen: (string | undefined)[] = [] scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise) => { seen.push(exec.agent?.id) return Promise.resolve({ 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']) }) it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') const other = { id: 'other' as SessionId } as Agent let bodyCalls = 0 ctx.tools.register({ ...tool('t'), execute: () => { bodyCalls += 1 return Promise.resolve('ran:t') }, }) const guard = (execution: Readonly): string => { expect(Object.isFrozen(execution.arguments)).toBe(true) return 'terminal policy' } const liftFirst = scope.ctx.tools.guard(guard) scope.ctx.tools.guard(guard) // Registered later and prepended outside every existing waterfall listener: // it can force the extensible pre decision to allow, but cannot bypass the // owner-level monotonic guard that runs after the waterfall. scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true }) expect(await run(ctx, 't', key)).toBe('Error: terminal policy') expect(await run(ctx, 't', other)).toBe('ran:t') expect(bodyCalls).toBe(1) liftFirst() expect(await run(ctx, 't', key)).toBe('Error: terminal policy') await scope.dispose() expect(await run(ctx, 't', key)).toBe('ran:t') expect(bodyCalls).toBe(2) }) it('composes global guards monotonically when one abstains and a later one denies', async () => { const ctx = await mount() let bodyCalls = 0 ctx.tools.register({ ...tool('t'), execute: () => { bodyCalls += 1 return Promise.resolve('ran:t') }, }) ctx.tools.guard(() => undefined) ctx.tools.guard(() => 'global denial') expect(await run(ctx, 't')).toBe('Error: global denial') expect(bodyCalls).toBe(0) }) it('live-iterates a guard registered by an earlier guard', async () => { const ctx = await mount() const calls: string[] = [] let added = false ctx.tools.register(tool('t')) ctx.tools.guard(() => { calls.push('first') if (!added) { added = true ctx.tools.guard(() => { calls.push('late') return 'late denial' }) } return undefined }) expect(await run(ctx, 't')).toBe('Error: late denial') expect(calls).toEqual(['first', 'late']) }) it('defers a scoped guard that replaces the last guard in its generation', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') const calls: string[] = [] ctx.tools.register(tool('t')) scope.ctx.tools.register(tool('scope_sibling')) const lift = scope.ctx.tools.guard(() => { calls.push('first') lift() scope.ctx.tools.guard(() => { calls.push('replacement') return 'replacement denial' }) return undefined }) expect(await run(ctx, 't', key)).toBe('ran:t') expect(calls).toEqual(['first']) expect(await run(ctx, 't', key)).toBe('Error: replacement denial') expect(calls).toEqual(['first', 'replacement']) }) it('shares one token and materialized argument value across the pipeline', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') let safeCalls = 0 let dangerCalls = 0 let scopedResults = 0 let safeArguments: unknown const tokens = new Set() ctx.tools.register({ ...tool('safe'), execute: (args) => { safeCalls += 1 safeArguments = args return Promise.resolve('safe') }, }) ctx.tools.register({ ...tool('danger'), execute: () => { dangerCalls += 1 return Promise.resolve('danger') }, }) scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined) ctx.on('tools/pre-execute', (exec, next) => { tokens.add(exec.token) expect(Object.isFrozen(exec.arguments)).toBe(true) return next() }) ctx.on('tools/execute', (exec, next) => { tokens.add(exec.token) return next() }) ctx.on('tools/post-execute', (exec, _result, next) => { tokens.add(exec.token) return next() }) scope.ctx.on('tools/result', () => { scopedResults += 1 }) expect(await run(ctx, 'danger', key)).toBe('Error: danger denied') const callerArguments = { source: true } const safeResult = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('safe-call'), name: 'safe', arguments: callerArguments, agent: key, }) expect(safeResult.content[0]).toMatchObject({ text: 'safe' }) expect(Object.isFrozen(callerArguments)).toBe(false) expect(safeArguments).not.toBe(callerArguments) expect(Object.isFrozen(safeArguments)).toBe(true) expect(callerArguments).toEqual({ source: true }) // One token for danger and one shared by every phase of safe. expect(tokens.size).toBe(2) expect({ safeCalls, dangerCalls, scopedResults }).toEqual({ safeCalls: 1, dangerCalls: 0, scopedResults: 2, }) }) it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') let policyCalls = 0 let bodyCalls = 0 let scopedObserved = 0 let globalObserved = 0 ctx.tools.register({ ...tool('t'), execute: () => { bodyCalls += 1 return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { policyCalls += 1 return next() }) let parent!: ToolExecutionToken ctx.tools.register(tool('parent')) const stopCapture = ctx.on('tools/pre-execute', (exec, next) => { if (exec.name === 'parent') parent = exec.token return next() }) await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} }) stopCapture() policyCalls = 0 const signal = new AbortController().signal scope.ctx.on('tools/result', (exec, result) => { scopedObserved += 1 expect(exec.arguments).toBeUndefined() expect(exec.parent).toBe(parent) expect(exec.signal).toBe(signal) expect(Object.isFrozen(exec)).toBe(true) expect(result.isError).toBe(true) }) ctx.on('tools/result', () => { globalObserved += 1 }) const callerArguments = { invalid: () => undefined } const scopedResult = await ctx.tools.execute({ callId: CallId('non-cloneable'), name: 't', arguments: callerArguments, agent: key, parent, signal, }) const subjectlessResult = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('non-cloneable-subjectless'), name: 't', arguments: { invalid: () => undefined }, }) expect(scopedResult.isError).toBe(true) expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable') expect(subjectlessResult.isError).toBe(true) expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({ policyCalls: 0, bodyCalls: 0, scopedObserved: 1, globalObserved: 2, }) expect(Object.isFrozen(callerArguments)).toBe(false) expect(callerArguments.invalid).toBeTypeOf('function') }) it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => { const ctx = await mount() const observed: (ToolExecutionToken | undefined)[] = [] ctx.tools.register({ ...tool('t'), execute: (_args, exec) => { observed.push(exec.parent) return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (exec, next) => { observed.push(exec.parent) return next() }) ctx.on('tools/execute', (exec, next) => { observed.push(exec.parent) return next() }) ctx.on('tools/result', (exec) => { observed.push(exec.parent) }) const forged = { fake: true } as unknown as ToolExecutionToken let parentReads = 0 const input = { callId: CallId('stateful-parent'), name: 't', arguments: {}, signal: testToolSignal, get parent(): ToolExecutionToken | undefined { parentReads += 1 return parentReads === 1 ? undefined : forged }, } as ToolExecutionInput const result = await ctx.tools.execute(input) expect(result.isError).toBe(false) expect(parentReads).toBe(1) expect(observed).toEqual([undefined, undefined, undefined, undefined]) }) it('uses one input snapshot for the normalized error shell', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'accepted') const driftAgent = { id: 'drift' as SessionId } as Agent ctx.tools.register(tool('parent')) ctx.tools.register(tool('t')) let parent!: ToolExecutionToken const stopCapture = ctx.on('tools/pre-execute', (exec, next) => { if (exec.name === 'parent') parent = exec.token return next() }) await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} }) stopCapture() const acceptedSignal = new AbortController().signal const driftSignal = new AbortController().signal const forged = { fake: true } as unknown as ToolExecutionToken const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 } const input = { get callId() { reads.callId += 1; return CallId('unstable-error') }, get name() { reads.name += 1; return 't' }, get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } }, get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent }, get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged }, get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal }, } as ToolExecutionInput let observed: Readonly | undefined let scopedObserved = 0 ctx.on('tools/result', (exec) => { observed = exec }) scope.ctx.on('tools/result', () => { scopedObserved += 1 }) const result = await ctx.tools.execute(input) expect(result.isError).toBe(true) expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 }) expect(scopedObserved).toBe(1) expect(observed).toMatchObject({ callId: CallId('unstable-error'), name: 't', agent: key, parent, signal: acceptedSignal, }) expect(Object.isFrozen(observed)).toBe(true) }) it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => { const ctx = await mount() ctx.tools.register(tool('t')) let argumentReads = 0 let observed = 0 ctx.on('tools/result', (exec, result) => { observed += 1 expect(exec.arguments).toBeUndefined() expect(result.isError).toBe(true) }) const input = { callId: CallId('throwing-arguments'), name: 't', signal: testToolSignal, get arguments(): unknown { argumentReads += 1 throw new Error('getter exploded') }, } as ToolExecutionInput const result = await ctx.tools.execute(input) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }]) expect(argumentReads).toBe(1) expect(observed).toBe(1) }) it.each([ ['Map', new Map([['mutable', true]])], ['class instance', new (class Arguments { value = 1 })()], ])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => { const ctx = await mount() let policyCalls = 0 let bodyCalls = 0 let observed = 0 ctx.tools.register({ ...tool('t'), execute: () => { bodyCalls += 1 return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { policyCalls += 1 return next() }) ctx.on('tools/result', (exec, result) => { observed += 1 expect(exec.arguments).toBeUndefined() expect(result.isError).toBe(true) }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue, }) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable', }]) expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 }) }) it('reads nested arguments once into the executed snapshot', async () => { const ctx = await mount() ctx.tools.register(tool('t')) let reads = 0 const argumentsValue = Object.defineProperty({}, 'value', { enumerable: true, get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]), }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) expect(reads).toBe(1) expect(result).toEqual({ content: [{ type: 'text', text: 'ran:t' }], isError: false, value: 'ran:t', }) }) it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('t')) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) const seen: boolean[] = [] const dispatchModes: string[] = [] ctx.on('internal/dispatch', (mode, name) => { if (name === 'tools/result') dispatchModes.push(mode) }) ctx.on('tools/execute', async (_exec, next) => { await next() return { content: [{ type: 'text', text: 'outer failure' }], isError: true, error: { message: 'outer failure' }, } }, { prepend: true }) scope.ctx.on('tools/result', (_exec, result) => { expect(Object.isFrozen(_exec)).toBe(true) expect(Object.isFrozen(_exec.arguments)).toBe(true) expect(Object.isFrozen(result)).toBe(true) expect(Object.isFrozen(result.content)).toBe(true) seen.push(result.isError) }) ctx.on('tools/result', () => { throw { toString: () => { throw new Error('coercion trap') } } }) ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never) ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key }) await Promise.resolve() expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) expect(dispatchModes).toEqual(['emit']) expect(warn).toHaveBeenCalledTimes(2) expect(warn.mock.calls.map(call => String(call[0]))).toEqual(expect.arrayContaining([ expect.stringContaining(''), expect.stringContaining('async observer failure'), ])) }) })