tools.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. /**
  2. * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
  3. * REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy
  4. * collaborator, per the prefer-the-real-implementation rule) over a fake
  5. * `ctx.fs` provider, so they verify schemas, argument validation, result
  6. * formatting, FsError→isError propagation, and that each tool dispatches the
  7. * `fs/*` waterfalls + records observed-state through the gate (read authorizes a
  8. * later edit) — not just that it moved bytes.
  9. */
  10. import { describe, expect, it, vi } from 'vitest'
  11. import { Context } from 'cordis'
  12. import { CallId } from '@deepseek-ai/dsh-llm'
  13. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  14. import ToolRegistry from '@deepseek-ai/dsh-tools'
  15. import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
  16. import type {
  17. FsDirEntry,
  18. FsEditOutcome,
  19. FsEditRequest,
  20. FsInfo,
  21. FsTarget,
  22. FsWriteIntent,
  23. FsWriteOutcome,
  24. } from '@deepseek-ai/dsh-fs'
  25. import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
  26. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  27. import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
  28. import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
  29. /** An in-memory fake provider; a test can arm a rejection on any primitive. */
  30. class FakeFs extends FileSystem {
  31. files = new Map<string, string>()
  32. rejectWith?: FsError
  33. writeIntents: (FsWriteIntent | undefined)[] = []
  34. editIntents: ({ version: FsVersion } | undefined)[] = []
  35. private throwIfArmed(): void {
  36. if (this.rejectWith) throw this.rejectWith
  37. }
  38. override async resolve(path: string): Promise<FsTarget> {
  39. return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
  40. }
  41. override async stat(target: FsTarget): Promise<FsInfo | undefined> {
  42. this.throwIfArmed()
  43. const content = this.files.get(target.targetKey)
  44. if (content === undefined) return undefined
  45. return { version: FsVersion('v1'), type: 'file', size: content.length }
  46. }
  47. override async readText(target: FsTarget): Promise<string> {
  48. return this.files.get(target.targetKey) ?? ''
  49. }
  50. override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
  51. const content = this.files.get(target.targetKey) ?? ''
  52. return (async function* () { yield content })()
  53. }
  54. override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
  55. return []
  56. }
  57. override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
  58. this.throwIfArmed()
  59. this.writeIntents.push(expected)
  60. const before = this.files.get(target.targetKey) ?? null
  61. this.files.set(target.targetKey, content)
  62. return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
  63. }
  64. override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
  65. this.throwIfArmed()
  66. this.editIntents.push(expected)
  67. const content = this.files.get(target.targetKey) ?? ''
  68. const after = content.split(edit.oldString).join(edit.newString)
  69. this.files.set(target.targetKey, after)
  70. return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after }
  71. }
  72. }
  73. async function setup() {
  74. const ctx = new Context()
  75. await ctx.plugin(SystemPrompt)
  76. await ctx.plugin(ToolRegistry)
  77. await ctx.plugin(FakeFs)
  78. await ctx.plugin(FsPolicy)
  79. await ctx.plugin(ToolFs)
  80. const fs = ctx.fs as FakeFs
  81. return { ctx, fs }
  82. }
  83. let callCounter = 0
  84. function call(ctx: Context, name: string, args: unknown, agent?: object) {
  85. return ctx.tools.execute({
  86. callId: CallId(`call-${++callCounter}`),
  87. name,
  88. arguments: args,
  89. ...agent ? { agent: agent as never } : {},
  90. })
  91. }
  92. function text(result: { content: { type: string; text?: string }[] }): string {
  93. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  94. }
  95. describe('registration', () => {
  96. it('registers read, write, and edit', async () => {
  97. const { ctx } = await setup()
  98. expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
  99. })
  100. it('registers prompt sections for each tool', async () => {
  101. const { ctx } = await setup()
  102. const prompt = renderPrompt(await ctx.systemPrompt.assemble())
  103. expect(prompt).toContain('Use the read tool')
  104. expect(prompt).toContain('Use the write tool')
  105. expect(prompt).toContain('Use the edit tool')
  106. })
  107. it('stays pending until ctx.fs exists (inject)', async () => {
  108. const ctx = new Context()
  109. await ctx.plugin(SystemPrompt)
  110. await ctx.plugin(ToolRegistry)
  111. await ctx.plugin(ToolFs) // no fs provider
  112. expect(ctx.tools.schemas()).toHaveLength(0)
  113. })
  114. it('unregisters everything on fiber disposal (HMR safety)', async () => {
  115. const ctx = new Context()
  116. await ctx.plugin(SystemPrompt)
  117. await ctx.plugin(ToolRegistry)
  118. await ctx.plugin(FakeFs)
  119. await ctx.plugin(FsPolicy)
  120. const fiber = await ctx.plugin(ToolFs)
  121. // Each tool contributes BOTH a schema and a prompt section; disposal must
  122. // withdraw both, not just the schemas.
  123. expect(ctx.tools.schemas()).toHaveLength(3)
  124. const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
  125. expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write'])
  126. await fiber.dispose()
  127. expect(ctx.tools.schemas()).toHaveLength(0)
  128. expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
  129. })
  130. })
  131. describe('read tool', () => {
  132. it('formats line-numbered content with a footer', async () => {
  133. const { ctx, fs } = await setup()
  134. fs.files.set('key:a.txt', 'hello\nworld')
  135. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  136. expect(result.isError).toBe(false)
  137. expect(text(result)).toBe(`<path>/abs/a.txt</path>
  138. <type>file</type>
  139. <content>
  140. 1: hello
  141. 2: world
  142. (End of file - total 2 lines)
  143. </content>`)
  144. })
  145. it('rejects a non-positive offset via arg validation', async () => {
  146. const { ctx } = await setup()
  147. const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
  148. expect(result.isError).toBe(true)
  149. expect(text(result)).toContain('offset must be a positive integer')
  150. })
  151. it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
  152. const { ctx } = await setup()
  153. for (const args of [
  154. { file_path: 'a.txt', offset: 1.5 },
  155. { file_path: 'a.txt', offset: Number.NaN },
  156. { file_path: 'a.txt', limit: 0 },
  157. { file_path: 'a.txt', limit: -3 },
  158. ]) {
  159. const result = await call(ctx, 'read', args)
  160. expect(result.isError, JSON.stringify(args)).toBe(true)
  161. expect(text(result)).toMatch(/must be a positive integer/)
  162. }
  163. })
  164. it('rejects a limit above the cap', async () => {
  165. const { ctx } = await setup()
  166. const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
  167. expect(result.isError).toBe(true)
  168. expect(text(result)).toContain('less than or equal to 2000')
  169. })
  170. it('rejects a blank file_path', async () => {
  171. const { ctx } = await setup()
  172. const result = await call(ctx, 'read', { file_path: ' ' })
  173. expect(result.isError).toBe(true)
  174. expect(text(result)).toContain('file_path must be a non-empty string')
  175. })
  176. it('records observed state so a follow-up edit by the same session is authorized', async () => {
  177. const { ctx, fs } = await setup()
  178. const session = { header: {} }
  179. fs.files.set('key:a.txt', 'hello')
  180. expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
  181. const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
  182. expect(edited.isError).toBe(false)
  183. expect(fs.editIntents).toEqual([{ version: 'v1' }])
  184. })
  185. it('propagates FS_NOT_FOUND for an absent file', async () => {
  186. const { ctx } = await setup()
  187. const result = await call(ctx, 'read', { file_path: 'missing.txt' })
  188. expect(result.isError).toBe(true)
  189. expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
  190. })
  191. it('rejects a non-regular target', async () => {
  192. const { ctx, fs } = await setup()
  193. fs.files.set('key:d', '')
  194. fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
  195. const result = await call(ctx, 'read', { file_path: 'd' })
  196. expect(result.isError).toBe(true)
  197. expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  198. })
  199. it('streams a large file (size at/above the cap) instead of reading whole', async () => {
  200. const { ctx, fs } = await setup()
  201. fs.files.set('key:big.txt', 'alpha\nbeta')
  202. const readSpy = vi.spyOn(fs, 'readText')
  203. const streamSpy = vi.spyOn(fs, 'streamText')
  204. fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE })
  205. const result = await call(ctx, 'read', { file_path: 'big.txt' })
  206. expect(result.isError).toBe(false)
  207. expect(text(result)).toContain('1: alpha')
  208. expect(streamSpy).toHaveBeenCalled()
  209. expect(readSpy).not.toHaveBeenCalled()
  210. })
  211. it('streams when the backend reports no size (never buffers a size-less file)', async () => {
  212. const { ctx, fs } = await setup()
  213. fs.files.set('key:a.txt', 'alpha')
  214. const streamSpy = vi.spyOn(fs, 'streamText')
  215. fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size
  216. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  217. expect(result.isError).toBe(false)
  218. expect(streamSpy).toHaveBeenCalled()
  219. })
  220. it('surfaces a byte-capped read as a truncated footer', async () => {
  221. const { ctx, fs } = await setup()
  222. // Many long lines so the window hits the byte cap before EOF.
  223. fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
  224. const result = await call(ctx, 'read', { file_path: 'big.txt' })
  225. expect(result.isError).toBe(false)
  226. expect(text(result)).toContain('Output capped.')
  227. })
  228. })
  229. describe('formatReadOutput footer variants', () => {
  230. const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
  231. it('reports a byte-capped read', () => {
  232. const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
  233. expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
  234. })
  235. it('reports a more-remaining page', () => {
  236. const out = formatReadOutput('/f', { ...base, totalLines: 99 })
  237. expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
  238. })
  239. it('reports end-of-file', () => {
  240. expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
  241. })
  242. it('renders an empty file as just the footer', () => {
  243. const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
  244. expect(out).toContain('(End of file - total 0 lines)')
  245. expect(out).not.toContain(': ')
  246. })
  247. })
  248. describe('write tool', () => {
  249. it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
  250. const { ctx, fs } = await setup()
  251. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
  252. expect(result.isError).toBe(false)
  253. expect(text(result)).toContain('Created file')
  254. expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
  255. })
  256. it('rejects a blank file_path', async () => {
  257. const { ctx } = await setup()
  258. const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
  259. expect(result.isError).toBe(true)
  260. expect(text(result)).toContain('file_path must be a non-empty string')
  261. })
  262. it('propagates a backend FsError as an isError result carrying its code', async () => {
  263. const { ctx, fs } = await setup()
  264. fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
  265. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
  266. expect(result.isError).toBe(true)
  267. expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
  268. })
  269. })
  270. describe('edit tool', () => {
  271. it('formats a single-replacement success after a read', async () => {
  272. const { ctx, fs } = await setup()
  273. const session = { header: {} }
  274. fs.files.set('key:a.txt', 'a')
  275. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  276. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
  277. expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
  278. })
  279. it('formats the replace_all success message distinctly', async () => {
  280. const { ctx, fs } = await setup()
  281. const session = { header: {} }
  282. fs.files.set('key:a.txt', 'a a a')
  283. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  284. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session })
  285. expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.')
  286. })
  287. it('rejects identical old/new strings', async () => {
  288. const { ctx } = await setup()
  289. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
  290. expect(result.isError).toBe(true)
  291. expect(text(result)).toContain('must differ')
  292. })
  293. it('rejects an empty old_string', async () => {
  294. const { ctx } = await setup()
  295. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
  296. expect(result.isError).toBe(true)
  297. expect(text(result)).toContain('old_string must be a non-empty string')
  298. })
  299. it('rejects a blank file_path', async () => {
  300. const { ctx } = await setup()
  301. const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
  302. expect(result.isError).toBe(true)
  303. expect(text(result)).toContain('file_path must be a non-empty string')
  304. })
  305. it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
  306. const { ctx, fs } = await setup()
  307. fs.files.set('key:a.txt', 'hello')
  308. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
  309. expect(result.isError).toBe(true)
  310. expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
  311. })
  312. })
  313. describe('tool-owned presentation (pure presentCall)', () => {
  314. // presentCall is a pure display function of args (no I/O); it drives the ACP
  315. // card's title/kind and the `locations` an editor follows along to.
  316. const presentCall = async (name: string, args: unknown) => {
  317. const { ctx } = await setup()
  318. return ctx.tools.get(name)?.presentCall?.(args)
  319. }
  320. it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
  321. expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
  322. card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
  323. locations: [{ path: 'src/a.ts', line: 12 }],
  324. })
  325. })
  326. it('read: bare title and line-1 location when offset/limit are unset', async () => {
  327. expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
  328. card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
  329. })
  330. })
  331. it('read: "from line N" window when only offset is set', async () => {
  332. expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
  333. card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],
  334. })
  335. })
  336. it('write: diff card (new-file style, oldText null), location', async () => {
  337. expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({
  338. card: 'diff', title: 'Write out.txt',
  339. diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }],
  340. locations: [{ path: 'out.txt' }],
  341. })
  342. })
  343. it('read: a limit with no offset windows from line 1', async () => {
  344. expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({
  345. card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
  346. })
  347. })
  348. it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => {
  349. // presentCall runs on replay of raw logged args, which parseEditArgs does not
  350. // gate — an empty old_string must still produce a valid diff (oldText null).
  351. expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({
  352. card: 'diff', title: 'Edit a.txt',
  353. diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }],
  354. locations: [{ path: 'a.txt' }],
  355. })
  356. })
  357. })
  358. describe('result-time contextual diff (meta + presentResult)', () => {
  359. // An edit records the applied contextual hunk on `tool/result` meta, and the
  360. // tool's presentResult narrows it back into a `diff` result card the bridge
  361. // renders. Drive execute end-to-end so the meta is the REAL computed hunk.
  362. const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
  363. it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
  364. const { ctx, fs } = await setup()
  365. const session = { header: {} }
  366. fs.files.set('key:a.txt', withContext)
  367. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  368. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
  369. expect(result.isError).toBe(false)
  370. expect(result.meta).toEqual({
  371. diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
  372. })
  373. })
  374. it('edit: presentResult turns the meta into a diff result card', async () => {
  375. const { ctx, fs } = await setup()
  376. const session = { header: {} }
  377. fs.files.set('key:a.txt', withContext)
  378. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  379. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
  380. const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result)
  381. expect(view).toEqual({
  382. card: 'diff', title: 'Edit a.txt',
  383. diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
  384. })
  385. })
  386. it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => {
  387. const { ctx, fs } = await setup()
  388. const session = { header: {} }
  389. fs.files.set('key:a.txt', withContext)
  390. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  391. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session })
  392. expect(result.isError).toBe(false)
  393. expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
  394. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result)
  395. expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
  396. })
  397. it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => {
  398. // A create has no prior content (no `meta`), yet the completed card must be a
  399. // `diff` — an ACP tool_call_update.content REPLACES the call's content, so a
  400. // non-diff result would clobber the pending new-file diff. The whole-file diff
  401. // is derived from the args (oldText:null), replay-safe.
  402. const { ctx } = await setup()
  403. const session = { header: {} }
  404. const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
  405. expect(result.isError).toBe(false)
  406. expect(result.meta).toBeUndefined()
  407. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
  408. expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
  409. })
  410. it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => {
  411. const { ctx, fs } = await setup()
  412. const session = { header: {} }
  413. fs.files.set('key:a.txt', 'same\n')
  414. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  415. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
  416. expect(result.isError).toBe(false)
  417. expect(result.meta).toBeUndefined()
  418. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
  419. expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
  420. })
  421. it('presentResult returns undefined on an error result (nothing applied)', async () => {
  422. const { ctx } = await setup()
  423. const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true }
  424. expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined()
  425. expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined()
  426. })
  427. it('edit presentResult returns undefined on malformed meta (defensive narrowing)', async () => {
  428. // edit has no whole-file fallback (only a literal replacement), so a malformed
  429. // meta yields the generic "updated successfully" rendering.
  430. const { ctx } = await setup()
  431. const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
  432. expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined()
  433. })
  434. it('write presentResult falls back to a whole-file diff on malformed meta (never leaks the result text)', async () => {
  435. // write always renders a diff card so the completed update can't clobber the
  436. // pending diff with the model-facing text; a malformed meta falls back to the
  437. // args-derived whole-file diff, same as a create.
  438. const { ctx } = await setup()
  439. const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
  440. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)
  441. expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] })
  442. })
  443. })