service.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  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: () => Promise<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: await ctx.agents.register(agent) }
  51. }
  52. describe('LocalFileReferenceService', () => {
  53. it('defers guidance until optional prompt and tool services become available', async () => {
  54. const ctx = new Context()
  55. await ctx.plugin(SessionStore)
  56. await ctx.plugin(AgentRegistry)
  57. await ctx.plugin(LocalFileReferenceService)
  58. try {
  59. const { agent } = await stubAgent(ctx, 'deferred-prompt')
  60. expect(ctx.agents.get(agent.id)).toBe(agent)
  61. await ctx.plugin(SystemPrompt, { personaPrefix: '' })
  62. await ctx.plugin(ToolRegistry)
  63. ctx.tools.register(defineContentToolFixture({
  64. name: 'read', description: 'read a file', parameters: {}, execute: () => Promise.resolve([]),
  65. }))
  66. expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
  67. } finally {
  68. await ctx.fiber.dispose()
  69. }
  70. })
  71. it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => {
  72. const ctx = await harness()
  73. const { agent } = await stubAgent(ctx)
  74. const fiber = ctx.plugin(LocalFileReferenceService, {
  75. maxResults: 5,
  76. maxEntries: 100,
  77. excludedDirectories: ['.git'],
  78. })
  79. await fiber
  80. await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal))
  81. .resolves.toEqual([{ path: 'README.md', kind: 'file' }])
  82. expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
  83. ctx.tools.register(defineContentToolFixture({
  84. name: 'read',
  85. description: 'read a file',
  86. parameters: {},
  87. execute: () => Promise.resolve([]),
  88. }))
  89. expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
  90. await fiber.dispose()
  91. expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
  92. })
  93. it('invalidates cached searches after tool results and disposes them with the agent', async () => {
  94. const ctx = await harness()
  95. const { agent, dispose } = await stubAgent(ctx)
  96. const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate')
  97. const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose')
  98. await ctx.plugin(LocalFileReferenceService)
  99. await ctx.fileReferences.list(agent, 'README', new AbortController().signal)
  100. ctx.emit('session/event', agent.session, { type: 'tool/result' } as never)
  101. expect(invalidate).toHaveBeenCalledOnce()
  102. ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never)
  103. expect(invalidate).toHaveBeenCalledOnce()
  104. const orphan = ctx.sessions.create(SessionId('file-reference-orphan'))
  105. ctx.emit('session/event', orphan, { type: 'tool/result' } as never)
  106. expect(invalidate).toHaveBeenCalledOnce()
  107. await dispose()
  108. expect(close).toHaveBeenCalledOnce()
  109. ctx.emit('agent/disposed', { agent })
  110. })
  111. it('installs guidance for agents announced after the service and validates deployment tunables', async () => {
  112. const ctx = await harness()
  113. await ctx.plugin(LocalFileReferenceService)
  114. const { agent } = await stubAgent(ctx)
  115. await expect(ctx.fileReferences.list(agent, '', new AbortController().signal))
  116. .resolves.toEqual([{ path: 'README.md', kind: 'file' }])
  117. const badResults = await harness()
  118. expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults')
  119. const badEntries = await harness()
  120. expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries')
  121. const badExclusion = await harness()
  122. expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] }))
  123. .toThrow('excludedDirectories')
  124. const fractionalResults = await harness()
  125. expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults')
  126. const zeroEntries = await harness()
  127. expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries')
  128. const emptyExclusion = await harness()
  129. expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] }))
  130. .toThrow('excludedDirectories')
  131. const backslashExclusion = await harness()
  132. expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] }))
  133. .toThrow('excludedDirectories')
  134. })
  135. it('deduplicates lifecycle announcements and falls back to the process cwd', async () => {
  136. const ctx = await harness()
  137. const fiber = ctx.plugin(LocalFileReferenceService)
  138. await fiber
  139. const { agent } = await stubAgent(ctx, 'cwd-fallback', false)
  140. await ctx.serial('agent/created', { agent, source: 'startup' })
  141. const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([])
  142. await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([])
  143. await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([])
  144. expect(list).toHaveBeenCalledTimes(2)
  145. })
  146. it('logs rejected prompt cleanup without failing service teardown', async () => {
  147. const ctx = await harness()
  148. const fiber = ctx.plugin(LocalFileReferenceService)
  149. await fiber
  150. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  151. const inject = vi.spyOn(ctx, 'inject')
  152. .mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never)
  153. // Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values.
  154. // oxlint-disable-next-line typescript/prefer-promise-reject-errors
  155. .mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never)
  156. const first = await stubAgent(ctx, 'cleanup-one')
  157. const second = await stubAgent(ctx, 'cleanup-two')
  158. expect(inject).toHaveBeenCalledTimes(2)
  159. await first.dispose()
  160. await second.dispose()
  161. await vi.waitFor(() => {
  162. expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup')
  163. expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup')
  164. })
  165. await expect(fiber.dispose()).resolves.toBeUndefined()
  166. })
  167. })