Merge commit 'refs/codex-unblock/20260723/master' into worktree/pty-review-fixes
# Conflicts: # .agents/notes/implemented/feature/2026-06-30-interception-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/tools.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/src/schema.ts # packages/core/tools/tests/tools.spec.ts # packages/pty/tool-pty/README.md # packages/pty/tool-pty/src/index.ts # packages/pty/tool-pty/src/render.ts # packages/tasks/tool-tasks/README.md # packages/tasks/tool-tasks/src/index.ts
This commit is contained in:
@@ -6,11 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -74,9 +74,13 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] {
|
||||
name,
|
||||
description: `Echo tool ${name}.`,
|
||||
parameters: { value: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(args) {
|
||||
calls.push(args)
|
||||
return Promise.resolve([{ type: 'text' as const, text: `${name}:${args.value}` }])
|
||||
return Promise.resolve(`${name}:${args.value}`)
|
||||
},
|
||||
}))
|
||||
return calls
|
||||
@@ -122,8 +126,32 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
|
||||
expect(sdk?.text).toContain('declare const tools: {')
|
||||
expect(sdk?.text).toContain('echo(args:')
|
||||
expect(sdk?.text).not.toContain('run_code(args:')
|
||||
expect(sdk?.text).toContain('echo: {')
|
||||
expect(sdk?.text).not.toContain('run_code:')
|
||||
})
|
||||
|
||||
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code' })
|
||||
let output: JsonSchemaNode = { type: 'string' }
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
output = { oneOf: [output, { type: 'null' }] }
|
||||
}
|
||||
ctx.tools.register({
|
||||
name: 'deep_output',
|
||||
description: 'Return a deeply nested output union.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: {
|
||||
schema: output,
|
||||
render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : 'null' }],
|
||||
},
|
||||
execute() { return Promise.resolve('ok') },
|
||||
})
|
||||
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
|
||||
expect(sdk).toContain('deep_output: Record<string, JsonValue>;')
|
||||
expect(sdk).toContain('deep_output: string | null')
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => {
|
||||
@@ -175,8 +203,8 @@ describe('mode-aware wire contribution', () => {
|
||||
? [RUN_CODE_NAME]
|
||||
: ['echo', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).toContain('echo(args:')
|
||||
expect(sdk).not.toContain('hidden(args:')
|
||||
expect(sdk).toContain('echo: {')
|
||||
expect(sdk).not.toContain('hidden:')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
@@ -205,8 +233,8 @@ describe('mode-aware wire contribution', () => {
|
||||
? [RUN_CODE_NAME]
|
||||
: ['kept', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).not.toContain('denied(args:')
|
||||
expect(sdk).toContain('kept(args:')
|
||||
expect(sdk).not.toContain('denied:')
|
||||
expect(sdk).toContain('kept: {')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
@@ -220,7 +248,7 @@ describe('mode-aware wire contribution', () => {
|
||||
it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
const impostor = defineTool({
|
||||
const impostor = defineContentToolFixture({
|
||||
name: RUN_CODE_NAME,
|
||||
description: 'Scoped impostor.',
|
||||
parameters: {},
|
||||
@@ -232,7 +260,7 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
|
||||
scope.ctx.tools.register(defineTool({
|
||||
scope.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'scoped_safe',
|
||||
description: 'Safe scoped tool.',
|
||||
parameters: {},
|
||||
@@ -244,7 +272,7 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(transports).toHaveLength(1)
|
||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
||||
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe:')
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||
const result = await runCode(ctx, 'return 1', { agent })
|
||||
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
|
||||
@@ -268,6 +296,10 @@ describe('mode-aware wire contribution', () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
runtime.behavior = (request) => {
|
||||
expect(request.bindings[0]!.errorClass).toEqual({
|
||||
name: 'ToolCallError',
|
||||
memberNameProperty: 'toolName',
|
||||
})
|
||||
const functions = request.bindings[0]!.functions
|
||||
return Promise.resolve({
|
||||
logs: [],
|
||||
@@ -331,10 +363,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const first = await tools.echo!({ value: 'one' })
|
||||
const second = await tools.echo!({ value: 'two' })
|
||||
return { logs: [`saw ${String(first)}`], value: second }
|
||||
if (typeof first !== 'string' || typeof second !== 'string') throw new Error('echo returned a non-string')
|
||||
return { logs: [`saw ${first}`], value: second }
|
||||
}
|
||||
const result = await runCode(ctx, 'const …: string = …', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected run_code success')
|
||||
expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' })
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }])
|
||||
expect(calls).toEqual([{ value: 'one' }, { value: 'two' }])
|
||||
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
|
||||
@@ -342,7 +377,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
|
||||
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
|
||||
])
|
||||
expect(result.meta).toEqual({ logs: ['saw echo:one'] })
|
||||
expect(result.meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes only an opaque parent token to nested result observers', async () => {
|
||||
@@ -380,6 +415,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: 'probe',
|
||||
description: 'Records execution overlap.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args) {
|
||||
active++
|
||||
expect(active, 'probe executions overlapped').toBe(1)
|
||||
@@ -387,12 +426,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
intervals.push(['exit', args.id])
|
||||
active--
|
||||
return [{ type: 'text' as const, text: args.id }]
|
||||
return args.id
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const values = await Promise.all([tools.probe!({ id: 'a' }), tools.probe!({ id: 'b' }), tools.probe!({ id: 'c' })])
|
||||
if (!values.every(value => typeof value === 'string')) throw new Error('probe returned a non-string')
|
||||
return { logs: [], value: values.join(',') }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
@@ -407,7 +447,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
|
||||
it('rejects the program-side call when the tool errors, with the tool error text', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'fail',
|
||||
description: 'Always fails.',
|
||||
parameters: {},
|
||||
@@ -422,7 +462,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: Error: deliberate failure' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' })
|
||||
})
|
||||
|
||||
it('a tools/pre-execute deny reaches the program as a binding rejection', async () => {
|
||||
@@ -445,7 +485,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((result.content[0] as { text: string }).text).toContain('not on my watch')
|
||||
})
|
||||
|
||||
it('rejects a binding argument that does not survive JSON normalization, dispatching nothing', async () => {
|
||||
it('rejects a binding argument that is not lossless JSON, dispatching nothing', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
@@ -458,25 +498,24 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect((result.content[0] as { text: string }).text).toContain('JSON-serializable')
|
||||
expect((result.content[0] as { text: string }).text).toContain('lossless JSON')
|
||||
expect(calls).toEqual([])
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches the JSON-normalized value: what the tool sees is what the event logs', async () => {
|
||||
it('dispatches and logs independent snapshots of the same lossless JSON value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
runtime.behavior = async (request) => {
|
||||
// A Date survives structured clone but is not JSON; the bridge
|
||||
// normalizes it to its JSON form (an ISO string) BEFORE dispatch.
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x', when: new Date(0) }).catch(() => undefined)
|
||||
const args = Object.assign(Object.create(null) as Record<string, unknown>, { value: 'x', nested: ['same'] })
|
||||
await request.bindings[0]!.functions.echo!(args)
|
||||
return { logs: [] }
|
||||
}
|
||||
await runCode(ctx, 'program', { agent })
|
||||
expect(calls).toEqual([{ value: 'x', when: '1970-01-01T00:00:00.000Z' }])
|
||||
expect(calls).toEqual([{ value: 'x', nested: ['same'] }])
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', nested: ['same'] })
|
||||
})
|
||||
|
||||
it('defers sub-call additionalContexts onto the outer run_code result', async () => {
|
||||
@@ -551,7 +590,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
})
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' })
|
||||
expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } })
|
||||
const text = (result.content[0] as { text: string }).text
|
||||
expect(text).toContain('code run failed (timeout)')
|
||||
expect(text).toContain('compute budget exhausted')
|
||||
@@ -568,7 +607,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const seen: string[] = []
|
||||
let sawAbort = false
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow',
|
||||
description: 'Slow tool observing its signal.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -604,7 +643,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
let sawAbort = false
|
||||
let started!: () => void
|
||||
const inFlight = new Promise<void>((resolve) => { started = resolve })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow',
|
||||
description: 'Slow tool observing its signal.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -654,7 +693,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((result.content[0] as { text: string }).text).toContain('requires a code runtime')
|
||||
})
|
||||
|
||||
it('presents the PROGRAM as the execute-card title on both call and result (the one slot execute cards always show)', async () => {
|
||||
it('presents the program as the execute-card title', async () => {
|
||||
const { ctx } = await setup({ mode: 'code' })
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
// The program IS the title, mirroring how command tools title their cards
|
||||
@@ -667,24 +706,59 @@ describe('the run_code dispatch bridge', () => {
|
||||
kind: 'execute',
|
||||
rawInput: 'return 1',
|
||||
})
|
||||
const view = tool.presentResult?.({ code: 'return 1' }, {
|
||||
content: [{ type: 'text', text: 'model-facing' }],
|
||||
isError: false,
|
||||
meta: { logs: ['printed'] },
|
||||
})
|
||||
|
||||
it.each([
|
||||
['logs only', { logs: ['printed'] }, 'printed'],
|
||||
['result only', { logs: [], value: 'returned' }, 'returned'],
|
||||
['logs plus result', { logs: ['printed'], value: 'returned' }, 'printed\nreturned'],
|
||||
['no output', { logs: [] }, '(run_code completed with no output)'],
|
||||
] as [string, CodeRunResult, string][])('keeps %s in durable content without a result presenter', async (_name, output, text) => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve(output)
|
||||
|
||||
const result = await runCode(ctx, 'return 1')
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text }])
|
||||
// Surfaces keep the pending program title and render this durable content
|
||||
// through their generic fallback. Omitting a result view also prevents the
|
||||
// host frame from carrying the same raw content a second time.
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a post-policy spill preview in durable content without a result presenter', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const preview = 'HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: /tmp/run-code.txt.)\n\nTAIL'
|
||||
runtime.behavior = () => Promise.resolve({ logs: ['printed'], value: 'returned' })
|
||||
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name !== RUN_CODE_NAME) return next()
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: preview }] })
|
||||
})
|
||||
// The result omits the title — an update replaces only provided fields,
|
||||
// so the pending card's program title persists through completion.
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: 'printed' }],
|
||||
|
||||
const result = await runCode(ctx, 'return 1')
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: preview }])
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps canonical failure content durable without a result presenter', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve({
|
||||
logs: ['captured before failure'],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 8 bytes' },
|
||||
})
|
||||
// No captured output → no content either; everything pending persists.
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
|
||||
.toEqual({ card: 'generic' })
|
||||
// Replay with an unrecognizable meta falls back to the generic rendering.
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
|
||||
|
||||
const result = await runCode(ctx, 'return 1')
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: code run failed (output-limit): outer output exceeded 8 bytes\nCaptured output:\ncaptured before failure',
|
||||
}])
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => {
|
||||
@@ -695,11 +769,15 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: 'mixed',
|
||||
description: 'Returns mixed content.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => [
|
||||
{ type: 'text', text: long },
|
||||
{ type: 'reasoning', text: 'hidden' },
|
||||
],
|
||||
},
|
||||
execute() {
|
||||
return Promise.resolve([
|
||||
{ type: 'text' as const, text: long },
|
||||
{ type: 'reasoning' as const, text: 'hidden' },
|
||||
])
|
||||
return Promise.resolve('mixed-value')
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
@@ -708,7 +786,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect((result.content[0] as { text: string }).text).toBe(`${long}\n[reasoning content]`)
|
||||
expect((result.content[0] as { text: string }).text).toBe('mixed-value')
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.resultSummary.length).toBe(201)
|
||||
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
|
||||
@@ -720,9 +798,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: 'workspace_path',
|
||||
description: 'Return a path beneath the session workspace.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(_args, exec) {
|
||||
const cwd = exec.agent?.session.header.cwd ?? ''
|
||||
return Promise.resolve([{ type: 'text' as const, text: `<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}` }])
|
||||
return Promise.resolve(`<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}`)
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async request => ({
|
||||
@@ -760,7 +842,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
})
|
||||
|
||||
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
|
||||
it('rejects undefined, getter-throwing, exotic, and unrepresentable binding arguments before dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
@@ -773,8 +855,9 @@ describe('the run_code dispatch bridge', () => {
|
||||
// Root undefined must reject up front: the event log rejects it as
|
||||
// data, and nothing may execute unlogged.
|
||||
await catchMessage(echo(undefined)),
|
||||
// A toJSON that throws a NON-Error propagates out of JSON.stringify.
|
||||
await catchMessage(echo({ toJSON() { throw 'raw-throw' } })),
|
||||
await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw 'raw-throw' } }))),
|
||||
await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('error-throw') } }))),
|
||||
await catchMessage(echo(new Date(0))),
|
||||
// A bare function is a value JSON cannot represent at all.
|
||||
await catchMessage(echo(() => 1)),
|
||||
].join(' | '),
|
||||
@@ -783,18 +866,70 @@ describe('the run_code dispatch bridge', () => {
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
const text = (result.content[0] as { text: string }).text
|
||||
expect(text).toContain('call the tool with an arguments object')
|
||||
expect(text).toContain('JSON-serializable: raw-throw')
|
||||
expect(text).toContain('a value JSON cannot represent')
|
||||
// None of the three dispatched, none logged.
|
||||
expect(text).toContain('lossless JSON: raw-throw')
|
||||
expect(text).toContain('lossless JSON: error-throw')
|
||||
expect(text.match(/tool arguments must be lossless JSON/g)).toHaveLength(5)
|
||||
// None dispatched or logged.
|
||||
expect(calls).toEqual([])
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches and durably logs binding arguments deeper than the structured-clone call stack', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const depth = 5_000
|
||||
let observedDepth = 0
|
||||
let observedLeaf: JsonValue | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'deep_args',
|
||||
description: 'Measure a deeply nested JSON argument.',
|
||||
parameters: { nested: { type: 'json', required: true } },
|
||||
output: {
|
||||
schema: { type: 'integer' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
execute(args) {
|
||||
let cursor = args.nested
|
||||
while (Array.isArray(cursor)) {
|
||||
if (cursor.length !== 1) throw new Error('expected one item per nesting layer')
|
||||
observedDepth++
|
||||
cursor = cursor[0]!
|
||||
}
|
||||
observedLeaf = cursor
|
||||
return Promise.resolve(observedDepth)
|
||||
},
|
||||
}))
|
||||
const session = new Session(SessionId('deep-code-arguments'))
|
||||
const agent = { session } as Agent
|
||||
runtime.behavior = async (request) => {
|
||||
let nested: JsonValue = 'leaf'
|
||||
for (let index = 0; index < depth; index++) nested = [nested]
|
||||
const value = await request.bindings[0]!.functions.deep_args!({ nested })
|
||||
return { logs: [], value }
|
||||
}
|
||||
|
||||
const result = await runCode(ctx, 'return tools.deep_args(...)', { agent })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth })
|
||||
expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' })
|
||||
const dispatch = session.events.find(event => event.type === 'tool/code-dispatch')
|
||||
if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event')
|
||||
const logged = dispatch.data.arguments as { nested: JsonValue }
|
||||
let loggedDepth = 0
|
||||
let loggedCursor = logged.nested
|
||||
while (Array.isArray(loggedCursor)) {
|
||||
if (loggedCursor.length !== 1) throw new Error('expected one logged item per nesting layer')
|
||||
loggedDepth++
|
||||
loggedCursor = loggedCursor[0]!
|
||||
}
|
||||
expect({ loggedDepth, loggedCursor }).toEqual({ loggedDepth: depth, loggedCursor: 'leaf' })
|
||||
})
|
||||
|
||||
it('gives the tool and durable log the same immutable argument value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const { agent, events } = fakeAgent()
|
||||
let mutationSucceeded: boolean | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'mutator',
|
||||
description: 'Attempts to mutate its args object.',
|
||||
parameters: { list: { type: 'array', required: true } },
|
||||
@@ -820,7 +955,11 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: '__proto__',
|
||||
description: 'A prototype-colliding tool name.',
|
||||
parameters: {},
|
||||
execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute() { return Promise.resolve('proto-tool-ok') },
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
const functions = request.bindings[0]!.functions
|
||||
@@ -833,11 +972,48 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' })
|
||||
})
|
||||
|
||||
it('renders a non-string completion value inspect-style', async () => {
|
||||
it('renders every non-string JSON root as pretty JSON while preserving strings raw', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } })
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42, ok: true } })
|
||||
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42,\n "ok": true\n}' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: {} })
|
||||
expect((await runCode(ctx, 'empty object')).content[0]).toEqual({ type: 'text', text: '{}' })
|
||||
const nested = { outer: [{ inner: true }] }
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: nested })
|
||||
expect((await runCode(ctx, 'nested')).content[0]).toEqual({ type: 'text', text: JSON.stringify(nested, null, 2) })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: ['x', 2] })
|
||||
expect((await runCode(ctx, 'array')).content[0]).toEqual({ type: 'text', text: '[\n "x",\n 2\n]' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: [] })
|
||||
expect((await runCode(ctx, 'empty array')).content[0]).toEqual({ type: 'text', text: '[]' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: null })
|
||||
expect((await runCode(ctx, 'null')).content[0]).toEqual({ type: 'text', text: 'null' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: 'raw' })
|
||||
expect((await runCode(ctx, 'string')).content[0]).toEqual({ type: 'text', text: 'raw' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [] })
|
||||
const absent = await runCode(ctx, 'undefined')
|
||||
expect(absent.content[0]).toEqual({ type: 'text', text: '(run_code completed with no output)' })
|
||||
expect(absent.isError ? undefined : absent.value).toEqual({ logs: [] })
|
||||
})
|
||||
|
||||
it('renders deeply nested JSON without recursive traversal or quadratic indentation', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
let value: JsonValue = {
|
||||
emptyArray: [],
|
||||
emptyObject: {},
|
||||
pair: ['leaf', 2],
|
||||
record: { first: true, second: null },
|
||||
}
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value })
|
||||
|
||||
const result = await runCode(ctx, 'deep result')
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const text = (result.content[0] as { type: 'text'; text: string }).text
|
||||
expect(text.startsWith('[\n [\n [')).toBe(true)
|
||||
expect(text).toContain('"leaf"')
|
||||
expect(text.endsWith(']')).toBe(true)
|
||||
expect(text.length).toBeLessThan(11_000)
|
||||
})
|
||||
|
||||
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
|
||||
@@ -855,7 +1031,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
error: {
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
},
|
||||
})
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
expect(calls).toEqual([])
|
||||
@@ -873,7 +1052,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool,
|
||||
defineContentToolFixture,
|
||||
type ToolDefinition,
|
||||
type ToolExecutionInput,
|
||||
type ToolExecutionMode,
|
||||
@@ -27,7 +27,7 @@ function exec(name: string, args: unknown): ToolExecutionInput {
|
||||
describe('ToolRegistry.executionMode', () => {
|
||||
it('returns parallel only for an explicit true classifier', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'safe',
|
||||
description: 'parallel-safe',
|
||||
parameters: {},
|
||||
@@ -39,7 +39,7 @@ describe('ToolRegistry.executionMode', () => {
|
||||
|
||||
it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'plain',
|
||||
description: 'no declaration',
|
||||
parameters: {},
|
||||
@@ -55,7 +55,7 @@ describe('ToolRegistry.executionMode', () => {
|
||||
|
||||
it('returns exclusive when the classifier returns false for these args', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'rw',
|
||||
description: 'read or write',
|
||||
parameters: { mode: { type: 'string', required: true } },
|
||||
@@ -66,9 +66,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
|
||||
})
|
||||
|
||||
it('classifies invalid defineTool arguments as exclusive without throwing', async () => {
|
||||
it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'needs-mode',
|
||||
description: 'requires mode',
|
||||
parameters: { mode: { type: 'string', required: true } },
|
||||
@@ -84,8 +84,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
name: 'thrower',
|
||||
description: 'classifier throws',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
isConcurrencySafe() { throw new Error('boom') },
|
||||
async execute() { return [] },
|
||||
async execute() { return null },
|
||||
}
|
||||
ctx.tools.register(raw)
|
||||
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
|
||||
@@ -97,8 +98,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
name: 'truthy',
|
||||
description: 'classifier returns a truthy string',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
isConcurrencySafe() { return 'yes' },
|
||||
async execute() { return [] },
|
||||
async execute() { return null },
|
||||
} as unknown as ToolDefinition
|
||||
ctx.tools.register(raw)
|
||||
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
|
||||
@@ -111,8 +113,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
name: 'raw-safe',
|
||||
description: 'raw',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
isConcurrencySafe(args) { seen = args; return true },
|
||||
async execute() { return [] },
|
||||
async execute() { return null },
|
||||
})
|
||||
expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
|
||||
expect(seen).toEqual({ anything: 1 })
|
||||
@@ -120,7 +123,7 @@ describe('ToolRegistry.executionMode', () => {
|
||||
|
||||
it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'safe',
|
||||
description: 'parallel-safe',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
|
||||
@@ -80,11 +80,15 @@ const inferredTool = defineTool({
|
||||
name: 'signal-inference',
|
||||
description: 'Pins contextual signal inference.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'null' },
|
||||
render: () => [],
|
||||
},
|
||||
async execute(_args, exec) {
|
||||
expectTypeOf(exec.signal).toEqualTypeOf<AbortSignal>()
|
||||
// @ts-expect-error -- defineTool contextually exposes a readonly signal.
|
||||
exec.signal = new AbortController().signal
|
||||
return []
|
||||
return null
|
||||
},
|
||||
})
|
||||
void inferredTool
|
||||
|
||||
@@ -27,6 +27,7 @@ const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
|
||||
const outcome = (): ToolExecutionResult => Object.freeze({
|
||||
content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
|
||||
isError: false,
|
||||
value: null,
|
||||
})
|
||||
|
||||
function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
|
||||
@@ -78,7 +79,7 @@ describe('tool-pipeline invariants', () => {
|
||||
expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
|
||||
|
||||
const exec = Object.freeze(execution())
|
||||
expect(() => { emitResult(ctx, exec, { content: [], isError: false }) })
|
||||
expect(() => { emitResult(ctx, exec, { content: [], isError: false, value: null }) })
|
||||
.toThrow(/outcome and content must be frozen/)
|
||||
|
||||
const anonymous = Object.freeze(execution({ name: '' }))
|
||||
|
||||
@@ -1,304 +1,455 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertSupportedOutputSchema,
|
||||
OutputSchemaError,
|
||||
validateStructuredValue,
|
||||
type StructuredOutputSchema,
|
||||
} from '../src/json-schema.ts'
|
||||
assertObjectJsonSchema,
|
||||
assertSupportedJsonSchema,
|
||||
JsonSchemaError,
|
||||
validateJsonSchemaValue,
|
||||
type JsonSchemaNode,
|
||||
type ObjectJsonSchema,
|
||||
} from '../src/index.ts'
|
||||
|
||||
/** Assert-and-narrow helper: the asserted schema, typed. */
|
||||
function asserted(schema: unknown): StructuredOutputSchema {
|
||||
assertSupportedOutputSchema(schema)
|
||||
function asserted(schema: unknown): JsonSchemaNode {
|
||||
assertSupportedJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
|
||||
function violationsOf(schema: unknown): string[] {
|
||||
try {
|
||||
assertSupportedOutputSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutputSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the schema to be rejected')
|
||||
function assertedObject(schema: unknown): ObjectJsonSchema {
|
||||
assertObjectJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
describe('assertSupportedOutputSchema', () => {
|
||||
it('accepts a representative subset schema (all supported keywords)', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
description: 'a finding',
|
||||
title: 'Finding',
|
||||
properties: {
|
||||
file: { type: 'string', description: 'path' },
|
||||
line: { type: 'integer' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
|
||||
additionalProperties: false,
|
||||
function violationsOf(schema: unknown, objectRoot = false): string[] {
|
||||
try {
|
||||
if (objectRoot) assertObjectJsonSchema(schema)
|
||||
else assertSupportedJsonSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof JsonSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected schema rejection')
|
||||
}
|
||||
|
||||
function recordWithForgedIntrinsicPrototype(
|
||||
own: Record<string, unknown>,
|
||||
inherited: Record<string, unknown> = {},
|
||||
revoked = false,
|
||||
): Record<string, unknown> {
|
||||
const prototype = Object.assign(Object.create(null) as Record<string, unknown>, inherited)
|
||||
const ForgedObject = function ForgedObject(): void {}
|
||||
Object.defineProperty(ForgedObject, 'name', { value: 'Object' })
|
||||
ForgedObject.prototype = prototype
|
||||
const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined
|
||||
if (constructor !== undefined) constructor.revoke()
|
||||
Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject })
|
||||
return Object.assign(Object.create(prototype) as Record<string, unknown>, own)
|
||||
}
|
||||
|
||||
describe('the enforced raw JSON Schema subset', () => {
|
||||
it('accepts every JSON root and every supported node', () => {
|
||||
for (const schema of [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
{ type: 'integer' },
|
||||
{ type: 'boolean' },
|
||||
{ type: 'null' },
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
nested: { type: 'object', properties: {}, additionalProperties: false },
|
||||
free: {},
|
||||
},
|
||||
anything: { type: 'array' },
|
||||
required: ['nested'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: ['file', 'line'],
|
||||
additionalProperties: true,
|
||||
})
|
||||
expect(schema.type).toBe('object')
|
||||
{ oneOf: [{ type: 'string' }, { type: 'number' }] },
|
||||
{ description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] },
|
||||
]) {
|
||||
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
|
||||
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
|
||||
.toContain('schema.type must be "object" (structured output is object-rooted)')
|
||||
it('retains an object-root guard only at consumers that need it', () => {
|
||||
expect(assertedObject({ type: 'object' }).type).toBe('object')
|
||||
for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) {
|
||||
expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-object schema nodes and missing/unknown type', () => {
|
||||
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
|
||||
it('rejects non-schema nodes, unknown types, and type arrays', () => {
|
||||
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf([])).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
|
||||
expect(violationsOf('no')).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects type ARRAYS with a dedicated message', () => {
|
||||
expect(violationsOf({ type: ['string', 'null'] }))
|
||||
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
|
||||
})
|
||||
|
||||
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
|
||||
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
const bad = violationsOf({ type: 'object', [keyword]: [] })
|
||||
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
|
||||
}
|
||||
it('enforces oneOf vocabulary and its minimum branch count', () => {
|
||||
expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ type: 'string', oneOf: [{}, {}] }))
|
||||
.toEqual(['schema cannot declare both type and oneOf'])
|
||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} }))
|
||||
.toEqual(['schema.items is not supported beside oneOf'])
|
||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
|
||||
.toContain('schema.oneOf[1].type')
|
||||
const sparse = new Array<unknown>(2)
|
||||
sparse[0] = { type: 'string' }
|
||||
expect(violationsOf({ oneOf: sparse }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
const compensatedSparse = new Array<unknown>(2)
|
||||
compensatedSparse[0] = { type: 'string' }
|
||||
Object.defineProperty(compensatedSparse, 'extra', { value: true })
|
||||
expect(violationsOf({ oneOf: compensatedSparse }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
class ExoticBranches extends Array<unknown> {}
|
||||
expect(violationsOf({ oneOf: new ExoticBranches({ type: 'string' }, { type: 'null' }) }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
const explosiveArray = new Proxy([{ type: 'string' }, { type: 'null' }], {
|
||||
getPrototypeOf() { throw new Error('prototype trap') },
|
||||
})
|
||||
expect(violationsOf({ oneOf: explosiveArray }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
})
|
||||
|
||||
it('reports EVERY violation, not just the first', () => {
|
||||
const bad = violationsOf({
|
||||
it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => {
|
||||
for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`)
|
||||
}
|
||||
expect(violationsOf({ type: 'object', items: {} }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'array', properties: {} }))
|
||||
.toEqual(['schema.properties is not supported on type "array"'])
|
||||
expect(violationsOf({ type: 'object', enum: ['x'] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'array', const: null }))
|
||||
.toEqual(['schema.const is not supported on type "array"'])
|
||||
expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null }))
|
||||
.toEqual([
|
||||
'schema.properties requires type or oneOf',
|
||||
'schema.required requires type or oneOf',
|
||||
'schema.additionalProperties requires type or oneOf',
|
||||
'schema.items requires type or oneOf',
|
||||
'schema.enum requires type or oneOf',
|
||||
'schema.const requires type or oneOf',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports every independent schema violation', () => {
|
||||
expect(violationsOf({
|
||||
type: 'object',
|
||||
pattern: 'x',
|
||||
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
|
||||
})
|
||||
expect(bad.length).toBe(3)
|
||||
})).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
|
||||
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
|
||||
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
|
||||
expect(violationsOf({ type: 'object', enum: [1] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
|
||||
.toEqual(['schema.properties.a.const is not supported on type "array"'])
|
||||
})
|
||||
|
||||
it('validates required: must be string[] naming declared properties', () => {
|
||||
expect(violationsOf({ type: 'object', required: 'file' }))
|
||||
it('validates object properties, required names, and openness', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'x' } }))
|
||||
.toEqual(['schema.properties.a must be a schema object'])
|
||||
expect(violationsOf({ type: 'object', required: 'a' }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', required: [1] }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
|
||||
.toEqual(['schema.required names "b" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', required: ['a'] }))
|
||||
.toEqual(['schema.required names "a" which is not in properties'])
|
||||
})
|
||||
|
||||
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
|
||||
expect(violationsOf({ type: 'object', additionalProperties: {} }))
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] }))
|
||||
.toEqual(['schema.required names "missing" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', additionalProperties: 'yes' }))
|
||||
.toEqual(['schema.additionalProperties must be a boolean'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
|
||||
.toEqual(['schema.properties.a.const must be a scalar'])
|
||||
expect(violationsOf({ type: 'object', properties: undefined }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] }))
|
||||
.toEqual([
|
||||
'schema.properties must be an object of schemas',
|
||||
'schema.required names "missing" which is not in properties',
|
||||
])
|
||||
const sparseRequired = new Array<string>(1)
|
||||
expect(violationsOf({ type: 'object', required: sparseRequired }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
})
|
||||
|
||||
it('rejects non-string description/title and non-JSON annotation payloads', () => {
|
||||
expect(violationsOf({ type: 'object', description: 7 }))
|
||||
.toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ type: 'object', title: 7 }))
|
||||
.toEqual(['schema.title must be a string'])
|
||||
expect(violationsOf({ type: 'object', default: () => 1 }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [undefined] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
// A cyclic annotation payload is caught by the JSON-data walk.
|
||||
const cyclicAnnotation: Record<string, unknown> = {}
|
||||
cyclicAnnotation.self = cyclicAnnotation
|
||||
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
// Object/array annotations that ARE JSON data pass.
|
||||
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
|
||||
it('requires type-correct scalar enum and const values', () => {
|
||||
for (const schema of [
|
||||
{ type: 'string', enum: ['a'], const: 'a' },
|
||||
{ type: 'number', enum: [1.5], const: 1.5 },
|
||||
{ type: 'integer', enum: [1], const: 1 },
|
||||
{ type: 'boolean', enum: [true], const: true },
|
||||
{ type: 'null', enum: [null], const: null },
|
||||
]) {
|
||||
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
|
||||
expect(violationsOf({ type: 'string', enum: [] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
expect(violationsOf({ type: 'number', enum: ['1'] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||
expect(violationsOf({ type: 'integer', enum: [1.5] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of integer values'])
|
||||
expect(violationsOf({ type: 'number', enum: [Number.NaN] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||
expect(violationsOf({ type: 'number', const: -0 }))
|
||||
.toEqual(['schema.const must be a number value'])
|
||||
expect(violationsOf({ type: 'boolean', const: 1 }))
|
||||
.toEqual(['schema.const must be a boolean value'])
|
||||
expect(violationsOf({ type: 'string', enum: undefined }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' }))
|
||||
.toEqual(['schema.const must be one of schema.enum when both are declared'])
|
||||
const sparseEnum = new Array<string>(1)
|
||||
expect(violationsOf({ type: 'string', enum: sparseEnum }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
})
|
||||
|
||||
it('rejects a circular schema instead of recursing forever', () => {
|
||||
const node: Record<string, unknown> = { type: 'object' }
|
||||
node.properties = { self: node }
|
||||
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
|
||||
it('validates annotation types and lossless JSON payloads', () => {
|
||||
expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string'])
|
||||
for (const [key, value] of [
|
||||
['default', undefined],
|
||||
['examples', [undefined]],
|
||||
['default', Number.POSITIVE_INFINITY],
|
||||
['examples', new Date(0)],
|
||||
] as const) {
|
||||
expect(violationsOf({ [key]: value })).toEqual([`schema.${key} annotation must be lossless JSON data`])
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(violationsOf({ default: cyclic }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
|
||||
const explosive = new Proxy({}, {
|
||||
ownKeys() { throw new Error('annotation trap') },
|
||||
})
|
||||
expect(violationsOf({ examples: explosive }))
|
||||
.toEqual(['schema.examples annotation must be lossless JSON data'])
|
||||
expect(violationsOf({ default: Object.defineProperty({}, 'hidden', { value: true }) }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
expect(violationsOf({ default: { [Symbol('hidden')]: true } }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
})
|
||||
|
||||
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
|
||||
it('accepts lossless annotation containers from another JavaScript realm', () => {
|
||||
const schema = runInNewContext(`({
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string', enum: ['x'] } },
|
||||
required: ['value'],
|
||||
default: { x: 1 },
|
||||
examples: [[{ ok: true }]],
|
||||
})`) as unknown
|
||||
|
||||
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
|
||||
const cyclic: Record<string, unknown> = { type: 'object' }
|
||||
cyclic.properties = { self: cyclic }
|
||||
expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular'])
|
||||
const leaf = { type: 'string' }
|
||||
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
|
||||
expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow()
|
||||
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
|
||||
const forgedSchema = recordWithForgedIntrinsicPrototype(
|
||||
{ type: 'object' },
|
||||
{ oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
)
|
||||
expect(violationsOf(forgedSchema)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(forgedSchema, true)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(recordWithForgedIntrinsicPrototype({ type: 'string' }, {}, true)))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
const prototypeWithoutConstructor = Object.create(null) as object
|
||||
expect(violationsOf(Object.create(prototypeWithoutConstructor) as unknown))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(Object.defineProperty({ type: 'string' }, 'hidden', { value: true })))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({ type: 'string', [Symbol('hidden')]: true }))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(new Proxy({}, {
|
||||
getPrototypeOf() { throw new Error('prototype trap') },
|
||||
}))).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(new Proxy({}, {
|
||||
ownKeys() { throw new Error('keys trap') },
|
||||
}))).toEqual(['schema must be a schema object'])
|
||||
})
|
||||
|
||||
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
|
||||
// `'toString' in {}` is true via Object.prototype; the declared-property
|
||||
// contract must be an own-property check.
|
||||
it('asserts deeply nested raw unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
|
||||
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses own-property semantics for required declarations', () => {
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
|
||||
.toEqual(['schema.required names "toString" which is not in properties'])
|
||||
})
|
||||
|
||||
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
|
||||
// A Map as `properties` has no own enumerable entries: structurally it
|
||||
// would read as "no properties" and serialize to {} — lossy, not loud.
|
||||
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
// A Date node is not a schema object even though Object.values(date) is [].
|
||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects exotic annotation payloads that would serialize lossily', () => {
|
||||
expect(violationsOf({ type: 'object', default: new Date(0) }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [new Map()] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateStructuredValue', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
line: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
free: { type: 'array' },
|
||||
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
|
||||
},
|
||||
required: ['file'],
|
||||
describe('validateJsonSchemaValue', () => {
|
||||
it('validates scalar, array, object, and null roots', () => {
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a fully valid value (empty violations)', () => {
|
||||
expect(validateStructuredValue(schema, {
|
||||
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
|
||||
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
|
||||
})).toEqual([])
|
||||
it('rejects wrong scalar types and lossy numbers', () => {
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null'])
|
||||
})
|
||||
|
||||
it('reports missing required and wrong root type', () => {
|
||||
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
|
||||
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
|
||||
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
|
||||
it('enforces scalar enum and const together', () => {
|
||||
const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' })
|
||||
expect(validateJsonSchemaValue(schema, 'a')).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]'])
|
||||
expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"'])
|
||||
})
|
||||
|
||||
it('type-checks every scalar branch with path-qualified messages', () => {
|
||||
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
|
||||
it('validates object requiredness, nested values, and raw open defaults', () => {
|
||||
const open = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { line: { type: 'integer' } },
|
||||
required: ['line'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['file'],
|
||||
})
|
||||
expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([])
|
||||
expect(validateJsonSchemaValue(open, { nested: { line: 1 } }))
|
||||
.toEqual(['missing required property "value.file"'])
|
||||
expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([
|
||||
'"value.file" must be a string',
|
||||
'missing required property "value.nested.line"',
|
||||
])
|
||||
expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } }))
|
||||
.toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('enforces enum membership and const equality', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
|
||||
.toEqual(['"value.severity" must be one of ["low","high"]'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
|
||||
.toEqual(['"value.kind" must be "bug"'])
|
||||
it('treats present undefined as missing when required, then rejects other lossy objects', () => {
|
||||
const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] })
|
||||
expect(validateJsonSchemaValue(required, { x: undefined }))
|
||||
.toEqual(['missing required property "value.x"'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined }))
|
||||
.toEqual(['"value" must be a lossless JSON object'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('checks arrays per index; an items-less array accepts anything', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
|
||||
it('returns a violation instead of throwing for a container with a hostile getter', () => {
|
||||
const value = Object.defineProperty({}, 'answer', {
|
||||
enumerable: true,
|
||||
get() { throw new Error('getter exploded') },
|
||||
})
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'integer' } },
|
||||
required: ['answer'],
|
||||
})
|
||||
|
||||
expect(validateJsonSchemaValue(schema, value))
|
||||
.toEqual(['"value" must be a lossless JSON value'])
|
||||
})
|
||||
|
||||
it('recurses into nested objects: required + additionalProperties: false', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
|
||||
.toEqual(['missing required property "value.nested.x"'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
|
||||
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
|
||||
.toEqual(['"value.nested" must be an object'])
|
||||
it('validates dense arrays per index and rejects lossy arrays', () => {
|
||||
const schema = asserted({ type: 'array', items: { type: 'integer' } })
|
||||
expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, runInNewContext('[1, 2]'))).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, [1, 1.5])).toEqual(['"value[1]" must be an integer'])
|
||||
expect(validateJsonSchemaValue(schema, 'x')).toEqual(['"value" must be an array'])
|
||||
const sparse: number[] = []
|
||||
sparse.length = 2
|
||||
sparse[0] = 1
|
||||
expect(validateJsonSchemaValue(schema, sparse)).toEqual(['"value" must be a dense lossless JSON array'])
|
||||
})
|
||||
|
||||
it('a required key present-but-undefined counts as missing', () => {
|
||||
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
|
||||
it('validates exact-one oneOf semantics, including overlap', () => {
|
||||
const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] })
|
||||
expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([])
|
||||
expect(validateJsonSchemaValue(disjoint, null))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||
const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] })
|
||||
expect(validateJsonSchemaValue(overlap, 1))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 2)'])
|
||||
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
|
||||
})
|
||||
|
||||
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
|
||||
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
|
||||
expect(validateStructuredValue(
|
||||
it('validates deeply nested exact-one unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
assertSupportedJsonSchema(schema)
|
||||
|
||||
expect(validateJsonSchemaValue(schema, 'leaf')).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, 42))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||
})
|
||||
|
||||
it('an unconstrained schema accepts only lossless JSON values', () => {
|
||||
const anyJson = asserted({})
|
||||
for (const value of [null, true, 1, 'x', [1], { x: null }]) {
|
||||
expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([])
|
||||
}
|
||||
for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) {
|
||||
expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value'])
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(validateJsonSchemaValue(anyJson, cyclic)).toEqual(['"value" must be a lossless JSON value'])
|
||||
const explosive = new Proxy({}, {
|
||||
ownKeys() { throw new Error('value trap') },
|
||||
})
|
||||
expect(validateJsonSchemaValue(anyJson, explosive)).toEqual(['"value" must be a lossless JSON value'])
|
||||
})
|
||||
|
||||
it('uses own properties for requiredness, recursion, and closed-object checks', () => {
|
||||
expect(validateJsonSchemaValue(
|
||||
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
|
||||
{},
|
||||
)).toEqual(['missing required property "value.toString"'])
|
||||
// additionalProperties: false must flag an OWN `toString` key even though
|
||||
// `'toString' in properties` is true via the prototype.
|
||||
expect(validateStructuredValue(
|
||||
asserted({ type: 'object', additionalProperties: false }),
|
||||
{ toString: 1 },
|
||||
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
// A declared property the value does NOT carry must not be validated
|
||||
// against the value's INHERITED member (constructor is a function on
|
||||
// every plain object's prototype, not a carried property).
|
||||
expect(validateStructuredValue(
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 }))
|
||||
.toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateJsonSchemaValue(
|
||||
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
||||
{},
|
||||
)).toEqual([])
|
||||
|
||||
const inheritedUnion = Object.assign(
|
||||
Object.create({ oneOf: [{ type: 'string' }, { type: 'null' }] }) as JsonSchemaNode,
|
||||
{ type: 'object' as const },
|
||||
)
|
||||
expect(validateJsonSchemaValue(inheritedUnion, {})).toEqual([])
|
||||
expect(validateJsonSchemaValue(inheritedUnion, 'x')).toEqual(['"value" must be an object'])
|
||||
expect(validateJsonSchemaValue(
|
||||
{ type: 'object', properties: undefined } as unknown as JsonSchemaNode,
|
||||
{},
|
||||
)).toEqual([])
|
||||
expect(validateJsonSchemaValue(
|
||||
{ type: 'object', required: undefined } as unknown as JsonSchemaNode,
|
||||
{},
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-plain object value is not an object in the JSON sense', () => {
|
||||
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('collects multiple violations across branches in one pass', () => {
|
||||
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
|
||||
'missing required property "value.file"',
|
||||
'"value.line" must be an integer',
|
||||
'"value.severity" must be one of ["low","high"]',
|
||||
])
|
||||
})
|
||||
|
||||
it('null-typed const/enum work through the scalar path', () => {
|
||||
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
|
||||
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-object properties value in the schema walk', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
})
|
||||
|
||||
it('an object schema without properties/required only type-checks its value', () => {
|
||||
const bare = asserted({ type: 'object' })
|
||||
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
|
||||
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
|
||||
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
|
||||
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
|
||||
it('keeps assertNever as a forged-schema backstop', () => {
|
||||
const forged = { type: 'tuple' } as unknown as JsonSchemaNode
|
||||
expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,92 @@
|
||||
/**
|
||||
* Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
|
||||
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
|
||||
* the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must
|
||||
* pass validateArgs, and targeted corruptions must be rejected. This closes the
|
||||
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
||||
import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
||||
import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Remove parameter-only requiredness before nesting a schema as an array item. */
|
||||
function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec {
|
||||
const { required: _required, ...schema } = prop
|
||||
return schema
|
||||
}
|
||||
|
||||
// A leaf prop arbitrary (no nesting) with optional required/enum.
|
||||
function leafPropArb(): fc.Arbitrary<SchemaProp> {
|
||||
function leafPropArb(): fc.Arbitrary<ParameterPropertySpec> {
|
||||
return fc.oneof(
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })),
|
||||
fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() })
|
||||
.map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
|
||||
.map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
|
||||
fc.record({ value: fc.string(), required: fc.boolean() })
|
||||
.map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() })
|
||||
.map(({ required }): ParameterPropertySpec => ({
|
||||
oneOf: [{ type: 'string' }, { type: 'null' }],
|
||||
...required ? { required: true } : {},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */
|
||||
function propArb(depth: number): fc.Arbitrary<SchemaProp> {
|
||||
function propArb(depth: number): fc.Arbitrary<ParameterPropertySpec> {
|
||||
if (depth <= 0) return leafPropArb()
|
||||
return fc.oneof(
|
||||
{ weight: 3, arbitrary: leafPropArb() },
|
||||
{
|
||||
weight: 1,
|
||||
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() })
|
||||
.map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })),
|
||||
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() })
|
||||
.map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({
|
||||
type: 'object',
|
||||
additionalProperties,
|
||||
properties,
|
||||
...required ? { required: true } : {},
|
||||
})),
|
||||
},
|
||||
{
|
||||
weight: 1,
|
||||
arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
|
||||
.map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })),
|
||||
.map(({ items, required }): ParameterPropertySpec => ({
|
||||
type: 'array',
|
||||
items: asValueSchema(items),
|
||||
...required ? { required: true } : {},
|
||||
})),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function specArb(depth: number): fc.Arbitrary<SchemaSpec> {
|
||||
function specArb(depth: number): fc.Arbitrary<ParameterSchemaSpec> {
|
||||
return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 })
|
||||
}
|
||||
|
||||
/** Generate a value that satisfies a prop (used to build valid args). */
|
||||
function valueForProp(prop: SchemaProp): fc.Arbitrary<unknown> {
|
||||
function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary<unknown> {
|
||||
if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp))
|
||||
if ('const' in prop) return fc.constant(prop.const)
|
||||
switch (prop.type) {
|
||||
case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
|
||||
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true })
|
||||
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0))
|
||||
case 'integer': return fc.integer()
|
||||
case 'boolean': return fc.boolean()
|
||||
case 'null': return fc.constant(null)
|
||||
case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
|
||||
case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
|
||||
case 'json': return fc.jsonValue().filter(value => isJsonValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate args satisfying every required key of a spec (optionals included randomly). */
|
||||
function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown>> {
|
||||
function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary<Record<string, unknown>> {
|
||||
const entries = Object.entries(spec)
|
||||
return fc.tuple(...entries.map(([key, prop]) =>
|
||||
fc.tuple(
|
||||
@@ -76,29 +107,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown
|
||||
}
|
||||
|
||||
/** Collect the `required: true` keys at the top level of a spec. */
|
||||
function requiredKeys(spec: SchemaSpec): string[] {
|
||||
function requiredKeys(spec: ParameterSchemaSpec): string[] {
|
||||
return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
|
||||
}
|
||||
|
||||
describe('schema DSL properties', () => {
|
||||
it('JSON Schema `required` equals the required:true keys at every level', () => {
|
||||
fc.assert(fc.property(specArb(2), (spec) => {
|
||||
const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
|
||||
const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
|
||||
expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
|
||||
for (const [key, prop] of Object.entries(s)) {
|
||||
const propJson = json.properties[key] as Record<string, unknown>
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
if ('type' in prop && prop.type === 'object' && prop.properties) {
|
||||
checkLevel(prop.properties, propJson as { required?: string[]; properties: Record<string, unknown> })
|
||||
}
|
||||
}
|
||||
}
|
||||
checkLevel(spec, schemaSpecToJsonSchema(spec))
|
||||
checkLevel(spec, parameterSchemaSpecToJsonSchema(spec))
|
||||
}))
|
||||
})
|
||||
|
||||
it('conversion is total (never throws) for any spec', () => {
|
||||
fc.assert(fc.property(specArb(3), (spec) => {
|
||||
expect(() => schemaSpecToJsonSchema(spec)).not.toThrow()
|
||||
expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow()
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
205
packages/core/tools/tests/schema.spec.ts
Normal file
205
packages/core/tools/tests/schema.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import {
|
||||
JsonSchemaError,
|
||||
parameterSchemaSpecToJsonSchema,
|
||||
valueSchemaSpecToJsonSchema,
|
||||
type InferArgs,
|
||||
type InferValue,
|
||||
type JsonValue,
|
||||
type ParameterSchemaSpec,
|
||||
type ValueSchemaSpec,
|
||||
} from '../src/index.ts'
|
||||
|
||||
describe('the unified author schema DSL', () => {
|
||||
it('compiles every value root and the author-only json node', () => {
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' }))
|
||||
.toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } }))
|
||||
.toEqual({ type: 'array', items: {} })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} }))
|
||||
.toEqual({ type: 'object', additionalProperties: false, properties: {} })
|
||||
expect(valueSchemaSpecToJsonSchema({
|
||||
type: 'json',
|
||||
description: 'anything',
|
||||
title: 'Any JSON',
|
||||
default: null,
|
||||
examples: [{ nested: true }],
|
||||
})).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] })
|
||||
expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] }))
|
||||
.toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] })
|
||||
})
|
||||
|
||||
it('keeps the implicit parameter root open while preserving explicit object openness', () => {
|
||||
expect(parameterSchemaSpecToJsonSchema({
|
||||
closed: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: { id: { type: 'integer', required: true } },
|
||||
},
|
||||
open: { type: 'object', additionalProperties: true },
|
||||
})).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
closed: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { id: { type: 'integer' } },
|
||||
required: ['id'],
|
||||
},
|
||||
open: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['closed'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects runtime-forged author forms rather than compiling them lossily', () => {
|
||||
for (const schema of [
|
||||
{ type: 'object' },
|
||||
{ oneOf: [{ type: 'string' }] },
|
||||
{ type: 'number', enum: ['1'] },
|
||||
{ type: 'string', enum: ['a'], const: 'b' },
|
||||
{ type: 'integer', const: 1.5 },
|
||||
{ type: 'json', default: undefined },
|
||||
{ type: 'array', items: { type: 'string', required: true } },
|
||||
{ type: 'array', items: 42 },
|
||||
{ type: 'string', extra: true },
|
||||
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
{ oneOf: 'not-an-array' },
|
||||
{ type: 'string', enum: 'a' },
|
||||
{},
|
||||
null,
|
||||
]) {
|
||||
expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
|
||||
}
|
||||
expect(() => parameterSchemaSpecToJsonSchema({
|
||||
value: { type: 'string', required: false },
|
||||
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
|
||||
const symbolKey = Symbol('hidden')
|
||||
expect(() => parameterSchemaSpecToJsonSchema({
|
||||
value: { type: 'string' },
|
||||
[symbolKey]: { type: 'number' },
|
||||
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
const hiddenKey = Object.defineProperty({ value: { type: 'string' } }, 'hidden', {
|
||||
value: { type: 'number' },
|
||||
})
|
||||
expect(() => parameterSchemaSpecToJsonSchema(hiddenKey as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
const sparseOneOf = new Array<ValueSchemaSpec>(2)
|
||||
sparseOneOf[0] = { type: 'string' }
|
||||
expect(() => valueSchemaSpecToJsonSchema({ oneOf: sparseOneOf } as unknown as ValueSchemaSpec)).toThrow(JsonSchemaError)
|
||||
const decoratedEnum = Object.assign(['a'], { hidden: true })
|
||||
expect(() => valueSchemaSpecToJsonSchema({
|
||||
type: 'string',
|
||||
enum: decoratedEnum,
|
||||
})).toThrow(JsonSchemaError)
|
||||
})
|
||||
|
||||
it('rejects cyclic author schemas', () => {
|
||||
const schema: Record<string, unknown> = { type: 'array' }
|
||||
schema.items = schema
|
||||
expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/)
|
||||
|
||||
const properties: Record<string, unknown> = {}
|
||||
properties.self = { type: 'object', additionalProperties: true, properties }
|
||||
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
|
||||
})
|
||||
|
||||
it('compiles deeply nested author unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let spec: unknown = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] }
|
||||
|
||||
const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec)
|
||||
|
||||
let cursor = compiled
|
||||
let layers = 0
|
||||
while (cursor.oneOf !== undefined) {
|
||||
cursor = cursor.oneOf[0]!
|
||||
layers++
|
||||
}
|
||||
expect(layers).toBe(depth)
|
||||
expect(cursor).toEqual({ type: 'string' })
|
||||
})
|
||||
|
||||
it('preserves a property literally named __proto__ as schema data', () => {
|
||||
const properties = Object.create(null) as ParameterSchemaSpec
|
||||
properties.__proto__ = { type: 'string', required: true }
|
||||
|
||||
const schema = parameterSchemaSpecToJsonSchema(properties)
|
||||
|
||||
expect(Object.hasOwn(schema.properties, '__proto__')).toBe(true)
|
||||
expect(schema.properties.__proto__).toEqual({ type: 'string' })
|
||||
expect(schema.required).toEqual(['__proto__'])
|
||||
})
|
||||
|
||||
it('infers scalar literals, arrays, objects, json, and exact-one unions', () => {
|
||||
expectTypeOf<InferValue<{ type: 'string'; enum: readonly ['a', 'b'] }>>().toEqualTypeOf<'a' | 'b'>()
|
||||
expectTypeOf<InferValue<{ type: 'number'; const: 1 }>>().toEqualTypeOf<1>()
|
||||
expectTypeOf<InferValue<{ type: 'integer' }>>().toEqualTypeOf<number>()
|
||||
expectTypeOf<InferValue<{ type: 'boolean'; enum: readonly [true] }>>().toEqualTypeOf<true>()
|
||||
expectTypeOf<InferValue<{ type: 'null' }>>().toEqualTypeOf<null>()
|
||||
expectTypeOf<InferValue<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>()
|
||||
expectTypeOf<InferValue<{ type: 'array' }>>().toEqualTypeOf<JsonValue[]>()
|
||||
expectTypeOf<InferValue<{ type: 'json' }>>().toEqualTypeOf<JsonValue>()
|
||||
expectTypeOf<InferValue<{ oneOf: readonly [{ type: 'string' }, { type: 'null' }] }>>()
|
||||
.toEqualTypeOf<string | null>()
|
||||
expectTypeOf<InferValue<{
|
||||
type: 'object'
|
||||
additionalProperties: false
|
||||
properties: { id: { type: 'integer'; required: true }; label: { type: 'string' } }
|
||||
}>>().toEqualTypeOf<{ id: number; label?: string }>()
|
||||
expectTypeOf<InferValue<{
|
||||
type: 'object'
|
||||
additionalProperties: true
|
||||
properties: { id: { type: 'integer'; required: true } }
|
||||
}>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
|
||||
})
|
||||
|
||||
it('bounds inference for deeply nested author schemas', () => {
|
||||
type Repeat<Count extends number, Result extends unknown[] = []> =
|
||||
Result['length'] extends Count ? Result : Repeat<Count, [unknown, ...Result]>
|
||||
type DeepArraySchema<Levels extends unknown[]> =
|
||||
Levels extends [unknown, ...infer Rest]
|
||||
? { type: 'array'; items: DeepArraySchema<Rest> }
|
||||
: { type: 'string' }
|
||||
type PeelArrays<Value, Levels extends unknown[]> =
|
||||
Levels extends [unknown, ...infer Rest]
|
||||
? Value extends (infer Item)[] ? PeelArrays<Item, Rest> : never
|
||||
: Value
|
||||
|
||||
type DeepValue = InferValue<DeepArraySchema<Repeat<50>>>
|
||||
expectTypeOf<PeelArrays<DeepValue, Repeat<16>>>().toEqualTypeOf<JsonValue>()
|
||||
})
|
||||
|
||||
it('infers required and optional parameter keys', () => {
|
||||
expectTypeOf<InferArgs<{
|
||||
path: { type: 'string'; required: true }
|
||||
offset: { type: 'integer' }
|
||||
data: { type: 'json' }
|
||||
}>>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>()
|
||||
})
|
||||
|
||||
it('makes invalid author forms compile-time errors', () => {
|
||||
const symbolKey = Symbol('parameter')
|
||||
const invalidObjects = {
|
||||
// @ts-expect-error explicit object schemas require an openness decision
|
||||
object: { type: 'object' } satisfies ValueSchemaSpec,
|
||||
// @ts-expect-error oneOf requires at least two branches
|
||||
oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec,
|
||||
// @ts-expect-error scalar enum values must match the node type
|
||||
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
|
||||
// @ts-expect-error parameter requiredness is true-or-absent
|
||||
required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
|
||||
// @ts-expect-error parameter maps accept string keys only
|
||||
symbol: { [symbolKey]: { type: 'string' } } satisfies ParameterSchemaSpec,
|
||||
}
|
||||
expect(Object.keys(invalidObjects)).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -39,7 +38,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
|
||||
name,
|
||||
description: `tool ${name}`,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value as string }],
|
||||
},
|
||||
execute: (): Promise<string> => Promise.resolve(reply),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +227,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
const guard = (execution: Readonly<ToolExecution>): string => {
|
||||
@@ -256,7 +259,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.tools.guard(() => undefined)
|
||||
@@ -322,14 +325,14 @@ describe('scoped execution dispatch', () => {
|
||||
execute: (args) => {
|
||||
safeCalls += 1
|
||||
safeArguments = args
|
||||
return Promise.resolve([{ type: 'text', text: 'safe' }])
|
||||
return Promise.resolve('safe')
|
||||
},
|
||||
})
|
||||
ctx.tools.register({
|
||||
...tool('danger'),
|
||||
execute: () => {
|
||||
dangerCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'danger' }])
|
||||
return Promise.resolve('danger')
|
||||
},
|
||||
})
|
||||
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
|
||||
@@ -382,7 +385,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
@@ -444,7 +447,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: (_args, exec) => {
|
||||
observed.push(exec.parent)
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
@@ -561,7 +564,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
@@ -604,6 +607,7 @@ describe('scoped execution dispatch', () => {
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'ran:t' }],
|
||||
isError: false,
|
||||
value: 'ran:t',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -622,6 +626,7 @@ describe('scoped execution dispatch', () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'outer failure' }],
|
||||
isError: true,
|
||||
error: { message: 'outer failure' },
|
||||
}
|
||||
}, { prepend: true })
|
||||
scope.ctx.on('tools/result', (_exec, result) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||
import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
describe('jsonSchemaToTs', () => {
|
||||
it('maps the defineTool DSL subset', () => {
|
||||
it('maps every unified schema construct', () => {
|
||||
const cases: [unknown, string][] = [
|
||||
[{ type: 'string' }, 'string'],
|
||||
[{ type: 'number' }, 'number'],
|
||||
[{ type: 'integer' }, 'number'],
|
||||
[{ type: 'boolean' }, 'boolean'],
|
||||
[{ type: 'null' }, 'null'],
|
||||
[{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'],
|
||||
[{ type: 'number', enum: [1, 2] }, '1 | 2'],
|
||||
[{ type: 'integer', const: 2 }, '2'],
|
||||
[{ type: 'boolean', const: true }, 'true'],
|
||||
[{ type: 'null', const: null }, 'null'],
|
||||
[{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'],
|
||||
[{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'],
|
||||
[{ type: 'array', items: { type: 'number' } }, 'number[]'],
|
||||
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
|
||||
[{ type: 'array' }, 'unknown[]'],
|
||||
[{ type: 'object' }, 'Record<string, unknown>'],
|
||||
[{ type: 'object', properties: {} }, 'Record<string, unknown>'],
|
||||
[{ type: 'array' }, 'JsonValue[]'],
|
||||
[{ type: 'object' }, 'Record<string, JsonValue>'],
|
||||
[{ type: 'object', additionalProperties: false }, 'Record<string, never>'],
|
||||
[{ type: 'object', properties: {} }, 'Record<string, JsonValue>'],
|
||||
[{ type: 'object', properties: {}, additionalProperties: false }, 'Record<string, never>'],
|
||||
[{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { id: { type: 'integer' }, label: { type: 'string' } },
|
||||
required: ['id'],
|
||||
}, ['{', ' id: number;', ' label?: string;', '}'].join('\n')],
|
||||
[{}, 'JsonValue'],
|
||||
]
|
||||
for (const [schema, expected] of cases) {
|
||||
expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected)
|
||||
@@ -22,11 +39,12 @@ describe('jsonSchemaToTs', () => {
|
||||
})
|
||||
|
||||
it('renders objects with required/optional keys, nested shapes, and per-property docs', () => {
|
||||
const schema = schemaSpecToJsonSchema({
|
||||
const schema = parameterSchemaSpecToJsonSchema({
|
||||
path: { type: 'string', required: true, description: 'Absolute file path' },
|
||||
limit: { type: 'number' },
|
||||
opts: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: { deep: { type: 'boolean', required: true } },
|
||||
},
|
||||
})
|
||||
@@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => {
|
||||
' limit?: number;',
|
||||
' opts?: {',
|
||||
' deep: boolean;',
|
||||
' };',
|
||||
'}',
|
||||
' } & Record<string, JsonValue>;',
|
||||
'} & Record<string, JsonValue>',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
@@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => {
|
||||
null,
|
||||
42,
|
||||
'string-schema',
|
||||
{},
|
||||
{ type: 'integer' },
|
||||
{ type: 'null' },
|
||||
{ oneOf: [{ type: 'string' }] },
|
||||
{ $ref: '#/defs/x' },
|
||||
{ type: 'object', properties: 7 },
|
||||
@@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => {
|
||||
for (const schema of cases) {
|
||||
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record<string, unknown>')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;')
|
||||
// A non-string-only enum degrades to plain string; an empty one too.
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string')
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string')
|
||||
// A hostile required list only accepts string members.
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;')
|
||||
// A property VALUE that is not an object degrades to unknown (and can
|
||||
// carry no description).
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown')
|
||||
})
|
||||
|
||||
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
|
||||
@@ -83,33 +93,59 @@ describe('jsonSchemaToTs', () => {
|
||||
expect(rendered).not.toContain('tool-*/ over')
|
||||
expect(rendered).toContain(String.raw`tool-*\/ over`)
|
||||
})
|
||||
|
||||
it('renders deeply nested unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: unknown = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
|
||||
const rendered = jsonSchemaToTs(schema)
|
||||
|
||||
expect(rendered.startsWith('string | null')).toBe(true)
|
||||
expect(rendered.length).toBe('string'.length + depth * ' | null'.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderToolsSdk', () => {
|
||||
const bash: ToolSchema = {
|
||||
const bash: ToolSdkSchema = {
|
||||
name: 'bash',
|
||||
description: 'Run a shell command.',
|
||||
parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
||||
parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
||||
output: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { exitCode: { type: 'integer' } },
|
||||
required: ['exitCode'],
|
||||
},
|
||||
}
|
||||
const exotic: ToolSchema = {
|
||||
const exotic: ToolSdkSchema = {
|
||||
name: 'my-mcp.tool',
|
||||
description: 'Exotic name.',
|
||||
parameters: schemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
||||
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
||||
output: { type: 'array', items: { type: 'string' } },
|
||||
}
|
||||
|
||||
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
|
||||
const text = renderToolsSdk([exotic, bash])
|
||||
expect(text).toContain('interface ToolArgsMap {')
|
||||
expect(text).toContain('interface ToolOutputMap {')
|
||||
expect(text).toContain('type ToolName = keyof ToolOutputMap')
|
||||
expect(text).toContain('declare class ToolCallError extends Error')
|
||||
expect(text).toContain('readonly toolName: ToolName;')
|
||||
expect(text).toContain('declare const tools: {')
|
||||
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
|
||||
expect(text).toContain('"my-mcp.tool"(args:')
|
||||
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))
|
||||
expect(text).toContain('): Promise<string>;')
|
||||
expect(text).toContain('type JsonValue = null | boolean | number | string')
|
||||
expect(text.indexOf('bash: {')).toBeGreaterThan(0)
|
||||
expect(text).toContain('"my-mcp.tool":')
|
||||
expect(text.indexOf('bash:')).toBeLessThan(text.indexOf('"my-mcp.tool":'))
|
||||
expect(text).toContain('exitCode: number;')
|
||||
expect(text).toContain('"my-mcp.tool": string[];')
|
||||
expect(text).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
|
||||
expect(text).toContain('/** Run a shell command. */')
|
||||
// The fixed instruction lines the model relies on.
|
||||
expect(text).toContain('erasable syntax only')
|
||||
expect(text).toContain('rejects with an `Error`')
|
||||
expect(text).toContain('rejects with `ToolCallError`')
|
||||
expect(text).toContain('sequentially, even under `Promise.all`')
|
||||
expect(text).toContain('JSON-serializable')
|
||||
expect(text).toContain('lossless JSON')
|
||||
})
|
||||
|
||||
it('is deterministic: same tool set, byte-identical text regardless of input order', () => {
|
||||
@@ -119,6 +155,8 @@ describe('renderToolsSdk', () => {
|
||||
})
|
||||
|
||||
it('renders an empty declaration for an empty tool set', () => {
|
||||
expect(renderToolsSdk([])).toContain('declare const tools: {}')
|
||||
const text = renderToolsSdk([])
|
||||
expect(text).toContain('interface ToolArgsMap {}')
|
||||
expect(text).toContain('interface ToolOutputMap {}')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user