tools.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { Context } from 'cordis'
  6. import { FsVersion } from '@deepseek-ai/dsh-fs'
  7. import { CallId } from '@deepseek-ai/dsh-llm'
  8. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  9. import AgentRegistry from '@deepseek-ai/dsh-agent'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  12. import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
  13. import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
  14. import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
  15. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  16. import ToolRegistry from '@deepseek-ai/dsh-tools'
  17. import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
  18. const contexts: Context[] = []
  19. const roots: string[] = []
  20. let callNumber = 0
  21. afterEach(async () => {
  22. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  23. for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
  24. })
  25. function agent(ctx: Context, cwd: string): Agent {
  26. const id = SessionId(`str-replace-editor-owner-${callNumber}`)
  27. const scope = ctx.plugin(() => {})
  28. const value: Agent = {
  29. id,
  30. options: {},
  31. session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
  32. status: 'idle',
  33. acceptsNextStep: false,
  34. ctx: scope.ctx,
  35. followup: () => {},
  36. steer: () => {},
  37. inject: () => {},
  38. send: () => {},
  39. cancel() {},
  40. whenIdle: () => Promise.resolve(),
  41. }
  42. ctx.agents.register(value)
  43. return value
  44. }
  45. function text(result: { content: { type: string; text?: string }[] }): string {
  46. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  47. }
  48. function call(ctx: Context, owner: Agent | undefined, args: unknown) {
  49. return ctx.tools.execute({
  50. signal: new AbortController().signal,
  51. callId: CallId(`str-replace-editor-${++callNumber}`),
  52. name: 'str_replace_editor',
  53. arguments: args,
  54. ...owner === undefined ? {} : { agent: owner },
  55. })
  56. }
  57. async function setup(
  58. config: ToolStrReplaceEditor.Config = {},
  59. options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {},
  60. ) {
  61. const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
  62. roots.push(root)
  63. const ctx = new Context()
  64. contexts.push(ctx)
  65. await ctx.plugin(SystemPrompt)
  66. await ctx.plugin(ToolRegistry)
  67. await ctx.plugin(AgentRegistry)
  68. if (options.sandboxMode === undefined) {
  69. await ctx.plugin(LocalFileSystem, { cwd: root })
  70. } else {
  71. await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root })
  72. await ctx.plugin(SandboxedFileSystem, { cwd: root })
  73. }
  74. if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
  75. await ctx.plugin(ToolStrReplaceEditor, config)
  76. return { ctx, root, owner: agent(ctx, root) }
  77. }
  78. describe('tool-str-replace-editor', () => {
  79. it('registers the standalone schema and configurable description', async () => {
  80. const { ctx } = await setup({ description: 'custom editor description' })
  81. const schema = ctx.tools.schemas()[0]
  82. expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor'])
  83. expect(schema?.description).toBe('custom editor description')
  84. const properties = (schema?.parameters as {
  85. properties: Record<string, { type?: string; items?: { type?: string } }>
  86. }).properties
  87. expect(properties).not.toHaveProperty('replace_all')
  88. expect(properties.insert_line?.type).toBe('integer')
  89. expect(properties.view_range?.items?.type).toBe('integer')
  90. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  91. command: 'view',
  92. path: '/workspace/a.txt',
  93. })).toMatchObject({
  94. card: 'generic',
  95. kind: 'read',
  96. locations: [{ path: '/workspace/a.txt' }],
  97. })
  98. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  99. command: 'create',
  100. path: '/workspace/a.txt',
  101. file_text: 'hello',
  102. })).toMatchObject({
  103. card: 'diff',
  104. diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }],
  105. })
  106. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  107. command: 'str_replace',
  108. path: '/workspace/a.txt',
  109. old_str: 'old',
  110. new_str: 'new',
  111. })).toMatchObject({
  112. card: 'diff',
  113. diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }],
  114. })
  115. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  116. command: 'insert',
  117. path: '/workspace/a.txt',
  118. insert_line: 0,
  119. new_str: 'x',
  120. })).toMatchObject({
  121. card: 'generic',
  122. kind: 'edit',
  123. locations: [{ path: '/workspace/a.txt', line: 1 }],
  124. })
  125. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  126. command: 'create',
  127. path: '/workspace/empty.txt',
  128. })).toMatchObject({
  129. diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }],
  130. })
  131. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  132. command: 'str_replace',
  133. path: '/workspace/a.txt',
  134. })).toMatchObject({
  135. diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }],
  136. })
  137. expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
  138. command: 'insert',
  139. path: '/workspace/a.txt',
  140. })).toMatchObject({
  141. locations: [{ path: '/workspace/a.txt' }],
  142. })
  143. })
  144. it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
  145. const { ctx, root, owner } = await setup()
  146. const sample = join(root, 'sample.txt')
  147. expect(text(await call(ctx, owner, {
  148. command: 'create',
  149. path: sample,
  150. file_text: 'one\ntwo\nthree\n',
  151. }))).toBe(`New file created successfully at: ${sample}`)
  152. expect(text(await call(ctx, owner, {
  153. command: 'view',
  154. path: sample,
  155. view_range: [2, -1],
  156. }))).toBe([
  157. `Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`,
  158. ' 2 two',
  159. ' 3 three',
  160. ' 4 ',
  161. '',
  162. ].join('\n'))
  163. expect(text(await call(ctx, owner, {
  164. command: 'str_replace',
  165. path: sample,
  166. old_str: 'two',
  167. new_str: 'TWO',
  168. }))).toBe(`The file ${sample} has been edited successfully.`)
  169. expect(text(await call(ctx, owner, {
  170. command: 'str_replace',
  171. path: sample,
  172. old_str: 'TWO',
  173. }))).toBe(`The file ${sample} has been edited successfully.`)
  174. expect(text(await call(ctx, owner, {
  175. command: 'insert',
  176. path: sample,
  177. insert_line: 1,
  178. new_str: 'between',
  179. }))).toBe(`The file ${sample} has been edited successfully.`)
  180. expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
  181. })
  182. it('lists visible entries to depth two and clips at the configured view limit', async () => {
  183. const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 })
  184. await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
  185. await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
  186. await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true })
  187. await mkdir(join(root, 'dir', '__pycache__'), { recursive: true })
  188. await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true })
  189. await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
  190. await writeFile(join(root, 'dir', '.hidden'), 'hidden')
  191. await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child')
  192. await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep')
  193. await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency')
  194. await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source')
  195. await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache')
  196. await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source')
  197. const listDir = ctx.fs.listDir.bind(ctx.fs)
  198. const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
  199. ctx.fs.listDir = async (target, signal) => {
  200. const entries = await listDir(target, signal)
  201. return target.displayPath === join(root, 'dir')
  202. ? [
  203. { name: 'same-target', type: 'other', target: otherTarget },
  204. { name: 'other', type: 'other', target: otherTarget },
  205. ...entries.toReversed(),
  206. ]
  207. : entries
  208. }
  209. const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
  210. expect(listing).not.toContain('.hidden')
  211. expect(listing).not.toContain('too-deep.txt')
  212. expect(listing).not.toContain('index.js')
  213. expect(listing).not.toContain('module.pyc')
  214. expect(listing).toContain('node_modules_old/kept.js')
  215. expect(listing).toContain('__pycache__backup/kept.py')
  216. const clipped = await setup({ maxOutputChars: 10 })
  217. await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
  218. expect(text(await call(clipped.ctx, clipped.owner, {
  219. command: 'view',
  220. path: join(clipped.root, 'large.txt'),
  221. })))
  222. .toContain('<response clipped>')
  223. })
  224. it('matches canonical empty-line, range, and end-insert behavior', async () => {
  225. const { ctx, root, owner } = await setup()
  226. const empty = join(root, 'empty.txt')
  227. const newline = join(root, 'newline.txt')
  228. const plain = join(root, 'plain.txt')
  229. await writeFile(empty, '')
  230. await writeFile(newline, '\n')
  231. await writeFile(plain, 'one\ntwo')
  232. expect(text(await call(ctx, owner, { command: 'view', path: empty })))
  233. .toContain('(which has a total of 1 lines):\n 1 \n')
  234. expect(text(await call(ctx, owner, { command: 'view', path: newline })))
  235. .toContain('(which has a total of 2 lines):\n 1 \n 2 \n')
  236. expect(text(await call(ctx, owner, {
  237. command: 'view',
  238. path: plain,
  239. view_range: [1, 2],
  240. }))).toContain(' 2 two')
  241. expect(text(await call(ctx, undefined, {
  242. command: 'view',
  243. path: plain,
  244. }))).toContain(' 1 one')
  245. expect((await call(ctx, undefined, {
  246. command: 'create',
  247. path: join(root, 'ownerless.txt'),
  248. file_text: 'ownerless',
  249. })).isError).toBe(false)
  250. await call(ctx, owner, {
  251. command: 'insert',
  252. path: plain,
  253. insert_line: 2,
  254. new_str: 'three',
  255. })
  256. expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree')
  257. await writeFile(newline, 'one\n')
  258. await call(ctx, owner, {
  259. command: 'insert',
  260. path: newline,
  261. insert_line: 2,
  262. new_str: 'three',
  263. })
  264. expect(await readFile(newline, 'utf8')).toBe('one\n\nthree')
  265. })
  266. it('uses old_str-only replacement failures and rejects relative paths', async () => {
  267. const { ctx, root, owner } = await setup()
  268. const ambiguous = join(root, 'ambiguous.txt')
  269. await writeFile(ambiguous, 'same\nother\nsame')
  270. const missing = await call(ctx, owner, {
  271. command: 'str_replace',
  272. path: ambiguous,
  273. old_str: 'absent',
  274. new_str: 'x',
  275. })
  276. expect(missing.isError).toBe(true)
  277. expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`)
  278. expect(text(missing)).not.toContain('old_string')
  279. const repeated = await call(ctx, owner, {
  280. command: 'str_replace',
  281. path: ambiguous,
  282. old_str: 'same',
  283. new_str: 'x',
  284. })
  285. expect(repeated.isError).toBe(true)
  286. expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
  287. expect(text(repeated)).not.toContain('replace_all')
  288. await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta')
  289. const repeatedMultiline = await call(ctx, owner, {
  290. command: 'str_replace',
  291. path: ambiguous,
  292. old_str: 'alpha\nbeta',
  293. new_str: 'x',
  294. })
  295. expect(text(repeatedMultiline))
  296. .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]')
  297. const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
  298. expect(relative.isError).toBe(true)
  299. expect(text(relative)).toContain('is not an absolute path')
  300. expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta')
  301. })
  302. it('reports invalid commands or arguments without mutating files', async () => {
  303. const { ctx, root, owner } = await setup()
  304. const ambiguous = join(root, 'ambiguous.txt')
  305. const empty = join(root, 'empty.txt')
  306. const trailingNewline = join(root, 'trailing-newline.txt')
  307. const threeLines = join(root, 'three-lines.txt')
  308. const directory = join(root, 'directory')
  309. await writeFile(ambiguous, 'same same')
  310. await writeFile(empty, '')
  311. await writeFile(trailingNewline, 'one\n')
  312. await writeFile(threeLines, 'one\ntwo\nthree')
  313. await mkdir(directory)
  314. const cases = [
  315. { command: 'view', path: '' },
  316. { command: 'view', path: join(root, 'missing.txt') },
  317. { command: 'view', path: ambiguous, view_range: [1] },
  318. { command: 'view', path: ambiguous, view_range: [0, 1] },
  319. { command: 'view', path: ambiguous, view_range: [1.5, 2] },
  320. { command: 'view', path: threeLines, view_range: [1, 99] },
  321. { command: 'view', path: threeLines, view_range: [2, 1] },
  322. { command: 'view', path: directory, view_range: [1, 1] },
  323. { command: 'create', path: join(root, 'new.txt') },
  324. { command: 'create', path: ambiguous, file_text: 'overwrite' },
  325. { command: 'str_replace', path: ambiguous, new_str: 'x' },
  326. { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' },
  327. { command: 'insert', path: ambiguous, new_str: 'x' },
  328. { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' },
  329. { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' },
  330. { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' },
  331. { command: 'insert', path: empty, insert_line: 2, new_str: 'x' },
  332. { command: 'insert', path: directory, insert_line: 0, new_str: 'x' },
  333. ]
  334. for (const args of cases) {
  335. expect((await call(ctx, owner, args)).isError).toBe(true)
  336. }
  337. expect(await readFile(ambiguous, 'utf8')).toBe('same same')
  338. ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' })
  339. const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') })
  340. expect(special.isError).toBe(true)
  341. expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
  342. expect((await call(ctx, owner, {
  343. command: 'str_replace',
  344. path: join(root, 'special'),
  345. old_str: 'x',
  346. new_str: 'y',
  347. })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
  348. expect((await call(ctx, owner, {
  349. command: 'insert',
  350. path: join(root, 'special'),
  351. insert_line: 0,
  352. new_str: 'x',
  353. })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
  354. })
  355. it('can opt into session-relative paths for non-canonical deployments', async () => {
  356. const { ctx, root, owner } = await setup({ requireAbsolutePath: false })
  357. await writeFile(join(root, 'relative.txt'), 'relative')
  358. expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' })))
  359. .toContain("Here's the content of")
  360. })
  361. it('delegates read-before-edit decisions to fs-policy', async () => {
  362. const { ctx, root, owner } = await setup({}, { fsPolicy: true })
  363. const existing = join(root, 'existing.txt')
  364. const created = join(root, 'created.txt')
  365. await writeFile(existing, 'before')
  366. const blindEdit = await call(ctx, owner, {
  367. command: 'str_replace',
  368. path: existing,
  369. old_str: 'before',
  370. new_str: 'after',
  371. })
  372. expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
  373. expect(await readFile(existing, 'utf8')).toBe('before')
  374. await call(ctx, owner, { command: 'view', path: existing })
  375. expect((await call(ctx, owner, {
  376. command: 'str_replace',
  377. path: existing,
  378. old_str: 'before',
  379. new_str: 'after',
  380. })).isError).toBe(false)
  381. expect(await readFile(existing, 'utf8')).toBe('after')
  382. expect((await call(ctx, owner, {
  383. command: 'insert',
  384. path: existing,
  385. insert_line: 1,
  386. new_str: 'tail',
  387. })).isError).toBe(false)
  388. expect(await readFile(existing, 'utf8')).toBe('after\ntail')
  389. expect((await call(ctx, owner, {
  390. command: 'create',
  391. path: created,
  392. file_text: 'new',
  393. })).isError).toBe(false)
  394. expect(await readFile(created, 'utf8')).toBe('new')
  395. })
  396. it('passes the session sandbox policy to every mutation', async () => {
  397. const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' })
  398. const path = join(root, 'blocked.txt')
  399. const result = await call(ctx, owner, {
  400. command: 'create',
  401. path,
  402. file_text: 'blocked',
  403. })
  404. expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
  405. expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
  406. const ownerless = await call(ctx, undefined, {
  407. command: 'create',
  408. path: join(root, 'ownerless-blocked.txt'),
  409. file_text: 'blocked',
  410. })
  411. expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
  412. })
  413. it('can preserve tabs outside the edited region', async () => {
  414. const { ctx, root, owner } = await setup({ expandTabsOnMutation: false })
  415. const path = join(root, 'Makefile')
  416. await writeFile(path, 'target:\n\told\nremove\n')
  417. await call(ctx, owner, {
  418. command: 'str_replace',
  419. path,
  420. old_str: 'old',
  421. new_str: 'new',
  422. })
  423. await call(ctx, owner, {
  424. command: 'str_replace',
  425. path,
  426. old_str: 'remove\n',
  427. })
  428. await call(ctx, owner, {
  429. command: 'insert',
  430. path,
  431. insert_line: 1,
  432. new_str: '\tkept',
  433. })
  434. expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n')
  435. })
  436. it('reports missing sandbox-policy composition during plugin startup', async () => {
  437. const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-'))
  438. roots.push(root)
  439. const ctx = new Context()
  440. contexts.push(ctx)
  441. await ctx.plugin(SystemPrompt)
  442. await ctx.plugin(ToolRegistry)
  443. await ctx.plugin(AgentRegistry)
  444. await ctx.plugin(LocalFileSystem, { cwd: root })
  445. Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' })
  446. await expect(ctx.plugin(ToolStrReplaceEditor))
  447. .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing')
  448. })
  449. it('maps unexpected backend write failures for replace and insert', async () => {
  450. const { ctx, root, owner } = await setup()
  451. const path = join(root, 'backend-error.txt')
  452. await writeFile(path, 'old\n')
  453. ctx.fs.writeText = async () => {
  454. throw new Error('backend write failed')
  455. }
  456. const replace = await call(ctx, owner, {
  457. command: 'str_replace',
  458. path,
  459. old_str: 'old',
  460. new_str: 'new',
  461. })
  462. expect(replace.isError).toBe(true)
  463. expect(text(replace)).toContain('backend write failed')
  464. const insert = await call(ctx, owner, {
  465. command: 'insert',
  466. path,
  467. insert_line: 1,
  468. new_str: 'new',
  469. })
  470. expect(insert.isError).toBe(true)
  471. expect(text(insert)).toContain('backend write failed')
  472. })
  473. it('rejects invalid plugin config', () => {
  474. expect(() => {
  475. ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })
  476. }).toThrow('maxOutputChars must be a positive safe integer')
  477. expect(() => {
  478. ToolStrReplaceEditor.apply(new Context(), { description: ' ' })
  479. }).toThrow('description must be non-empty')
  480. })
  481. })