integration.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. /**
  2. * Integration tests: the real local backend (`dsh-fs-local`) plus the model
  3. * tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()`
  4. * so nothing bypasses the tool registry. Two deployments:
  5. *
  6. * - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before-
  7. * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
  8. * - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to
  9. * its undefined default, so write/edit are unconditional. This proves the
  10. * tool carries no dependency on the policy plugin.
  11. *
  12. * These verify the WORLD — files are read back from disk and asserted
  13. * byte-for-byte — not the tool's self-report.
  14. */
  15. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  16. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  17. import { tmpdir } from 'node:os'
  18. import { join } from 'node:path'
  19. import { Context } from 'cordis'
  20. import { CallId } from '@deepseek-ai/dsh-llm'
  21. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  22. import ToolRegistry from '@deepseek-ai/dsh-tools'
  23. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  24. import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
  25. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  26. let dir: string
  27. let ctx: Context
  28. let fiber: Awaited<ReturnType<Context['plugin']>>
  29. // A stable session object stands in for an agent session (the file-state
  30. // owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to
  31. // `undefined` and the backend falls back to its configured cwd (= `dir`).
  32. const session = { header: {} }
  33. let callCounter = 0
  34. function call(name: string, args: unknown) {
  35. return ctx.tools.execute({
  36. callId: CallId(`call-${++callCounter}`),
  37. name,
  38. arguments: args,
  39. agent: { session } as never,
  40. })
  41. }
  42. function text(result: { content: { type: string; text?: string }[] }): string {
  43. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  44. }
  45. afterEach(async () => {
  46. await fiber.dispose()
  47. await rm(dir, { recursive: true, force: true })
  48. })
  49. // --------------------------------------------------------------------------
  50. // DEFAULT deployment: the policy gate plugin is loaded.
  51. // --------------------------------------------------------------------------
  52. describe('default deployment (with dsh-fs-policy)', () => {
  53. beforeEach(async () => {
  54. dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
  55. ctx = new Context()
  56. await ctx.plugin(SystemPrompt)
  57. await ctx.plugin(ToolRegistry)
  58. await ctx.plugin(LocalFileSystem, { cwd: dir })
  59. await ctx.plugin(FsPolicy)
  60. fiber = await ctx.plugin(ToolFs)
  61. })
  62. describe('write → disk', () => {
  63. it('creates a file with exactly the requested bytes', async () => {
  64. const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
  65. expect(result.isError).toBe(false)
  66. expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
  67. })
  68. it('rejects overwriting an existing file without reading it first', async () => {
  69. await writeFile(join(dir, 'a.txt'), 'original')
  70. const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
  71. expect(result.isError).toBe(true)
  72. expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
  73. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
  74. })
  75. it('allows overwriting after a read', async () => {
  76. await writeFile(join(dir, 'a.txt'), 'original')
  77. expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
  78. const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
  79. expect(result.isError).toBe(false)
  80. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
  81. })
  82. it('rejects a full overwrite when the file changed since the read (stale)', async () => {
  83. await writeFile(join(dir, 'a.txt'), 'original')
  84. await call('read', { file_path: 'a.txt' })
  85. await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
  86. const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
  87. expect(result.isError).toBe(true)
  88. expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
  89. })
  90. })
  91. describe('read', () => {
  92. it('returns line-numbered content', async () => {
  93. await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
  94. const result = await call('read', { file_path: 'a.txt' })
  95. expect(text(result)).toContain('1: alpha')
  96. expect(text(result)).toContain('2: beta')
  97. expect(text(result)).toContain('(End of file - total 2 lines)')
  98. })
  99. it('reports a binary file as an error', async () => {
  100. await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
  101. const result = await call('read', { file_path: 'bin' })
  102. expect(result.isError).toBe(true)
  103. expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
  104. })
  105. it('paginates a multi-line file with offset/limit', async () => {
  106. await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour')
  107. const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 })
  108. expect(text(result)).toContain('2: two')
  109. expect(text(result)).toContain('3: three')
  110. expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
  111. })
  112. })
  113. describe('edit → disk', () => {
  114. it('applies a unique literal replacement after a read', async () => {
  115. await writeFile(join(dir, 'a.txt'), 'hello world')
  116. await call('read', { file_path: 'a.txt' })
  117. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
  118. expect(result.isError).toBe(false)
  119. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
  120. })
  121. it('rejects an edit before any read, leaving the file untouched', async () => {
  122. await writeFile(join(dir, 'a.txt'), 'hello world')
  123. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
  124. expect(result.isError).toBe(true)
  125. expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
  126. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
  127. })
  128. it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => {
  129. // A file with more lines than the read window; read only the first line.
  130. const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`)
  131. await writeFile(join(dir, 'a.txt'), lines.join('\n'))
  132. const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
  133. expect(read.isError).toBe(false)
  134. expect(text(read)).toContain('(Showing lines 1-1 of 20')
  135. // Editing a line OUTSIDE the window is authorized because the file is unchanged.
  136. const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' })
  137. expect(result.isError).toBe(false)
  138. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n'))
  139. })
  140. it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => {
  141. await writeFile(join(dir, 'a.txt'), 'hello world')
  142. await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
  143. await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
  144. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
  145. expect(result.isError).toBe(true)
  146. expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
  147. })
  148. it('rejects an ambiguous match without replace_all', async () => {
  149. await writeFile(join(dir, 'a.txt'), 'a a a')
  150. await call('read', { file_path: 'a.txt' })
  151. const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
  152. expect(result.isError).toBe(true)
  153. expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
  154. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
  155. })
  156. it('replaces all matches with replace_all', async () => {
  157. await writeFile(join(dir, 'a.txt'), 'a a a')
  158. await call('read', { file_path: 'a.txt' })
  159. const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
  160. expect(result.isError).toBe(false)
  161. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
  162. })
  163. it('supports a full write→edit cycle without an intervening read', async () => {
  164. await call('write', { file_path: 'a.txt', content: 'one two' })
  165. const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
  166. expect(result.isError).toBe(false)
  167. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
  168. })
  169. })
  170. describe('the gate records only through the events (no method coupling)', () => {
  171. it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => {
  172. await writeFile(join(dir, 'a.txt'), 'hello world')
  173. // Reach AROUND the tool — an explicit escape hatch for non-tool consumers.
  174. await ctx.fs.readText(await ctx.fs.resolve('a.txt'))
  175. // The model-facing edit still rejects: the read did not emit fs/observed.
  176. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
  177. expect(result.isError).toBe(true)
  178. expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
  179. })
  180. })
  181. describe('stat budget', () => {
  182. it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
  183. await writeFile(join(dir, 'a.txt'), 'hello world')
  184. const statSpy = vi.spyOn(ctx.fs, 'stat')
  185. // read: exactly one stat (type + size routing + observed version).
  186. await call('read', { file_path: 'a.txt' })
  187. expect(statSpy).toHaveBeenCalledTimes(1)
  188. // edit (guarded, after the read): the gate supplies vObserved; the tool
  189. // does not stat to manufacture a basis. CAS happens in editText's lock.
  190. statSpy.mockClear()
  191. const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
  192. expect(edited.isError).toBe(false)
  193. expect(statSpy).not.toHaveBeenCalled()
  194. // write (guarded replace, after the edit refreshed observed state): zero stat.
  195. statSpy.mockClear()
  196. const written = await call('write', { file_path: 'a.txt', content: 'fresh' })
  197. expect(written.isError).toBe(false)
  198. expect(statSpy).not.toHaveBeenCalled()
  199. statSpy.mockRestore()
  200. })
  201. })
  202. })
  203. // --------------------------------------------------------------------------
  204. // BARE deployment: the tool suite WITHOUT the policy gate.
  205. // --------------------------------------------------------------------------
  206. describe('bare provider (no dsh-fs-policy)', () => {
  207. beforeEach(async () => {
  208. dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
  209. ctx = new Context()
  210. await ctx.plugin(SystemPrompt)
  211. await ctx.plugin(ToolRegistry)
  212. await ctx.plugin(LocalFileSystem, { cwd: dir })
  213. fiber = await ctx.plugin(ToolFs)
  214. })
  215. it('read works (it never needed policy)', async () => {
  216. await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
  217. const result = await call('read', { file_path: 'a.txt' })
  218. expect(result.isError).toBe(false)
  219. expect(text(result)).toContain('1: alpha')
  220. })
  221. it('write unconditionally creates a new file', async () => {
  222. const result = await call('write', { file_path: 'new.txt', content: 'fresh' })
  223. expect(result.isError).toBe(false)
  224. expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
  225. })
  226. it('write unconditionally OVERWRITES an existing unread file', async () => {
  227. await writeFile(join(dir, 'a.txt'), 'original')
  228. const result = await call('write', { file_path: 'a.txt', content: 'clobbered' })
  229. expect(result.isError).toBe(false)
  230. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
  231. })
  232. it('edit unconditionally edits an UNREAD existing file', async () => {
  233. await writeFile(join(dir, 'a.txt'), 'hello world')
  234. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
  235. expect(result.isError).toBe(false)
  236. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
  237. })
  238. it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
  239. const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
  240. expect(result.isError).toBe(true)
  241. expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
  242. })
  243. it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
  244. await writeFile(join(dir, 'a.txt'), 'hello world')
  245. const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
  246. expect(result.isError).toBe(true)
  247. expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
  248. })
  249. it('neither write nor edit stats in the tool on the bare path', async () => {
  250. await writeFile(join(dir, 'a.txt'), 'hello world')
  251. const statSpy = vi.spyOn(ctx.fs, 'stat')
  252. expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false)
  253. expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false)
  254. expect(statSpy).not.toHaveBeenCalled()
  255. statSpy.mockRestore()
  256. })
  257. })
  258. // --------------------------------------------------------------------------
  259. // Per-session cwd: a relative file_path resolves against the CALLING session's
  260. // workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd —
  261. // so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression
  262. // this guards: before the seam fix the tool passed no cwd, so a relative write
  263. // landed in config.cwd instead of the session dir.
  264. // --------------------------------------------------------------------------
  265. describe('per-session cwd', () => {
  266. let sessionDir: string
  267. beforeEach(async () => {
  268. dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-'))
  269. sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-'))
  270. ctx = new Context()
  271. await ctx.plugin(SystemPrompt)
  272. await ctx.plugin(ToolRegistry)
  273. await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir
  274. await ctx.plugin(FsPolicy)
  275. fiber = await ctx.plugin(ToolFs)
  276. })
  277. afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) })
  278. const callIn = (sessionObj: object, name: string, args: unknown) =>
  279. ctx.tools.execute({
  280. callId: CallId(`call-${++callCounter}`),
  281. name,
  282. arguments: args,
  283. agent: { session: sessionObj } as never,
  284. })
  285. it('writes a relative path into the SESSION cwd, not config.cwd', async () => {
  286. const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' })
  287. expect(result.isError).toBe(false)
  288. // Verify the WORLD: the file is in the session dir, and NOT in config.cwd.
  289. expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi')
  290. await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  291. })
  292. it('read + edit both resolve against the session cwd (end-to-end)', async () => {
  293. // ONE session object across both calls — observed-state keys by owner
  294. // identity, so read must record under the same owner the edit reads.
  295. const session = { header: { cwd: sessionDir } }
  296. await writeFile(join(sessionDir, 'code.txt'), 'alpha')
  297. expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false)
  298. const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' })
  299. expect(edited.isError).toBe(false)
  300. expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta')
  301. })
  302. })
  303. // --------------------------------------------------------------------------
  304. // Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract —
  305. // all through ctx.tools.execute() against the REAL backend + policy.
  306. // --------------------------------------------------------------------------
  307. describe('signal, concurrency, and the fs/observed contract', () => {
  308. beforeEach(async () => {
  309. dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
  310. ctx = new Context()
  311. await ctx.plugin(SystemPrompt)
  312. await ctx.plugin(ToolRegistry)
  313. await ctx.plugin(LocalFileSystem, { cwd: dir })
  314. await ctx.plugin(FsPolicy)
  315. fiber = await ctx.plugin(ToolFs)
  316. })
  317. const session = { header: {} }
  318. const callSig = (signal: AbortSignal, name: string, args: unknown) =>
  319. ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal })
  320. const callOwned = (name: string, args: unknown) =>
  321. ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
  322. it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => {
  323. await writeFile(join(dir, 'a.txt'), 'hello')
  324. const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
  325. expect(read.isError).toBe(true)
  326. expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
  327. const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
  328. expect(write.isError).toBe(true)
  329. expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
  330. await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  331. // Read first (un-aborted, SAME session owner) so the edit clears the
  332. // observation gate; then the aborted edit fails on the signal, not on
  333. // FS_NOT_OBSERVED.
  334. expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
  335. const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
  336. expect(edit.isError).toBe(true)
  337. expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
  338. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
  339. })
  340. it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => {
  341. await writeFile(join(dir, 'a.txt'), 'base value here')
  342. // One read establishes the observed version both edits guard against; then
  343. // race two edits so both carry the SAME observed version (the barrier).
  344. expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
  345. const [one, two] = await Promise.all([
  346. callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }),
  347. callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }),
  348. ])
  349. const errors = [one, two].filter(r => r.isError)
  350. expect(errors).toHaveLength(1)
  351. expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
  352. // The world is consistent: exactly one edit landed.
  353. const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
  354. expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
  355. })
  356. it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
  357. // fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing
  358. // listener cannot roll the write back — it only turns the tool result into
  359. // isError. The file must still carry the written bytes.
  360. ctx.on('fs/observed', () => { throw new Error('recording bug') })
  361. const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' })
  362. expect(result.isError).toBe(true)
  363. expect(await readFile(join(dir, 'w.txt'), 'utf8')).toBe('durable')
  364. })
  365. })