tools.spec.ts 26 KB

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