Files
deepseek-harness/packages/tools/tests/tools.spec.ts
T
Tianyi Cui ef45ca823a Fix schema-DSL findings from the second Codex review
InferArgs now produces genuinely optional keys: required/optional
properties are split at the key level (RequiredKeys + mapped `?`), so
{ limit: { type: 'number' } } infers as { limit?: number } and callers
can omit it — previously the key stayed required with `| undefined`.
Array item inference recurses (arrays of objects infer their element
shape instead of Record<string, unknown>), matching the generated
JSON Schema.

Tool execution error reporting handles non-Error throws again:
`throw { message: 'denied' }` reports the message instead of
"[object Object]" (errorMessage helper).

The new schema tests now actually typecheck: schema literals use
`satisfies SchemaSpec` (the standalone-literal widening made
schemaSpecToJsonSchema reject the suite's own examples), and the
ToolSchema probe cast goes through unknown. Tests-and-examples
typechecking is now part of `yarn typecheck` via the new
tsconfig.typecheck.json (resolves vendor packages by their built
declarations, so vendor's relaxed-strictness source stays out of
scope) — vitest never typechecks, so this gate is what catches such
breakage. +4 regression tests (typed omission, array-of-objects
inference both type- and runtime-level, non-Error throw message).
2026-06-11 13:46:01 +08:00

384 lines
12 KiB
TypeScript

import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema,
type InferArgs, type SchemaSpec, 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: String(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('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: 'c1', content: [{ type: 'text', text: 'hi' }], isError: 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: 'c1', name: 'nope', arguments: {} })
expect(unknown.isError).toBe(true)
const thrown = await ctx.tools.execute({ callId: 'c2', name: 'boom', arguments: {} })
expect(thrown.isError).toBe(true)
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
})
it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
if (exec.name === 'echo') {
return {
callId: exec.callId,
content: [{ type: 'text', text: 'denied by policy' }],
isError: true,
}
}
return next()
})
const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'denied by policy' })
})
it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const order: string[] = []
ctx.on('tools/execute', async (exec, next) => {
order.push('first:before')
const result = await next()
order.push('first:after')
return result
})
ctx.on('tools/execute', async (exec, next) => {
order.push('second:before')
const result = await next()
order.push('second:after')
return result
})
const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'x' } })
expect(result.isError).toBe(false)
expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
})
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'])
})
})
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: '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: '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: 'c1',
name: 'raw-tool',
arguments: { path: '/tmp' },
})
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
})
})
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() {
// eslint-disable-next-line no-throw-literal — testing non-Error throws
throw { message: 'denied by object' }
},
})
const result = await ctx.tools.execute({ callId: 'c1', name: 'object-thrower', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
})
})