Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	docs/config-catalog.md
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
This commit is contained in:
Tianyi Cui
2026-07-21 19:15:18 +08:00
140 changed files with 8516 additions and 426 deletions
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
/**
* Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy.
* The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path.
*/
let root: string
let ws: string
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */
function serverScript(hang: boolean): string {
const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
return 'let b=Buffer.alloc(0);'
+ `const DEF=${definition};`
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
+ `else if(m.method==="textDocument/definition"){${hang ? '' : 'process.stdout.write(fr({id:m.id,result:DEF}));'}}`
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
+ 'else if(m.method==="exit")process.exit(0);'
+ '}});'
}
async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
servers: {
inline: {
command: process.execPath,
args: ['-e', serverScript(hang)],
extensionToLanguage: { '.ts': 'typescript' },
shutdownTimeoutMs: 200,
killGraceMs: 200,
},
},
})
await ctx.plugin(TimeoutPolicy)
await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {})
return ctx
}
let seq = 0
function call(ctx: Context, args: unknown) {
return ctx.tools.execute({
callId: `int-${++seq}` as never,
name: 'lsp',
arguments: args,
agent: { session: { header: { cwd: ws } } } as never,
})
}
describe('tool-lsp integration', () => {
it('round-trips a definition query through the real provider and renders a location', async () => {
const ctx = await mount(false)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
await ctx.fiber.dispose()
}, 30_000)
it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
const ctx = await mount(true, 300)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('TOOL_TIMEOUT')
await ctx.fiber.dispose()
}, 30_000)
})
@@ -0,0 +1,24 @@
/**
* Loader export-shape guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a
* stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the
* bare `apply`, dropping `inject` (postmortem 0001). This verifies the namespace survives
* `Loader.prototype.unwrapExports`; the `lsp-definition` ACP snapshot owns full app composition.
*/
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as toolLsp from '@deepseek-ai/dsh-tool-lsp'
describe('dsh-tool-lsp Loader export-shape guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in toolLsp).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolLsp) as Record<string, unknown>
expect(unwrapped).toBe(toolLsp)
expect(unwrapped.name).toBe('tool-lsp')
expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt'])
expect(typeof unwrapped.apply).toBe('function')
expect(unwrapped.Config).toBeDefined()
})
})
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
renderUri,
} from '@deepseek-ai/dsh-tool-lsp'
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
const WS = '/home/u/proj'
function loc(uri: string, line: number, character = 0): LspLocation {
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
}
describe('parseLspArgs', () => {
it('accepts the four operations and converts one-based to zero-based', () => {
for (const operation of LSP_OPERATIONS) {
const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 })
expect(input.operation).toBe(operation)
expect(input.position).toEqual({ line: 2, character: 4 })
}
})
it('rejects an unknown operation', () => {
expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }))
.toThrow(/operation must be one of/)
})
it('rejects a blank file_path', () => {
expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 }))
.toThrow(/file_path/)
})
it('rejects non-positive or non-integer coordinates', () => {
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/)
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/)
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/)
})
})
describe('renderUri', () => {
it('relativizes a file: URI inside the workspace with forward slashes', () => {
const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('src/a.ts')
})
it('returns an absolute path for a file: URI outside the workspace', () => {
const uri = pathToFileURL('/other/lib/b.ts').href
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
})
it('renders the workspace root itself as "."', () => {
expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
})
it('keeps an in-workspace path whose first segment starts with dots relative', () => {
// `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external.
const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('..generated/a.ts')
})
it('keeps a non-file URI verbatim', () => {
expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
})
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
})
})
describe('formatLocations', () => {
it('renders a no-result line for an empty list', () => {
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
})
it('renders one-based path:line:character grouped by file', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
expect(text).toBe('a.ts:1:1\na.ts:5:3')
})
it('caps at maxLocations and marks the omission', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('a.ts:1:1')
expect(text).toContain('3 more locations omitted (limit 2).')
})
it('uses the singular omission marker for exactly one extra', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('1 more location omitted (limit 1).')
})
it('caps the complete location text even when one URI is enormous', () => {
const maxResultChars = 80
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
expect(text).toHaveLength(maxResultChars)
expect(text).toContain('locations truncated')
})
})
describe('formatHover', () => {
it('renders a no-result line for null', () => {
expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.')
})
it('returns short hover verbatim', () => {
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```')
})
it('caps the complete hover text including its truncation marker', () => {
const text = formatHover({ contents: 'a'.repeat(100) }, 60)
expect(text).toHaveLength(60)
expect(text).toContain('hover truncated (limit 60 characters).')
})
it('still honors a cap smaller than the truncation marker', () => {
expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10)
})
})
describe('presentLspCall', () => {
it('is a generic search card with an operation/cursor title and a line location', () => {
expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
card: 'generic',
kind: 'search',
title: 'LSP findReferences a.ts:3:7',
locations: [{ path: 'a.ts', line: 3 }],
})
})
})
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
/** A scripted provider recording queries; `respond` yields the result or throws. */
function stubProvider(
respond: (request: LspProviderQuery) => LspQueryResult,
extensionToLanguage: Record<string, string> = { '.ts': 'typescript' },
): LspProvider & { seen: LspProviderQuery[] } {
const seen: LspProviderQuery[] = []
return {
id: LspProviderId('stub'),
extensionToLanguage,
seen,
query(request) {
seen.push(request)
return Promise.resolve(respond(request))
},
}
}
/** Mount the real tool stack over a real seam plus one stub provider. */
async function mount(
provider?: LspProvider,
config: ToolLsp.Config = {},
): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
if (provider) (ctx.lsp as Lsp).registerProvider(provider)
await ctx.plugin(ToolLsp, config)
return { ctx }
}
let seq = 0
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
return ctx.tools.execute({
callId: `c-${++seq}` as never,
name: 'lsp',
arguments: args,
...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {},
})
}
const okLocations: LspQueryResult = {
kind: 'locations',
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/ws',
}
describe('tool-lsp registration', () => {
it('registers the lsp tool and its prompt section', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
expect(ctx.tools.get('lsp')).toBeDefined()
const prompt = await ctx.systemPrompt.assemble()
const text = prompt.sections.map(s => s.text).join('\n')
expect(text).toContain(LSP_PROMPT_TEXT)
})
it('attaches the default timeout budget to the tool definition', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS)
})
it('honors a configured timeout override', async () => {
const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 })
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000)
})
it('exposes exactly the four operations in the schema enum', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover'])
})
it('has no default export (namespace plugin shape)', () => {
expect((ToolLsp as { default?: unknown }).default).toBeUndefined()
})
it('rejects a non-positive config value at load', async () => {
await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
})
it('rejects a timeout above Node timer range at load', async () => {
await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(/timeoutMs/)
expect(() => {
ToolLsp.apply(new Context(), {
maxLocations: 100,
maxResultChars: 16_000,
timeoutMs: MAX_TIMER_DELAY_MS + 1,
})
}).toThrow(/timeoutMs/)
})
})
describe('tool-lsp execution', () => {
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
const provider = stubProvider(() => okLocations)
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
expect(result.isError).toBe(false)
expect(provider.seen[0]).toMatchObject({
operation: 'goToDefinition',
filePath: 'a.ts',
position: { line: 2, character: 4 },
workspaceRoot: '/ws',
})
})
it('renders locations relative to the workspace', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
expect(result).toMatchObject({ isError: false, value: okLocations })
})
it('keeps all acquired locations in the canonical value when presentation is capped', async () => {
const locations = [
{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } },
{ uri: 'file:///ws/b.ts', range: { start: { line: 1, character: 2 }, end: { line: 1, character: 3 } } },
]
const { ctx } = await mount(stubProvider(() => ({
kind: 'locations',
locations,
resolvedWorkspaceRoot: '/ws',
})), { maxLocations: 1 })
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({
type: 'text',
text: 'a.ts:1:1\n… 1 more location omitted (limit 1).',
})
expect(result).toMatchObject({
isError: false,
value: { kind: 'locations', locations, resolvedWorkspaceRoot: '/ws' },
})
})
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
// location URIs are under. Relativizing against the alias would misclassify the location as
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/real/ws',
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('renders hover content', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number' } } })
})
it('preserves an optional hover range in the canonical value', async () => {
const range = { start: { line: 2, character: 3 }, end: { line: 2, character: 7 } }
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number', range } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 3, character: 4 }, '/ws')
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number', range } } })
})
it('preserves a null hover result as an explicit value', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: null })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({ type: 'text', text: 'No hover information.' })
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: null } })
})
it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null)
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('LSP_WORKSPACE_REQUIRED')
})
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('LSP_UNAVAILABLE')
})
it('returns a structured INVALID_ARGS on a bad operation', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('INVALID_ARGS')
})
it('forwards exec.signal to the seam query', async () => {
const seen: (AbortSignal | undefined)[] = []
const provider: LspProvider = {
id: LspProviderId('sig'),
extensionToLanguage: { '.ts': 'typescript' },
query(_request, signal) {
seen.push(signal)
return Promise.resolve(okLocations)
},
}
const { ctx } = await mount(provider)
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
// undefined); the point is the tool threads it through without throwing.
expect(seen).toHaveLength(1)
})
it('presentCall renders the pending card from args', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 })
expect(view).toEqual({
card: 'generic',
kind: 'search',
title: 'LSP hover a.ts:2:3',
locations: [{ path: 'a.ts', line: 2 }],
})
})
})