service.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import AgentRegistry from '@deepseek-ai/dsh-agent'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  11. import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference'
  12. import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts'
  13. const roots: string[] = []
  14. afterEach(async () => {
  15. vi.restoreAllMocks()
  16. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  17. })
  18. async function harness(): Promise<Context> {
  19. const ctx = new Context()
  20. await ctx.plugin(SessionStore)
  21. await ctx.plugin(SystemPrompt, { personaPrefix: '' })
  22. await ctx.plugin(ToolRegistry)
  23. await ctx.plugin(AgentRegistry)
  24. return ctx
  25. }
  26. async function stubAgent(
  27. ctx: Context,
  28. id = 'file-reference-agent',
  29. includeCwd = true,
  30. ): Promise<{ agent: Agent; dispose: () => void }> {
  31. const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-'))
  32. roots.push(root)
  33. await writeFile(join(root, 'README.md'), 'readme')
  34. const session = ctx.sessions.create(SessionId(id), { meta: includeCwd ? { cwd: root } : {} })
  35. const agent = {
  36. id: session.id,
  37. options: {},
  38. session,
  39. status: 'idle',
  40. acceptsNextStep: false,
  41. ctx,
  42. followup() {},
  43. steer() {},
  44. inject() {},
  45. send() {},
  46. updateInbox() { return 'not-found' as const },
  47. cancel() {},
  48. whenIdle: () => Promise.resolve(),
  49. } as unknown as Agent
  50. return { agent, dispose: ctx.agents.register(agent) }
  51. }
  52. describe('LocalFileReferenceService', () => {
  53. it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => {
  54. const ctx = await harness()
  55. const { agent } = await stubAgent(ctx)
  56. const fiber = ctx.plugin(LocalFileReferenceService, {
  57. maxResults: 5,
  58. maxEntries: 100,
  59. excludedDirectories: ['.git'],
  60. })
  61. await fiber
  62. await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal))
  63. .resolves.toEqual([{ path: 'README.md', kind: 'file' }])
  64. expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
  65. ctx.tools.register(defineContentToolFixture({
  66. name: 'read',
  67. description: 'read a file',
  68. parameters: {},
  69. execute: () => Promise.resolve([]),
  70. }))
  71. expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
  72. await fiber.dispose()
  73. expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
  74. })
  75. it('invalidates cached searches after tool results and disposes them with the agent', async () => {
  76. const ctx = await harness()
  77. const { agent, dispose } = await stubAgent(ctx)
  78. const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate')
  79. const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose')
  80. await ctx.plugin(LocalFileReferenceService)
  81. await ctx.fileReferences.list(agent, 'README', new AbortController().signal)
  82. ctx.emit('session/event', agent.session, { type: 'tool/result' } as never)
  83. expect(invalidate).toHaveBeenCalledOnce()
  84. ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never)
  85. expect(invalidate).toHaveBeenCalledOnce()
  86. const orphan = ctx.sessions.create(SessionId('file-reference-orphan'))
  87. ctx.emit('session/event', orphan, { type: 'tool/result' } as never)
  88. expect(invalidate).toHaveBeenCalledOnce()
  89. dispose()
  90. expect(close).toHaveBeenCalledOnce()
  91. ctx.emit('agent/disposed', { agent })
  92. })
  93. it('installs guidance for agents announced after the service and validates deployment tunables', async () => {
  94. const ctx = await harness()
  95. await ctx.plugin(LocalFileReferenceService)
  96. const { agent } = await stubAgent(ctx)
  97. await expect(ctx.fileReferences.list(agent, '', new AbortController().signal))
  98. .resolves.toEqual([{ path: 'README.md', kind: 'file' }])
  99. const badResults = await harness()
  100. expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults')
  101. const badEntries = await harness()
  102. expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries')
  103. const badExclusion = await harness()
  104. expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] }))
  105. .toThrow('excludedDirectories')
  106. const fractionalResults = await harness()
  107. expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults')
  108. const zeroEntries = await harness()
  109. expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries')
  110. const emptyExclusion = await harness()
  111. expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] }))
  112. .toThrow('excludedDirectories')
  113. const backslashExclusion = await harness()
  114. expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] }))
  115. .toThrow('excludedDirectories')
  116. })
  117. it('deduplicates lifecycle announcements and falls back to the process cwd', async () => {
  118. const ctx = await harness()
  119. const fiber = ctx.plugin(LocalFileReferenceService)
  120. await fiber
  121. const { agent } = await stubAgent(ctx, 'cwd-fallback', false)
  122. ctx.emit('agent/created', { agent })
  123. const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([])
  124. await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([])
  125. await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([])
  126. expect(list).toHaveBeenCalledTimes(2)
  127. })
  128. it('logs rejected prompt cleanup without failing service teardown', async () => {
  129. const ctx = await harness()
  130. const fiber = ctx.plugin(LocalFileReferenceService)
  131. await fiber
  132. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  133. const inject = vi.spyOn(ctx, 'inject')
  134. .mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never)
  135. // Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values.
  136. // oxlint-disable-next-line typescript/prefer-promise-reject-errors
  137. .mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never)
  138. const first = await stubAgent(ctx, 'cleanup-one')
  139. const second = await stubAgent(ctx, 'cleanup-two')
  140. expect(inject).toHaveBeenCalledTimes(2)
  141. first.dispose()
  142. second.dispose()
  143. await vi.waitFor(() => {
  144. expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup')
  145. expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup')
  146. })
  147. await expect(fiber.dispose()).resolves.toBeUndefined()
  148. })
  149. })