| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656 |
- 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<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 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<string> => Promise.resolve(reply),
- }
- }
- async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
- 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<void>
- expectTypeOf<AsyncToolResultListener>().not.toExtend<ToolResultListener>()
- expectTypeOf<ReturnType<ToolResultListener>>().toEqualTypeOf<undefined>()
- })
- 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<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'])
- })
- 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<ToolExecution>): 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<ToolExecutionToken>()
- 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<ToolExecution> | 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('<unprintable thrown value>'),
- expect.stringContaining('async observer failure'),
- ]))
- })
- })
|