import { describe, expect, it } from 'vitest' import { join, resolve } 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, { 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 = { '.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 const testToolSignal = new AbortController().signal const workspaceRoot = resolve('/virtual/workspace') const resolvedWorkspaceRoot = resolve('/virtual/real-workspace') const workspaceAlias = resolve('/virtual/workspace-alias') /** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */ function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) { return ctx.tools.execute({ signal: testToolSignal, callId: `c-${++seq}` as never, name: 'lsp', arguments: args, ...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {}, }) } const okLocations: LspQueryResult = { kind: 'locations', locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], resolvedWorkspaceRoot: workspaceRoot, } 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 }, workspaceRoot) expect(result.isError).toBe(false) expect(provider.seen[0]).toMatchObject({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 2, character: 4 }, workspaceRoot, }) }) 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 }, workspaceRoot) 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 cappedWorkspaceRoot = resolve('/virtual/capped-workspace') const locations = [ { uri: pathToFileURL(join(cappedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }, { uri: pathToFileURL(join(cappedWorkspaceRoot, 'b.ts')).href, range: { start: { line: 1, character: 2 }, end: { line: 1, character: 3 } } }, ] const { ctx } = await mount(stubProvider(() => ({ kind: 'locations', locations, resolvedWorkspaceRoot: cappedWorkspaceRoot, })), { maxLocations: 1 }) const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, cappedWorkspaceRoot) 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: cappedWorkspaceRoot }, }) }) it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => { // A symlinked session cwd resolves to the real path that contains the provider's location URIs. // Relativizing against the alias would misclassify the location as external. const provider = stubProvider(() => ({ kind: 'locations', locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], resolvedWorkspaceRoot, })) const { ctx } = await mount(provider) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias) expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias }) 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 }, workspaceRoot) 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 }, workspaceRoot) 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 }, workspaceRoot) 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 }, workspaceRoot) // 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 }], }) }) })