tool-lsp.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { describe, expect, it } from 'vitest'
  2. import { join, resolve } from 'node:path'
  3. import { pathToFileURL } from 'node:url'
  4. import { Context } from 'cordis'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry from '@deepseek-ai/dsh-tools'
  7. import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
  8. import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
  9. import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
  10. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  11. /** A scripted provider recording queries; `respond` yields the result or throws. */
  12. function stubProvider(
  13. respond: (request: LspProviderQuery) => LspQueryResult,
  14. extensionToLanguage: Record<string, string> = { '.ts': 'typescript' },
  15. ): LspProvider & { seen: LspProviderQuery[] } {
  16. const seen: LspProviderQuery[] = []
  17. return {
  18. id: LspProviderId('stub'),
  19. extensionToLanguage,
  20. seen,
  21. query(request) {
  22. seen.push(request)
  23. return Promise.resolve(respond(request))
  24. },
  25. }
  26. }
  27. /** Mount the real tool stack over a real seam plus one stub provider. */
  28. async function mount(
  29. provider?: LspProvider,
  30. config: ToolLsp.Config = {},
  31. ): Promise<{ ctx: Context }> {
  32. const ctx = new Context()
  33. await ctx.plugin(SystemPrompt)
  34. await ctx.plugin(ToolRegistry)
  35. await ctx.plugin(Lsp)
  36. if (provider) (ctx.lsp as Lsp).registerProvider(provider)
  37. await ctx.plugin(ToolLsp, config)
  38. return { ctx }
  39. }
  40. let seq = 0
  41. const testToolSignal = new AbortController().signal
  42. const workspaceRoot = resolve('/virtual/workspace')
  43. const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
  44. const workspaceAlias = resolve('/virtual/workspace-alias')
  45. /** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
  46. function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
  47. return ctx.tools.execute({
  48. signal: testToolSignal,
  49. callId: `c-${++seq}` as never,
  50. name: 'lsp',
  51. arguments: args,
  52. ...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {},
  53. })
  54. }
  55. const okLocations: LspQueryResult = {
  56. kind: 'locations',
  57. locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
  58. resolvedWorkspaceRoot: workspaceRoot,
  59. }
  60. describe('tool-lsp registration', () => {
  61. it('registers the lsp tool and its prompt section', async () => {
  62. const { ctx } = await mount(stubProvider(() => okLocations))
  63. expect(ctx.tools.get('lsp')).toBeDefined()
  64. const prompt = await ctx.systemPrompt.assemble()
  65. const text = prompt.sections.map(s => s.text).join('\n')
  66. expect(text).toContain(LSP_PROMPT_TEXT)
  67. })
  68. it('attaches the default timeout budget to the tool definition', async () => {
  69. const { ctx } = await mount(stubProvider(() => okLocations))
  70. expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS)
  71. })
  72. it('honors a configured timeout override', async () => {
  73. const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 })
  74. expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000)
  75. })
  76. it('exposes exactly the four operations in the schema enum', async () => {
  77. const { ctx } = await mount(stubProvider(() => okLocations))
  78. const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
  79. expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover'])
  80. })
  81. it('has no default export (namespace plugin shape)', () => {
  82. expect((ToolLsp as { default?: unknown }).default).toBeUndefined()
  83. })
  84. it('rejects a non-positive config value at load', async () => {
  85. await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
  86. })
  87. it('rejects a timeout above Node timer range at load', async () => {
  88. await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
  89. .rejects.toThrow(/timeoutMs/)
  90. expect(() => {
  91. ToolLsp.apply(new Context(), {
  92. maxLocations: 100,
  93. maxResultChars: 16_000,
  94. timeoutMs: MAX_TIMER_DELAY_MS + 1,
  95. })
  96. }).toThrow(/timeoutMs/)
  97. })
  98. })
  99. describe('tool-lsp execution', () => {
  100. it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
  101. const provider = stubProvider(() => okLocations)
  102. const { ctx } = await mount(provider)
  103. const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
  104. expect(result.isError).toBe(false)
  105. expect(provider.seen[0]).toMatchObject({
  106. operation: 'goToDefinition',
  107. filePath: 'a.ts',
  108. position: { line: 2, character: 4 },
  109. workspaceRoot,
  110. })
  111. })
  112. it('renders locations relative to the workspace', async () => {
  113. const { ctx } = await mount(stubProvider(() => okLocations))
  114. const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
  115. expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
  116. expect(result).toMatchObject({ isError: false, value: okLocations })
  117. })
  118. it('keeps all acquired locations in the canonical value when presentation is capped', async () => {
  119. const cappedWorkspaceRoot = resolve('/virtual/capped-workspace')
  120. const locations = [
  121. { uri: pathToFileURL(join(cappedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } },
  122. { uri: pathToFileURL(join(cappedWorkspaceRoot, 'b.ts')).href, range: { start: { line: 1, character: 2 }, end: { line: 1, character: 3 } } },
  123. ]
  124. const { ctx } = await mount(stubProvider(() => ({
  125. kind: 'locations',
  126. locations,
  127. resolvedWorkspaceRoot: cappedWorkspaceRoot,
  128. })), { maxLocations: 1 })
  129. const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, cappedWorkspaceRoot)
  130. expect(result.content[0]).toEqual({
  131. type: 'text',
  132. text: 'a.ts:1:1\n… 1 more location omitted (limit 1).',
  133. })
  134. expect(result).toMatchObject({
  135. isError: false,
  136. value: { kind: 'locations', locations, resolvedWorkspaceRoot: cappedWorkspaceRoot },
  137. })
  138. })
  139. it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
  140. // A symlinked session cwd resolves to the real path that contains the provider's location URIs.
  141. // Relativizing against the alias would misclassify the location as external.
  142. const provider = stubProvider(() => ({
  143. kind: 'locations',
  144. locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
  145. resolvedWorkspaceRoot,
  146. }))
  147. const { ctx } = await mount(provider)
  148. const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
  149. expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
  150. expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
  151. })
  152. it('renders hover content', async () => {
  153. const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
  154. const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
  155. expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
  156. expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number' } } })
  157. })
  158. it('preserves an optional hover range in the canonical value', async () => {
  159. const range = { start: { line: 2, character: 3 }, end: { line: 2, character: 7 } }
  160. const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number', range } })))
  161. const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 3, character: 4 }, '/ws')
  162. expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number', range } } })
  163. })
  164. it('preserves a null hover result as an explicit value', async () => {
  165. const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: null })))
  166. const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
  167. expect(result.content[0]).toEqual({ type: 'text', text: 'No hover information.' })
  168. expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: null } })
  169. })
  170. it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
  171. const { ctx } = await mount(stubProvider(() => okLocations))
  172. const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null)
  173. expect(result.isError).toBe(true)
  174. expect(result.error?.info?.code).toBe('LSP_WORKSPACE_REQUIRED')
  175. })
  176. it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
  177. const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
  178. const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
  179. expect(result.isError).toBe(true)
  180. expect(result.error?.info?.code).toBe('LSP_UNAVAILABLE')
  181. })
  182. it('returns a structured INVALID_ARGS on a bad operation', async () => {
  183. const { ctx } = await mount(stubProvider(() => okLocations))
  184. const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
  185. expect(result.isError).toBe(true)
  186. expect(result.error?.info?.code).toBe('INVALID_ARGS')
  187. })
  188. it('forwards exec.signal to the seam query', async () => {
  189. const seen: (AbortSignal | undefined)[] = []
  190. const provider: LspProvider = {
  191. id: LspProviderId('sig'),
  192. extensionToLanguage: { '.ts': 'typescript' },
  193. query(_request, signal) {
  194. seen.push(signal)
  195. return Promise.resolve(okLocations)
  196. },
  197. }
  198. const { ctx } = await mount(provider)
  199. await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
  200. // The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
  201. // undefined); the point is the tool threads it through without throwing.
  202. expect(seen).toHaveLength(1)
  203. })
  204. it('presentCall renders the pending card from args', async () => {
  205. const { ctx } = await mount(stubProvider(() => okLocations))
  206. const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 })
  207. expect(view).toEqual({
  208. card: 'generic',
  209. kind: 'search',
  210. title: 'LSP hover a.ts:2:3',
  211. locations: [{ path: 'a.ts', line: 2 }],
  212. })
  213. })
  214. })