| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- 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: 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() {
- // testing non-Error throws on purpose
- 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' })
- })
- })
|