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

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

1072 lines
41 KiB
TypeScript

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 ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
} 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('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({ callId: CallId('c1'), 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({
callId: CallId('c1'),
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({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
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('ToolNotFoundError carries the tool name and a stable 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.toolName).toBe('ghost')
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 until the permission system lands', 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)' })
})
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 additionalContext', 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' }],
additionalContext: { 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.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute additionalContext rides 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', additionalContext: { 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.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
// execute() returns — the registry snapshots the authoritative fields before
// the waterfall and rebuilds from the snapshot + decision.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
mutable.callId = 'hijacked'
mutable.isError = true
mutable.error = { name: 'Evil', code: 'EVIL' }
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
expect(result.error).toBeUndefined() // no listener-injected error
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'hi' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
})
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('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({
callId: CallId('c1'),
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({
callId: CallId('c1'),
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({
callId: CallId('c1'),
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 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'])
await 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'])
await dispose()
expect(ctx.tools.get('echo')).toBeUndefined()
})
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
// The registry-disposer convention (set by agents.register): the returned
// function IS the cordis effect disposer, so a composite (generator)
// effect that yields it has the unregistration run at that yield's LIFO
// position on owner unload. A wrapper would leave the inner effect
// disposing as a CONCURRENT SIBLING of the composite; the async probe
// below (disposed first, LIFO) yields the event loop exactly like the
// agent factory's stop-and-drain link, and a sibling unregistration fires
// in that window — the probe would observe the tool already gone. Pins
// the convention for the whole register-method family (system-prompt
// registrars, registerProvider, setFactory share the same return).
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' },
},
})
// no 'required' key in the nested object because nothing is required
const config = jsonSchema.properties['config'] as Record<string, unknown>
expect('required' in config).toBe(false)
})
})
describe('schema DSL regressions (Codex review round 2)', () => {
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 }>()
// omitting the optional key is assignable — the actual regression
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 RFC, 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 RFC, 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({ callId: CallId('c1'), 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)
})
})
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()
})
})