1
0

tools.spec.ts 24 KB

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