tools.spec.ts 24 KB

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