plugin.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /** The plugin records each top-level turn's file changes from real git snapshots and whole-file captures, and serves their comparisons. */
  2. import { mkdir, mkdtemp, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
  3. import { homedir, tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import type {} from '@deepseek-ai/dsh-agent'
  8. import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
  9. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  10. import * as WorkspaceChanges from '../src/index.ts'
  11. import { changes, endTurn, git, mutate, scratchDir, settle, startTurn, toolCall } from './support.ts'
  12. const cleanups: Array<() => Promise<unknown>> = []
  13. afterEach(async () => {
  14. for (const cleanup of cleanups.reverse()) await cleanup()
  15. cleanups.length = 0
  16. vi.restoreAllMocks()
  17. })
  18. const signal = new AbortController().signal
  19. async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
  20. const ctx = new Context()
  21. cleanups.push(() => ctx.fiber.dispose())
  22. await ctx.plugin(SessionStore)
  23. await ctx.plugin(LocalSubprocessRuntime)
  24. const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config)
  25. return { ctx, fiber }
  26. }
  27. /** Loose objects the repository itself holds. */
  28. function looseObjects(cwd: string): number {
  29. return Number(git(cwd, 'count-objects').split(' ')[0])
  30. }
  31. async function repository(): Promise<string> {
  32. const cwd = await scratchDir('dsh-workspace-changes-repo-', cleanups)
  33. git(cwd, 'init', '-q', '-b', 'main')
  34. await writeFile(join(cwd, 'a.txt'), 'l1\nl2\nl3\n')
  35. await writeFile(join(cwd, 'b.txt'), 'x\n')
  36. await writeFile(join(cwd, 'same.txt'), 'same\n')
  37. await writeFile(join(cwd, '.gitignore'), '.env*\n')
  38. git(cwd, 'add', '-A')
  39. git(cwd, 'commit', '-q', '-m', 'init')
  40. return cwd
  41. }
  42. /** The sequence of the latest announcement for one session. */
  43. function announcedSeq(session: Session): number {
  44. const event = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
  45. if (event === undefined) throw new Error('no workspace/changes announcement')
  46. return event.seq
  47. }
  48. describe('workspace-changes in a repository', () => {
  49. it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => {
  50. const cwd = await repository()
  51. await writeFile(join(cwd, 'b.txt'), 'x user\n')
  52. await writeFile(join(cwd, 'u.txt'), 'user untracked\n')
  53. const { ctx } = await boot()
  54. const repositoryObjects = looseObjects(cwd)
  55. const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } })
  56. startTurn(session, 1)
  57. await settle(ctx, session)
  58. await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l2', new_string: 'l2 model' },
  59. () => writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n'), { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
  60. await mkdir(join(cwd, 'sub', 'dir'), { recursive: true })
  61. await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n')
  62. await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n')
  63. await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
  64. toolCall(session, 1, 'bash', { command: 'printf > files' })
  65. // An ignored file is captured before its first edit and read again at turn end; repeated edits count once.
  66. await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, () => writeFile(join(cwd, '.env'), 'A=1\n'))
  67. await mutate(ctx, session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n'))
  68. await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve())
  69. await mutate(ctx, session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, () => Promise.resolve(), { isError: true })
  70. // A created ignored file that is gone again by turn end is not a change.
  71. await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' }, () => Promise.resolve())
  72. await mutate(ctx, session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, () => Promise.resolve())
  73. toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } })
  74. endTurn(session, 1)
  75. await settle(ctx, session)
  76. const events = session.snapshotEvents().filter(event => event.type === 'workspace/changes')
  77. expect(events.map(event => event.data)).toEqual([{ turn: 1 }])
  78. const [recorded, ...rest] = changes(ctx, session)
  79. expect(rest).toEqual([])
  80. expect(recorded).toMatchObject({ turn: 1, cwd, total: 5, added: 7, deleted: 1 })
  81. expect(recorded!.snapshot!.before).toMatch(/^[0-9a-f]{40,64}$/)
  82. expect(recorded!.snapshot!.after).toMatch(/^[0-9a-f]{40,64}$/)
  83. expect(recorded!.files).toEqual([
  84. { path: '.env', display: '.env', added: 2, deleted: 0 },
  85. { path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 },
  86. { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
  87. { path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 },
  88. { path: 'sub/dir/c.txt', display: 'sub/dir/c.txt', added: 1, deleted: 0 },
  89. ])
  90. expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([
  91. ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt',
  92. ])
  93. // Snapshot objects live in the recorder's temporary directory; the repository's own store is untouched.
  94. expect(looseObjects(cwd)).toBe(repositoryObjects)
  95. // Summaries are served by session and event sequence only while the Session lives.
  96. const seq = events[0]!.seq
  97. expect(ctx.workspaceChanges.summary(session.id, seq)).toBe(recorded)
  98. expect(ctx.workspaceChanges.summary(session.id, seq + 1)).toBeUndefined()
  99. expect(ctx.workspaceChanges.summary(SessionId('elsewhere'), seq)).toBeUndefined()
  100. ctx.emit('session/disposed', session)
  101. expect(ctx.workspaceChanges.summary(session.id, seq)).toBeUndefined()
  102. })
  103. it('serves each listed file’s comparison from the snapshots or the captured copies', async () => {
  104. const cwd = await repository()
  105. await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\n`)
  106. git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'long')
  107. const { ctx } = await boot({ maxFileBytes: 64 })
  108. const session = ctx.sessions.create(SessionId('compare'), { meta: { cwd } })
  109. startTurn(session, 1)
  110. await settle(ctx, session)
  111. await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\n')
  112. await writeFile(join(cwd, 'new.txt'), 'n1\n')
  113. await rm(join(cwd, 'b.txt'))
  114. await rename(join(cwd, 'same.txt'), join(cwd, 'moved.txt'))
  115. await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\nmore\n`)
  116. await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
  117. toolCall(session, 1, 'bash', { command: 'x' })
  118. await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\nB=2\n' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n'))
  119. endTurn(session, 1)
  120. await settle(ctx, session)
  121. const seq = announcedSeq(session)
  122. const [recorded] = changes(ctx, session)
  123. expect(recorded!.files.map(file => file.display)).toEqual(['.env', 'a.txt', 'b.txt', 'bin.dat', 'long.txt', 'moved.txt', 'new.txt'])
  124. const diff = (index: number) => ctx.workspaceChanges.diff(session.id, seq, index, signal)
  125. expect(await diff(0)).toEqual({
  126. kind: 'text', path: '.env', display: '.env', before: false, after: true, coarse: false,
  127. hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2, lines: ['+A=1', '+B=2'] }],
  128. })
  129. expect(await diff(1)).toEqual({
  130. kind: 'text', path: 'a.txt', display: 'a.txt', before: true, after: true, coarse: false,
  131. hunks: [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, lines: [' l1', '-l2', '+l2 model', ' l3'] }],
  132. })
  133. expect(await diff(2)).toMatchObject({ kind: 'text', path: 'b.txt', before: true, after: false, hunks: [{ lines: ['-x'] }] })
  134. expect(await diff(3)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' })
  135. // A snapshot blob beyond the byte cap keeps its git counts but serves no lines.
  136. expect(recorded!.files[4]).toMatchObject({ path: 'long.txt', added: 1, deleted: 0 })
  137. expect(await diff(4)).toEqual({ kind: 'oversized', path: 'long.txt', display: 'long.txt' })
  138. // A rename compares the old path's turn-start content with the new path's turn-end content.
  139. expect(await diff(5)).toEqual({ kind: 'text', path: 'moved.txt', display: 'moved.txt', before: true, after: true, coarse: false, hunks: [] })
  140. expect(await diff(6)).toMatchObject({ kind: 'text', path: 'new.txt', before: false, after: true, hunks: [{ lines: ['+n1'] }] })
  141. expect(await diff(7)).toBeUndefined()
  142. expect(await ctx.workspaceChanges.diff(session.id, seq + 1, 0, signal)).toBeUndefined()
  143. expect(await ctx.workspaceChanges.diff(SessionId('elsewhere'), seq, 0, signal)).toBeUndefined()
  144. // A caller's abort fails the read while the Session lives; disposal under a running read answers undefined.
  145. const aborted = AbortSignal.abort()
  146. await expect(ctx.workspaceChanges.diff(session.id, seq, 0, aborted)).rejects.toThrow()
  147. await expect(ctx.workspaceChanges.diff(session.id, seq, 1, aborted)).rejects.toThrow('aborted')
  148. const pending = ctx.workspaceChanges.diff(session.id, seq, 1, signal)
  149. ctx.emit('session/disposed', session)
  150. expect(await pending).toBeUndefined()
  151. expect(await diff(0)).toBeUndefined()
  152. })
  153. it('records nothing and stays quiet about captures for a working directory that no longer exists', async () => {
  154. const root = await scratchDir('dsh-workspace-changes-gone-', cleanups)
  155. const cwd = join(root, 'gone')
  156. const { ctx } = await boot()
  157. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  158. const session = ctx.sessions.create(SessionId('gone'), { meta: { cwd } })
  159. startTurn(session, 1)
  160. await settle(ctx, session)
  161. await mutate(ctx, session, 1, 'write', { file_path: 'w.txt', content: 'w\n' }, () => Promise.resolve())
  162. endTurn(session, 1)
  163. await settle(ctx, session)
  164. expect(changes(ctx, session)).toEqual([])
  165. expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toHaveLength(1)
  166. })
  167. it('places files above the working directory and outside the repository by their display rule', async () => {
  168. const root = await repository()
  169. const cwd = join(root, 'pkg')
  170. await mkdir(cwd)
  171. const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-test-'))
  172. cleanups.push(() => rm(outside, { recursive: true, force: true }))
  173. const { ctx } = await boot()
  174. const session = ctx.sessions.create(SessionId('nested'), { meta: { cwd } })
  175. startTurn(session, 1)
  176. await settle(ctx, session)
  177. await writeFile(join(root, 'a.txt'), 'changed\n')
  178. await writeFile(join(cwd, 'inner.txt'), 'inner\n')
  179. await mutate(ctx, session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' },
  180. () => writeFile(join(outside, 'note.txt'), 'one\ntwo\nthree\n'))
  181. endTurn(session, 1, 'blocked')
  182. await settle(ctx, session)
  183. const [recorded] = changes(ctx, session)
  184. expect(recorded!.files).toEqual([
  185. { path: join(await realpath(root), 'a.txt'), display: '../a.txt', added: 1, deleted: 3 },
  186. { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 },
  187. { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 },
  188. ])
  189. expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+one', '+two', '+three'] }] })
  190. })
  191. it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => {
  192. const cwd = await repository()
  193. const { ctx } = await boot()
  194. const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } })
  195. const agent = { session } as never
  196. startTurn(session, 1)
  197. await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
  198. expect(changes(ctx, session)).toEqual([])
  199. await writeFile(join(cwd, 'one.txt'), '1\n')
  200. toolCall(session, 1, 'bash', { command: 'x' })
  201. await ctx.serial('agent/turn-stopping', { agent, turn: 7, signal })
  202. await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
  203. const inTurn = session.snapshotEvents().find(event => event.type === 'workspace/changes')
  204. expect(ctx.workspaceChanges.summary(session.id, inTurn!.seq)).toMatchObject({ turn: 1, total: 1 })
  205. endTurn(session, 1)
  206. await settle(ctx, session)
  207. expect(changes(ctx, session)).toHaveLength(1)
  208. expect(session.snapshotEvents().find(event => event.type === 'turn/end')!.seq).toBeGreaterThan(inTurn!.seq)
  209. startTurn(session, 2)
  210. await settle(ctx, session)
  211. await writeFile(join(cwd, 'two.txt'), '2\n')
  212. toolCall(session, 2, 'bash', { command: 'x' })
  213. await ctx.serial('agent/turn-stopping', { agent, turn: 2, signal })
  214. await rm(join(cwd, 'two.txt'))
  215. toolCall(session, 2, 'bash', { command: 'steered' })
  216. endTurn(session, 2)
  217. await settle(ctx, session)
  218. const second = changes(ctx, session).filter(data => data.turn === 2)
  219. expect(second.map(data => data.files.map(file => file.path))).toEqual([['two.txt'], []])
  220. expect(second[1]).toMatchObject({ total: 0 })
  221. startTurn(session, 3)
  222. await settle(ctx, session)
  223. toolCall(session, 3, 'read', { file_path: 'a.txt' })
  224. endTurn(session, 3)
  225. await settle(ctx, session)
  226. expect(changes(ctx, session).filter(data => data.turn === 3)).toEqual([])
  227. })
  228. it('keeps an interrupted turn’s record when the next turn starts before it settles', async () => {
  229. const cwd = await repository()
  230. const { ctx } = await boot()
  231. const session = ctx.sessions.create(SessionId('interleaved'), { meta: { cwd } })
  232. startTurn(session, 1)
  233. await settle(ctx, session)
  234. await writeFile(join(cwd, 'one.txt'), '1\n')
  235. toolCall(session, 1, 'bash', { command: 'x' })
  236. endTurn(session, 1, 'blocked')
  237. startTurn(session, 2)
  238. await settle(ctx, session)
  239. await writeFile(join(cwd, 'two.txt'), '2\n')
  240. toolCall(session, 2, 'bash', { command: 'x' })
  241. endTurn(session, 2)
  242. await settle(ctx, session)
  243. expect(changes(ctx, session).map(data => [data.turn, data.files.map(file => file.display)])).toEqual([[1, ['one.txt']], [2, ['two.txt']]])
  244. })
  245. it('caps the file list while reporting the complete count', async () => {
  246. const cwd = await repository()
  247. const { ctx } = await boot({ maxFiles: 2 })
  248. const session = ctx.sessions.create(SessionId('capped'), { meta: { cwd } })
  249. startTurn(session, 1)
  250. await settle(ctx, session)
  251. for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`)
  252. toolCall(session, 1, 'bash', { command: 'x' })
  253. await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l1', new_string: 'l1' }, () => Promise.resolve())
  254. endTurn(session, 1)
  255. await settle(ctx, session)
  256. const [recorded] = changes(ctx, session)
  257. expect(recorded!.total).toBe(3)
  258. expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt'])
  259. expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toBeUndefined()
  260. })
  261. it('warns and skips the turn when its git work fails', async () => {
  262. const cwd = await repository()
  263. const { ctx } = await boot()
  264. vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(join(cwd, 'missing-git'))
  265. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  266. const session = ctx.sessions.create(SessionId('warn'), { meta: { cwd } })
  267. startTurn(session, 1)
  268. await settle(ctx, session)
  269. await writeFile(join(cwd, 'x.txt'), 'x\n')
  270. toolCall(session, 1, 'bash', { command: 'x' })
  271. // A repository whose snapshot failed is not summarized from captures as if it had no repository.
  272. await mutate(ctx, session, 1, 'write', { file_path: 'y.txt', content: 'y\n' }, () => writeFile(join(cwd, 'y.txt'), 'y\n'))
  273. endTurn(session, 1)
  274. await settle(ctx, session)
  275. expect(changes(ctx, session)).toEqual([])
  276. const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:'))
  277. expect(own).toHaveLength(1)
  278. expect(own[0]).toContain('missing-git')
  279. })
  280. it('leaves nested repositories out even when a file tool edits inside them', async () => {
  281. const cwd = await repository()
  282. const sub = join(cwd, 'sub')
  283. await mkdir(sub)
  284. git(sub, 'init', '-q', '-b', 'main')
  285. await writeFile(join(sub, 'inner.txt'), 'inner\n')
  286. git(sub, 'add', '-A'); git(sub, 'commit', '-q', '-m', 'inner')
  287. git(cwd, 'add', 'sub'); git(cwd, 'commit', '-q', '-m', 'gitlink')
  288. const { ctx } = await boot()
  289. const session = ctx.sessions.create(SessionId('gitlink'), { meta: { cwd } })
  290. startTurn(session, 1)
  291. await settle(ctx, session)
  292. await mutate(ctx, session, 1, 'edit', { file_path: 'sub/inner.txt', old_string: 'inner', new_string: 'inner\nedited' },
  293. () => writeFile(join(sub, 'inner.txt'), 'inner\nedited\n'))
  294. await mutate(ctx, session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, () => writeFile(join(cwd, 'top.txt'), 'top\n'))
  295. await mutate(ctx, session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, () => writeFile(join(cwd, 'same.txt'), 'same\n'))
  296. endTurn(session, 1)
  297. await settle(ctx, session)
  298. expect(changes(ctx, session).map(summary => summary.files.map(file => file.display))).toEqual([['top.txt']])
  299. })
  300. it('rejects non-positive bounds at load', async () => {
  301. const ctx = new Context()
  302. cleanups.push(() => ctx.fiber.dispose())
  303. await ctx.plugin(SessionStore)
  304. await ctx.plugin(LocalSubprocessRuntime)
  305. await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles')
  306. await expect(ctx.plugin(WorkspaceChanges, { diffTimeoutMs: 1.5 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer diffTimeoutMs')
  307. })
  308. })
  309. describe('workspace-changes without a repository', () => {
  310. it('summarizes file-tool edits only for a working directory outside any git repository', async () => {
  311. const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups)
  312. await writeFile(join(cwd, 'existing.txt'), 'before\n')
  313. await writeFile(join(cwd, 'shell.txt'), 'shell\n')
  314. const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-plain-'))
  315. cleanups.push(() => rm(outside, { recursive: true, force: true }))
  316. const { ctx } = await boot()
  317. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  318. const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
  319. startTurn(session, 1)
  320. await settle(ctx, session)
  321. await mutate(ctx, session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\n'))
  322. await mutate(ctx, session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\nagain\n'))
  323. // Shell edits and scratch files under a temporary root stay out; a file elsewhere outside the workspace counts.
  324. await writeFile(join(cwd, 'shell.txt'), 'shell\nedited\n')
  325. toolCall(session, 1, 'bash', { command: 'x' })
  326. await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve())
  327. await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' },
  328. () => writeFile(join(outside, 'note.txt'), 'one\ntwo\n'))
  329. // A file the shell edits after a file tool touched it is compared by its final content.
  330. await mutate(ctx, session, 1, 'write', { file_path: 'mixed.txt', content: 'tool\n' }, () => writeFile(join(cwd, 'mixed.txt'), 'tool\n'))
  331. await writeFile(join(cwd, 'mixed.txt'), 'tool\nshell\n')
  332. toolCall(session, 1, 'bash', { command: 'x' })
  333. endTurn(session, 1)
  334. await settle(ctx, session)
  335. expect(changes(ctx, session)).toEqual([{
  336. turn: 1, cwd, total: 3, added: 7, deleted: 1,
  337. files: [
  338. { path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 },
  339. { path: 'mixed.txt', display: 'mixed.txt', added: 2, deleted: 0 },
  340. { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 },
  341. ],
  342. }])
  343. const seq = announcedSeq(session)
  344. expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({
  345. kind: 'text', path: 'existing.txt', display: 'existing.txt', before: true, after: true, coarse: false,
  346. hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 3, lines: ['-before', '+after', '+more', '+again'] }],
  347. })
  348. expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
  349. await expect(stat(join(cwd, '.git'))).rejects.toThrow()
  350. })
  351. it('lists oversized and binary captured files without counts and serves no lines for them', async () => {
  352. const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups)
  353. // The recorder places its temporary directory under the platform temp root; point that root at a scratch directory.
  354. const tempRoot = await scratchDir('dsh-workspace-changes-temp-', cleanups)
  355. // POSIX reads TMPDIR, Windows reads TMP then TEMP.
  356. const previous = { TMPDIR: process.env.TMPDIR, TMP: process.env.TMP, TEMP: process.env.TEMP }
  357. for (const name of ['TMPDIR', 'TMP', 'TEMP'] as const) process.env[name] = tempRoot
  358. cleanups.push(async () => {
  359. for (const name of ['TMPDIR', 'TMP', 'TEMP'] as const) {
  360. if (previous[name] === undefined) Reflect.deleteProperty(process.env, name); else process.env[name] = previous[name]
  361. }
  362. })
  363. // Other tooling may create its own directories under the redirected temp root; only the recorder's count.
  364. const recorderDirs = async (): Promise<string[]> => (await readdir(tempRoot)).filter(name => name.startsWith('dsh-workspace-changes-'))
  365. await writeFile(join(cwd, 'grows.txt'), 'small\n')
  366. await writeFile(join(cwd, 'huge.txt'), 'a'.repeat(20))
  367. await writeFile(join(cwd, 'mixed.dat'), Uint8Array.of(65, 0, 66))
  368. await mkdir(join(cwd, 'already-dir'))
  369. const { ctx } = await boot({ maxFileBytes: 16 })
  370. const session = ctx.sessions.create(SessionId('bounds'), { meta: { cwd } })
  371. startTurn(session, 1)
  372. await settle(ctx, session)
  373. await mutate(ctx, session, 1, 'write', { file_path: 'grows.txt', content: 'x' }, () => writeFile(join(cwd, 'grows.txt'), 'x'.repeat(17)))
  374. // Both sides beyond the cap are never known to match, so the file is listed rather than dropped.
  375. await mutate(ctx, session, 1, 'write', { file_path: 'huge.txt', content: 'b' }, () => writeFile(join(cwd, 'huge.txt'), 'b'.repeat(20)))
  376. // An oversized side outranks a binary one in the card and the tab alike.
  377. await mutate(ctx, session, 1, 'write', { file_path: 'mixed.dat', content: 'x' }, () => writeFile(join(cwd, 'mixed.dat'), 'x'.repeat(17)))
  378. await mutate(ctx, session, 1, 'write', { file_path: 'shrinks.txt', content: 'x' }, () => writeFile(join(cwd, 'shrinks.txt'), 'x'))
  379. await mutate(ctx, session, 1, 'write', { file_path: 'bin.dat', content: 'x' }, () => writeFile(join(cwd, 'bin.dat'), Uint8Array.of(65, 0, 66)))
  380. // A directory at the path, before or after the call, is neither absent nor a file, so the path is not tracked.
  381. await mutate(ctx, session, 1, 'write', { file_path: 'dir', content: 'x' }, () => mkdir(join(cwd, 'dir')))
  382. await mutate(ctx, session, 1, 'write', { file_path: 'already-dir', content: 'x' }, () => Promise.resolve())
  383. endTurn(session, 1)
  384. await settle(ctx, session)
  385. const [recorded] = changes(ctx, session)
  386. expect(recorded!.files).toEqual([
  387. { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
  388. { path: 'grows.txt', display: 'grows.txt', added: 0, deleted: 0, oversized: true },
  389. { path: 'huge.txt', display: 'huge.txt', added: 0, deleted: 0, oversized: true },
  390. { path: 'mixed.dat', display: 'mixed.dat', added: 0, deleted: 0, oversized: true },
  391. { path: 'shrinks.txt', display: 'shrinks.txt', added: 1, deleted: 0 },
  392. ])
  393. const seq = announcedSeq(session)
  394. expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' })
  395. for (const index of [1, 2, 3]) {
  396. expect(await ctx.workspaceChanges.diff(session.id, seq, index, signal)).toMatchObject({ kind: 'oversized', path: recorded!.files[index]!.path })
  397. }
  398. expect(await ctx.workspaceChanges.diff(session.id, seq, 4, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] })
  399. // Every copy lives under the Session's temporary directory and goes with it.
  400. const [scratch, ...others] = await recorderDirs()
  401. expect(others).toEqual([])
  402. expect(scratch).toBeDefined()
  403. expect((await readdir(join(tempRoot, scratch as string, 'captures'))).length).toBeGreaterThan(0)
  404. ctx.emit('session/disposed', session)
  405. await vi.waitFor(async () => { expect(await recorderDirs()).toEqual([]) })
  406. })
  407. it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
  408. const cwd = await repository()
  409. const { ctx, fiber } = await boot()
  410. const session = ctx.sessions.create(SessionId('disposed'), { meta: { cwd } })
  411. startTurn(session, 1)
  412. await settle(ctx, session)
  413. ctx.emit('session/disposed', session)
  414. await writeFile(join(cwd, 'one.txt'), '1\n')
  415. toolCall(session, 1, 'bash', { command: 'x' })
  416. endTurn(session, 1)
  417. await settle(ctx, session)
  418. expect(changes(ctx, session)).toEqual([])
  419. startTurn(session, 2)
  420. await settle(ctx, session)
  421. await writeFile(join(cwd, 'two.txt'), '2\n')
  422. toolCall(session, 2, 'bash', { command: 'x' })
  423. endTurn(session, 2)
  424. await settle(ctx, session)
  425. expect(changes(ctx, session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']])
  426. startTurn(session, 3)
  427. await settle(ctx, session)
  428. await fiber.dispose()
  429. await writeFile(join(cwd, 'three.txt'), '3\n')
  430. toolCall(session, 3, 'bash', { command: 'x' })
  431. endTurn(session, 3)
  432. await settle(ctx, session)
  433. expect(session.snapshotEvents().filter(event => event.type === 'workspace/changes' && event.data.turn === 3)).toEqual([])
  434. })
  435. it('ignores subagent sessions and sessions without a working directory', async () => {
  436. const cwd = await repository()
  437. const { ctx } = await boot()
  438. const sessions = [
  439. ctx.sessions.create(SessionId('child'), { meta: { cwd, delegationDepth: 1 } }),
  440. ctx.sessions.create(SessionId('origin'), { meta: { cwd, origin: 'subagent' } }),
  441. ctx.sessions.create(SessionId('nowhere')),
  442. ]
  443. for (const session of sessions) {
  444. startTurn(session, 1)
  445. await settle(ctx, session)
  446. await mutate(ctx, session, 1, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n'))
  447. endTurn(session, 1)
  448. await settle(ctx, session)
  449. expect(changes(ctx, session)).toEqual([])
  450. await rm(join(cwd, 'w.txt'))
  451. }
  452. await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never))
  453. })
  454. })
  455. describe('workspace-changes without git', () => {
  456. it('summarizes file-tool edits only, even inside a repository, and reports the absence once', async () => {
  457. const cwd = await repository()
  458. const { ctx } = await boot()
  459. vi.spyOn(ctx.subprocess, 'resolveExecutable').mockRejectedValue(new Error('git: not found'))
  460. const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
  461. const session = ctx.sessions.create(SessionId('nogit'), { meta: { cwd } })
  462. for (const turn of [1, 2]) {
  463. startTurn(session, turn)
  464. await settle(ctx, session)
  465. await writeFile(join(cwd, `${turn}.txt`), 'x\n')
  466. toolCall(session, turn, 'bash', { command: 'x' })
  467. if (turn === 2) await mutate(ctx, session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n'))
  468. endTurn(session, turn)
  469. await settle(ctx, session)
  470. }
  471. expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, added: 1, deleted: 0, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }])
  472. expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 0, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+w'] }] })
  473. expect(info).toHaveBeenCalledTimes(1)
  474. expect(info.mock.calls[0]![0]).toContain('git is unavailable')
  475. })
  476. it('treats the macOS developer-tools stub as absent until a developer directory is selected', async () => {
  477. const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
  478. Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
  479. cleanups.push(async () => { Object.defineProperty(process, 'platform', platform) })
  480. const probes: Array<{ done: Promise<{ exitCode: number | null; signal: null }>; available: boolean; executable?: string }> = [
  481. { done: Promise.reject(new Error('spawn failed')), available: false },
  482. { done: Promise.resolve({ exitCode: 1, signal: null }), available: false },
  483. { done: Promise.resolve({ exitCode: 0, signal: null }), available: true },
  484. { done: Promise.resolve({ exitCode: 1, signal: null }), available: true, executable: '/opt/homebrew/bin/git' },
  485. ]
  486. for (const probe of probes) {
  487. probe.done.catch(() => undefined)
  488. const cwd = await scratchDir('dsh-workspace-changes-stub-', cleanups)
  489. const { ctx } = await boot()
  490. vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(probe.executable ?? '/usr/bin/git')
  491. const real = ctx.subprocess.spawn.bind(ctx.subprocess)
  492. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(spec =>
  493. spec.argv[0] === '/usr/bin/xcode-select' ? { done: probe.done } as never : real(spec))
  494. const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
  495. vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  496. const session = ctx.sessions.create(SessionId('stub'), { meta: { cwd } })
  497. startTurn(session, 1)
  498. await settle(ctx, session)
  499. expect(spawn.mock.calls.some(call => call[0].argv[0] === '/usr/bin/xcode-select')).toBe(probe.executable === undefined)
  500. expect(info).toHaveBeenCalledTimes(probe.available ? 0 : 1)
  501. await ctx.fiber.dispose()
  502. }
  503. })
  504. })