| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462 |
- import { describe, expect, expectTypeOf, it } from 'vitest'
- import { Context } from 'cordis'
- import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
- import ToolRegistry, {
- defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
- type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
- type ToolExecution, type ToolExecutionResult,
- } from '@deepseek-ai/dsh-tools'
- async function setup() {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- return ctx
- }
- const echoTool = defineTool({
- name: 'echo',
- description: 'echo arguments back',
- parameters: { text: { type: 'string' } },
- async execute(args) {
- return [{ type: 'text' as const, text: args.text ?? '' }]
- },
- })
- describe('ToolRegistry', () => {
- it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- expect(ctx.tools.schemas()).toEqual([{
- name: 'echo',
- description: 'echo arguments back',
- parameters: { type: 'object', properties: { text: { type: 'string' } } },
- }])
- // schemas() result must not leak execute — ToolSchema deliberately has no
- // 'execute' key, so widen through unknown to probe for the absent property
- expect((ctx.tools.schemas()[0] as unknown as Record<string, unknown>).execute).toBeUndefined()
- const assembly = await ctx.systemPrompt.assemble()
- expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
- })
- it('schemas() drops the UI presentation callbacks — they must never reach the model', async () => {
- const ctx = await setup()
- // A tool that declares presentCall/presentResult (functions). schemas() feeds
- // the system-prompt assembly → the model request, so those callbacks (and
- // `execute`) must be stripped: a function in the JSON tool schema would
- // corrupt the request. schemas() is an explicit allowlist, so it can't leak.
- ctx.tools.register(defineTool({
- name: 'present',
- description: 'has presenters',
- parameters: { x: { type: 'string', required: true } },
- async execute() { return [] },
- presentCall: args => ({ card: 'generic', title: args.x }),
- presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }),
- }))
- const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
- expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
- expect(schema.presentCall).toBeUndefined()
- expect(schema.presentResult).toBeUndefined()
- expect(schema.execute).toBeUndefined()
- })
- it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] },
- }))
- const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
- expect(schema).toBeDefined()
- expect('timeoutMs' in (schema as object)).toBe(false)
- })
- it('executes a tool and returns its content', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
- })
- it('threads a tool-attached meta (object return form) onto the result', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'meta-tool',
- async execute() {
- return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
- expect(result).toEqual({
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
- })
- })
- it('omits meta when the object return form supplies none', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'no-meta-tool',
- async execute() {
- return { content: [{ type: 'text', text: 'ok' }] }
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
- expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
- expect('meta' in result).toBe(false)
- })
- it('normalizes a contract-violating non-cloneable result before final notification', async () => {
- const ctx = await setup()
- let observedError: boolean | undefined
- ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
- ctx.tools.register({
- ...echoTool,
- name: 'bad-meta',
- async execute() {
- return { content: [], meta: () => undefined }
- },
- })
- const result = await ctx.tools.execute({
- callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
- })
- expect(result.isError).toBe(true)
- expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
- expect(observedError).toBe(true)
- })
- it('returns isError results for unknown tools and throwing tools', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'boom',
- async execute() {
- throw new Error('exploded')
- },
- })
- const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} })
- expect(unknown.isError).toBe(true)
- expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
- // An unknown tool is a routable failure class, same as a tool-thrown one.
- expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
- const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
- expect(thrown.isError).toBe(true)
- expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
- })
- it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'hostile-throw',
- async execute() {
- throw new Proxy({}, {
- getPrototypeOf: () => { throw new Error('prototype trap') },
- has: () => { throw new Error('has trap') },
- get: () => { throw new Error('get trap') },
- })
- },
- })
- await expect(ctx.tools.execute({
- callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
- })).resolves.toMatchObject({
- isError: true,
- content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
- })
- })
- it('ToolNotFoundError carries a stable message and code', async () => {
- const { HarnessError } = await import('@deepseek-ai/dsh-llm')
- const err = new ToolNotFoundError('ghost')
- expect(err).toBeInstanceOf(HarnessError)
- expect(err.name).toBe('ToolNotFoundError')
- expect(err.code).toBe('UNKNOWN_TOOL')
- expect(err.message).toBe('unknown tool "ghost"')
- })
- it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
- if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
- return next()
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
- })
- it('an ask decision degrades to deny when no approval seam is mounted', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
- ({ kind: 'ask', reason: 'needs approval' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
- })
- it('an ask decision with no reason degrades to deny with a default message', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
- })
- describe('ask routing through ctx.approval', () => {
- /**
- * A minimal Agent stand-in — the approval seam reaches
- * `agent.session.append` and folds `.events`; the seeded open turn
- * satisfies request()'s enclosure precondition.
- */
- function fakeAgent(): Agent {
- return {
- session: { events: [{ type: 'turn/start' }], append: () => ({}) },
- } as unknown as Agent
- }
- async function approvalSetup() {
- const ctx = await setup()
- await ctx.plugin(ApprovalService)
- ctx.tools.register(echoTool)
- return ctx
- }
- it('dispatches the tool when the answerer grants allowed-once, forwarding the ask fields', async () => {
- const ctx = await approvalSetup()
- const agent = fakeAgent()
- const controller = new AbortController()
- const seen: ApprovalRequest[] = []
- ctx.on('approval/request', (req) => {
- seen.push(req)
- return Promise.resolve<ApprovalOutcome>('allowed-once')
- })
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
- ({ kind: 'ask', reason: 'hook wants a human' }))
- const result = await ctx.tools.execute({
- callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal,
- })
- expect(result).toMatchObject({ isError: false, content: [{ type: 'text', text: 'hi' }] })
- expect(seen).toHaveLength(1)
- expect(seen[0]).toMatchObject({ agent, toolName: 'echo', callId: 'c1', reason: 'hook wants a human' })
- expect(seen[0]?.signal).toBe(controller.signal)
- })
- it('denies with the user-rejection reason on rejected', async () => {
- const ctx = await approvalSetup()
- ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
- })
- it('denies with the cancellation reason on cancelled', async () => {
- const ctx = await approvalSetup()
- ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
- })
- it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
- const ctx = await approvalSetup()
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
- })
- it('denies an agent-less execution without asking — nothing to route or audit through', async () => {
- const ctx = await approvalSetup()
- let asked = false
- ctx.on('approval/request', () => {
- asked = true
- return Promise.resolve<ApprovalOutcome>('allowed-once')
- })
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
- expect(asked).toBe(false)
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
- })
- it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => {
- // ApprovalService normalizes rogue answers itself; this pins the
- // registry's own exhaustiveness backstop by shadowing the service with a
- // stand-in that violates the outcome contract.
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
- expect(result.isError).toBe(true)
- const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
- expect(text).toContain('unreachable')
- })
- })
- it('a tools/post-execute listener can replace the result content (accept) ', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
- ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(false)
- expect(result.content[0]).toMatchObject({ text: 'rewritten' })
- })
- it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
- ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
- })
- it('a block decision can ALSO attach additionalContexts', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
- ({
- kind: 'block',
- feedback: [{ type: 'text', text: 'rejected' }],
- additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
- }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'rejected' })
- expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
- })
- it('post-execute additionalContexts ride on the result for the loop to buffer', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
- ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
- })
- it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'composite',
- description: 'composite',
- parameters: {},
- async execute(_args, exec) {
- exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
- exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } })
- return [{ type: 'text', text: 'done' }]
- },
- }))
- ctx.on('tools/execute', async (_exec, next) => {
- const result = await next()
- return {
- ...result,
- additionalContexts: [
- ...result.additionalContexts ?? [],
- { content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' } },
- ],
- }
- })
- ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
- const downstream = await next()
- return {
- ...downstream,
- additionalContexts: [
- { content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' } },
- ...downstream.additionalContexts ?? [],
- ],
- }
- })
- const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} })
- expect(result.additionalContexts?.map(context => context.source)).toEqual([
- { kind: 'plugin', plugin: 'nested-1' },
- { kind: 'plugin', plugin: 'nested-2' },
- { kind: 'plugin', plugin: 'wrapper' },
- { kind: 'plugin', plugin: 'post' },
- ])
- expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
- })
- it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'failing-composite',
- description: 'failing composite',
- parameters: {},
- async execute(_args, exec) {
- exec.deferContext({ content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' } })
- throw new Error('outer failure')
- },
- }))
- const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} })
- expect(failed.isError).toBe(true)
- expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
- ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
- kind: 'block',
- feedback: [{ type: 'text', text: 'blocked' }],
- additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
- }))
- const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
- expect(blocked.isError).toBe(true)
- expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
- })
- it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- const order: string[] = []
- ctx.on('tools/pre-execute', async (_exec, next) => {
- order.push('pre:before')
- const decision = await next()
- order.push('pre:after')
- return decision
- })
- ctx.on('tools/post-execute', async (_exec, _result, next) => {
- order.push('post:before')
- const decision = await next()
- order.push('post:after')
- return decision
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
- expect(result.isError).toBe(false)
- // pre runs fully (gate) before dispatch, then post runs over the result.
- expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
- })
- it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
- const ctx = await setup()
- const order: string[] = []
- ctx.tools.register(defineTool({
- name: 'traced',
- description: 'echo',
- parameters: { text: { type: 'string' } },
- async execute(args) {
- order.push('dispatch')
- return [{ type: 'text' as const, text: args.text ?? '' }]
- },
- }))
- ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
- ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
- order.push('execute:before')
- const result = await next()
- order.push('execute:after')
- return result
- })
- ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
- expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
- // The around seam wraps dispatch; pre gates before it, post runs over its result.
- expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
- })
- it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- let entered = false
- ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
- ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
- entered = true
- return next()
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
- expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
- })
- it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'boom',
- async execute() { throw new HarnessError('kaboom', 'BOOM') },
- })
- let seen: { isError: boolean; error?: unknown } | undefined
- ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
- const result = await next()
- // The base next() IS dispatch-with-normalization: the wrapper sees the
- // normalized isError result, never a raw throw from the tool body.
- seen = { isError: result.isError, error: result.error }
- return result
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
- expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
- })
- it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'boom',
- async execute() { throw new Error('exploded') },
- })
- let postSaw: boolean | undefined
- ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
- ctx.on('tools/post-execute', async (_exec, result, next) => {
- postSaw = result.isError
- return next()
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
- expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
- })
- it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
- const ctx = await setup()
- let seenSignal: AbortSignal | undefined
- ctx.tools.register({
- ...echoTool,
- name: 'signal-probe',
- async execute(_args, exec) {
- seenSignal = exec.signal
- return [{ type: 'text' as const, text: 'ok' }]
- },
- })
- const upstream = new AbortController().signal
- const replacement = new AbortController().signal
- ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
- expect(exec.signal).toBe(upstream)
- // Cordis next() ignores passed arguments, so a wrapper mutates exec in
- // place (the documented "mutate the shared object, then delegate" idiom).
- exec.signal = replacement
- return next()
- })
- await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
- expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
- })
- it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
- const ctx = await setup()
- let dispatched = false
- ctx.tools.register({
- ...echoTool,
- name: 'never-runs',
- async execute() { dispatched = true; return [] },
- })
- ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
- ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
- expect(dispatched).toBe(false) // returning without next() skips core dispatch
- expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
- })
- it('preserves additionalContexts supplied by an around-dispatch result', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/execute', async () => ({
- content: [{ type: 'text', text: 'short-circuited with context' }],
- isError: false,
- additionalContexts: [{
- content: [{ type: 'text', text: 'from around dispatch' }],
- source: { kind: 'plugin', plugin: 'test' },
- }],
- }))
- const result = await ctx.tools.execute({
- callId: CallId('around-context'), name: 'echo', arguments: {},
- })
- expect(result.additionalContexts).toEqual([{
- content: [{ type: 'text', text: 'from around dispatch' }],
- source: { kind: 'plugin', plugin: 'test' },
- }])
- })
- it('returns an isError result when a tools/execute listener throws', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result).toEqual({
- content: [{ type: 'text', text: 'Error: wrapper broke' }],
- isError: true,
- })
- })
- it('returns an isError result when a tools/pre-execute listener throws', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/pre-execute', async () => {
- throw new Error('permission hook broke')
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result).toEqual({
- content: [{ type: 'text', text: 'Error: permission hook broke' }],
- isError: true,
- })
- })
- it('returns an isError result when a tools/post-execute listener throws', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/post-execute', async () => {
- throw new Error('post hook broke')
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result).toEqual({
- content: [{ type: 'text', text: 'Error: post hook broke' }],
- isError: true,
- })
- })
- it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- ctx.on('tools/pre-execute', async () => {
- throw new HarnessError('denied', 'DENIED')
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result).toMatchObject({
- isError: true,
- error: { name: 'HarnessError', code: 'DENIED' },
- })
- })
- it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- const first = ctx.tools.schemas()
- const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
- firstParameters.properties['mutated'] = { type: 'string' }
- first[0]!.description = 'mutated'
- expect(ctx.tools.schemas()).toEqual([{
- name: 'echo',
- description: 'echo arguments back',
- parameters: { type: 'object', properties: { text: { type: 'string' } } },
- }])
- })
- it('rejects a non-positive or non-finite registration timeout', async () => {
- const ctx = await setup()
- expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
- .toThrow('timeoutMs must be a positive finite number')
- expect(() => ctx.tools.register({ ...echoTool, name: 'infinite-timeout', timeoutMs: Number.POSITIVE_INFINITY }))
- .toThrow('timeoutMs must be a positive finite number')
- })
- it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.tools.register({ ...echoTool, name: 'scoped' })
- }, { inject: ['tools'] }))
- expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
- await fiber.dispose()
- expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
- })
- it('returns a callable disposer from register() that unregisters the tool', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- // Register a second tool and call its returned disposer directly
- const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
- expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
- dispose()
- expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
- })
- it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
- const ctx = await setup()
- let threw = false
- ctx.on('tools/change', () => {
- if (!threw) { threw = true; throw new Error('boom change listener') }
- })
- // The throwing emit must roll the entry back, not leak it.
- expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
- expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
- expect(ctx.tools.schemas()).toHaveLength(0)
- // A subsequent listener-free register of the SAME name succeeds and is
- // exposed exactly once (the duplicate-name check is not wedged).
- const dispose = ctx.tools.register(echoTool)
- expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
- dispose()
- expect(ctx.tools.get('echo')).toBeUndefined()
- })
- it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
- // Registry methods return the exact Cordis effect disposer so a composite yield places
- // unregistration at its LIFO position. A wrapper would create a concurrent sibling; this async
- // probe yields during earlier teardown and would then observe the tool already removed.
- const ctx = await setup()
- const order: string[] = []
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.effect(function* () {
- yield () => { order.push('disposed-last') }
- yield inner.tools.register({ ...echoTool, name: 'nested' })
- order.push('registered')
- yield async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- order.push(inner.tools.get('nested') ? 'first: still registered' : 'first: already gone')
- }
- })
- }, { inject: ['tools'] }))
- await fiber.dispose()
- expect(order).toEqual(['registered', 'first: still registered', 'disposed-last'])
- expect(ctx.tools.get('nested')).toBeUndefined()
- })
- })
- describe('defineTool / schema DSL', () => {
- it('converts SchemaSpec to standard JSON Schema with required array', () => {
- const spec = {
- path: { type: 'string', required: true, description: 'Absolute path' },
- offset: { type: 'number' },
- limit: { type: 'number', description: 'Max lines' },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema).toEqual({
- type: 'object',
- properties: {
- path: { type: 'string', description: 'Absolute path' },
- offset: { type: 'number' },
- limit: { type: 'number', description: 'Max lines' },
- },
- required: ['path'],
- })
- })
- it('handles empty spec (no properties, no required)', () => {
- expect(schemaSpecToJsonSchema({})).toEqual({
- type: 'object',
- properties: {},
- })
- })
- it('handles nested object spec', () => {
- const spec = {
- config: {
- type: 'object',
- required: true,
- properties: {
- host: { type: 'string', required: true },
- port: { type: 'number' },
- },
- },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema).toEqual({
- type: 'object',
- properties: {
- config: {
- type: 'object',
- properties: {
- host: { type: 'string' },
- port: { type: 'number' },
- },
- required: ['host'],
- },
- },
- required: ['config'],
- })
- })
- it('defineTool returns a valid ToolDefinition with typed execute', async () => {
- const ctx = await setup()
- const tool = defineTool({
- name: 'typed-echo',
- description: 'A typed echo tool',
- parameters: {
- text: { type: 'string', required: true },
- uppercase: { type: 'boolean' },
- },
- async execute(args) {
- // args is typed: { text: string; uppercase?: boolean }
- const result = args.uppercase ? args.text.toUpperCase() : args.text
- return [{ type: 'text', text: result }]
- },
- })
- ctx.tools.register(tool)
- expect(ctx.tools.schemas()).toEqual([{
- name: 'typed-echo',
- description: 'A typed echo tool',
- parameters: {
- type: 'object',
- properties: {
- text: { type: 'string' },
- uppercase: { type: 'boolean' },
- },
- required: ['text'],
- },
- }])
- const result = await ctx.tools.execute({
- callId: CallId('c1'),
- name: 'typed-echo',
- arguments: { text: 'hello', uppercase: true },
- })
- expect(result.isError).toBe(false)
- expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
- })
- it('type-level: InferArgs maps required properties to non-optional', () => {
- // Compile-time check: if this compiles, InferArgs is correct.
- // args.a is string (required), args.b is number|undefined (optional).
- const tool = defineTool({
- name: 'type-check',
- description: '',
- parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
- async execute(args) {
- // Verify types at runtime via typeof
- expect(typeof args.a).toBe('string')
- // args.b should be undefined when not provided
- void args
- return [{ type: 'text', text: args.a }]
- },
- })
- void tool
- })
- it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'roundtrip',
- description: 'Round-trip test',
- parameters: {
- req: { type: 'string', required: true },
- opt: { type: 'number', description: 'Optional number' },
- },
- async execute(args) {
- return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
- },
- }))
- // Schema round-trip: schemas() returns standard JSON Schema
- const schemas = ctx.tools.schemas()
- expect(schemas).toHaveLength(1)
- expect(schemas[0]!.parameters).toEqual({
- type: 'object',
- properties: {
- req: { type: 'string' },
- opt: { type: 'number', description: 'Optional number' },
- },
- required: ['req'],
- })
- // Execution round-trip
- const result = await ctx.tools.execute({
- callId: CallId('c1'),
- name: 'roundtrip',
- arguments: { req: 'hello' },
- })
- expect(result.isError).toBe(false)
- expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
- })
- it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
- const ctx = await setup()
- ctx.tools.register({
- name: 'raw-tool',
- description: 'Raw JSON Schema tool (like an MCP adapter would register)',
- parameters: {
- type: 'object',
- properties: { path: { type: 'string' } },
- required: ['path'],
- },
- async execute(args: unknown) {
- const p = args as { path: string }
- return [{ type: 'text', text: p.path }]
- },
- })
- const schemas = ctx.tools.schemas()
- expect(schemas[0]!.parameters).toEqual({
- type: 'object',
- properties: { path: { type: 'string' } },
- required: ['path'],
- })
- const result = await ctx.tools.execute({
- callId: CallId('c1'),
- name: 'raw-tool',
- arguments: { path: '/tmp' },
- })
- expect(result.isError).toBe(false)
- expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
- })
- })
- describe('schema DSL edge cases', () => {
- it('emits enum values in JSON Schema property', () => {
- const spec = {
- color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema.properties['color']).toMatchObject({
- type: 'string',
- enum: ['red', 'green', 'blue'],
- description: 'Color choice',
- })
- })
- it('emits default value in JSON Schema property', () => {
- const spec = {
- limit: { type: 'number', default: 25 },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema.properties['limit']).toMatchObject({
- type: 'number',
- default: 25,
- })
- })
- it('handles array items without nested properties (plain type array)', () => {
- const spec = {
- tags: { type: 'array', items: { type: 'string' } },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema.properties['tags']).toEqual({
- type: 'array',
- items: { type: 'string' },
- })
- })
- it('handles enum and default together in one property', () => {
- const spec = {
- level: { type: 'string', enum: ['low', 'high'], default: 'low' },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema.properties['level']).toMatchObject({
- type: 'string',
- enum: ['low', 'high'],
- default: 'low',
- })
- })
- it('omits description, enum, default keys when not specified', () => {
- const spec = {
- bare: { type: 'string' },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- const prop = jsonSchema.properties['bare'] as Record<string, unknown>
- expect(prop).toEqual({ type: 'string' })
- expect('description' in prop).toBe(false)
- expect('enum' in prop).toBe(false)
- expect('default' in prop).toBe(false)
- })
- it('handles array with no items (items omitted)', () => {
- const spec = {
- raw: { type: 'array' },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema.properties['raw']).toEqual({
- type: 'array',
- })
- })
- it('handles nested object with all-optional properties (no required array)', () => {
- const spec = {
- config: {
- type: 'object',
- properties: {
- host: { type: 'string' },
- port: { type: 'number' },
- },
- },
- } satisfies SchemaSpec
- const jsonSchema = schemaSpecToJsonSchema(spec)
- expect(jsonSchema.properties['config']).toMatchObject({
- type: 'object',
- properties: {
- host: { type: 'string' },
- port: { type: 'number' },
- },
- })
- const config = jsonSchema.properties['config'] as Record<string, unknown>
- expect('required' in config).toBe(false)
- })
- })
- describe('schema DSL optional and nested contracts', () => {
- it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
- type Args = InferArgs<{
- path: { type: 'string'; required: true }
- limit: { type: 'number' }
- }>
- expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
- const omitted: Args = { path: '/tmp' }
- expect(omitted.limit).toBeUndefined()
- })
- it('InferArgs recurses into array items, including arrays of objects', () => {
- type Args = InferArgs<{
- names: { type: 'array'; required: true; items: { type: 'string' } }
- servers: {
- type: 'array'
- items: {
- type: 'object'
- properties: {
- host: { type: 'string'; required: true }
- port: { type: 'number' }
- }
- }
- }
- }>
- expectTypeOf<Args>().toEqualTypeOf<{
- names: string[]
- servers?: { host: string; port?: number }[]
- }>()
- })
- it('runtime JSON Schema matches the array-of-objects inference', () => {
- const spec = {
- servers: {
- type: 'array',
- items: {
- type: 'object',
- properties: {
- host: { type: 'string', required: true },
- port: { type: 'number' },
- },
- },
- },
- } satisfies SchemaSpec
- expect(schemaSpecToJsonSchema(spec)).toEqual({
- type: 'object',
- properties: {
- servers: {
- type: 'array',
- items: {
- type: 'object',
- properties: {
- host: { type: 'string' },
- port: { type: 'number' },
- },
- required: ['host'],
- },
- },
- },
- })
- })
- it('reports messages from non-Error throws (throw { message })', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'object-thrower',
- async execute() {
- // testing non-Error throws on purpose
- throw { message: 'denied by object' }
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
- })
- it('reports messages from throws of non-objects (throw "string")', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'string-thrower',
- async execute() {
- // testing primitive throws on purpose
- throw 'kaboom'
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
- })
- it('reports messages from throws of objects without message property', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'object-no-message',
- async execute() {
- // testing object throw without .message
- throw { code: 500 }
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
- expect(result.isError).toBe(true)
- const firstContent = result.content[0]!
- expect(firstContent.type).toBe('text')
- if (firstContent.type === 'text') {
- expect(firstContent.text).toBe('Error: [object Object]')
- }
- })
- })
- describe('ToolRegistry.get', () => {
- it('get() returns the registered tool definition', async () => {
- const ctx = await setup()
- ctx.tools.register(echoTool)
- const tool = ctx.tools.get('echo')
- expect(tool).toBeDefined()
- expect(tool!.name).toBe('echo')
- })
- it('get() returns undefined for unknown tool names', async () => {
- const ctx = await setup()
- expect(ctx.tools.get('nope')).toBeUndefined()
- })
- })
- describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
- it('returns [] for valid args and is total over malformed input', () => {
- const spec = {
- path: { type: 'string', required: true },
- limit: { type: 'number' },
- } satisfies SchemaSpec
- expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
- expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
- // never throws regardless of shape
- expect(validateArgs(spec, null)).toHaveLength(1)
- expect(validateArgs(spec, 'nope')).toHaveLength(1)
- expect(validateArgs(spec, [])).toHaveLength(1)
- })
- it('flags a missing required key and a required key present as undefined', () => {
- const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
- expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
- expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
- })
- it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
- const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
- expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
- })
- it('does not apply defaults (validation only)', () => {
- const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
- // absent optional is valid, and validation does not synthesize the default
- expect(validateArgs(spec, {})).toEqual([])
- })
- it('type-checks primitives', () => {
- const spec = {
- s: { type: 'string' },
- n: { type: 'number' },
- b: { type: 'boolean' },
- } satisfies SchemaSpec
- expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
- expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
- expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
- })
- it('checks enum membership', () => {
- const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
- expect(validateArgs(spec, { color: 'red' })).toEqual([])
- expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
- })
- it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
- // The converter emits `enum` regardless of type; the validator must agree.
- // `enum` is string[], so a number value can never be a member.
- const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
- expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
- })
- it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
- const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
- expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
- })
- it('recurses into nested objects (and an object without properties only type-checks)', () => {
- const spec = {
- config: {
- type: 'object',
- required: true,
- properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
- },
- bag: { type: 'object' },
- } satisfies SchemaSpec
- expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
- expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
- 'missing required property "config.host"',
- '"bag" must be an object',
- ])
- })
- it('recurses into array items (and an array without items only type-checks)', () => {
- const spec = {
- tags: { type: 'array', items: { type: 'string' } },
- raw: { type: 'array' },
- } satisfies SchemaSpec
- expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
- expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
- // a non-array value for an array-typed prop
- expect(validateArgs(spec, { tags: 'nope' })).toEqual(['"tags" must be an array'])
- })
- it('validates arrays of objects element-wise', () => {
- const spec = {
- servers: {
- type: 'array',
- items: { type: 'object', properties: { host: { type: 'string', required: true } } },
- },
- } satisfies SchemaSpec
- expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
- 'missing required property "servers[1].host"',
- ])
- })
- })
- describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => {
- it('returns an isError result with the violations when the model sends bad args', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'reader',
- description: 'reads a path',
- parameters: { path: { type: 'string', required: true } },
- async execute(args) {
- return [{ type: 'text', text: args.path }]
- },
- }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
- expect(result.isError).toBe(true)
- expect(result.content[0]).toMatchObject({
- text: 'Error: invalid arguments: missing required property "path"',
- })
- })
- it('runs execute normally when args are valid', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'reader',
- description: 'reads a path',
- parameters: { path: { type: 'string', required: true } },
- async execute(args) {
- return [{ type: 'text', text: `read ${args.path}` }]
- },
- }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
- expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
- })
- it('ToolArgsError carries a stable code and the violation list', () => {
- const err = new ToolArgsError(['missing required property "a"', '"b" must be a number'])
- expect(err).toBeInstanceOf(Error)
- expect(err.name).toBe('ToolArgsError')
- expect(err.code).toBe('INVALID_ARGS')
- expect(err.violations).toEqual(['missing required property "a"', '"b" must be a number'])
- expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
- })
- it('a schema-invalid call surfaces the structured error on the result', async () => {
- const ctx = await setup()
- ctx.tools.register(defineTool({
- name: 'reader',
- description: 'reads a path',
- parameters: { path: { type: 'string', required: true } },
- async execute(args) {
- return [{ type: 'text', text: args.path }]
- },
- }))
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
- expect(result.isError).toBe(true)
- expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
- })
- it('a tool throwing a HarnessError surfaces its name and code', async () => {
- const { HarnessError } = await import('@deepseek-ai/dsh-llm')
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'coded',
- async execute() {
- throw new HarnessError('disk full', 'ENOSPC')
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
- expect(result.isError).toBe(true)
- expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
- expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
- })
- it('a non-HarnessError throw has no structured error (only the text)', async () => {
- const ctx = await setup()
- ctx.tools.register({
- ...echoTool,
- name: 'plain',
- async execute() {
- throw new Error('just a message')
- },
- })
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
- expect(result.isError).toBe(true)
- expect(result.error).toBeUndefined()
- expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
- })
- it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
- const ctx = await setup()
- // A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.
- ctx.tools.register({
- name: 'raw',
- description: 'raw tool',
- parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
- async execute(args: unknown) {
- return [{ type: 'text', text: typeof args }]
- },
- })
- // Missing the "required" path — but raw tools validate their own input, so
- // this reaches execute rather than being rejected by the harness.
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
- expect(result.isError).toBe(false)
- })
- it('attaches a positive-finite timeoutMs to the definition', () => {
- const tool = defineTool({
- name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] },
- })
- expect(tool.timeoutMs).toBe(30_000)
- })
- it('omits timeoutMs when not declared', () => {
- const tool = defineTool({
- name: 'x', description: 'd', parameters: {},
- async execute() { return [{ type: 'text' as const, text: 'ok' }] },
- })
- expect(tool.timeoutMs).toBeUndefined()
- })
- it('throws when timeoutMs is zero or negative', () => {
- const make = (ms: number) => defineTool({
- name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] },
- })
- expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
- expect(() => make(-5)).toThrow('positive finite number')
- })
- it('throws when timeoutMs is non-finite', () => {
- expect(() => defineTool({
- name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] },
- })).toThrow('positive finite number')
- })
- })
- describe('defineTool presentation (presentCall / presentResult)', () => {
- it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
- const tool = defineTool({
- name: 'demo',
- description: 'demo',
- parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
- async execute() { return [{ type: 'text', text: 'ok' }] },
- presentCall(args) {
- // args is typed { path: string; n?: number } — zero casts.
- expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
- return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
- },
- presentResult(args, result) {
- return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
- },
- })
- expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' })
- expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
- .toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
- })
- it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
- const tool = defineTool({
- name: 'plain',
- description: 'plain',
- parameters: { x: { type: 'string', required: true } },
- async execute() { return [] },
- })
- expect(typeof tool.presentCall).toBe('undefined')
- expect(typeof tool.presentResult).toBe('undefined')
- })
- it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
- const tool = defineTool({
- name: 'demo',
- description: 'demo',
- parameters: { path: { type: 'string', required: true } },
- async execute() { return [] },
- presentCall: args => ({ card: 'generic', title: args.path }),
- presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
- })
- // Unlike execute (which throws ToolArgsError on a mismatch), the display
- // methods soft-validate and fall back to undefined so a UI never crashes
- // replaying an old/foreign log entry. The ToolDefinition methods take
- // `unknown`, so malformed shapes pass without a cast.
- expect(tool.presentCall?.({})).toBeUndefined()
- expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
- })
- })
|