tools.spec.ts 22 KB

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