plugin.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /** The plugin records each top-level turn's file changes from real git snapshots. */
  2. import { mkdir, mkdtemp, realpath, 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 } 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, 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. async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
  19. const ctx = new Context()
  20. cleanups.push(() => ctx.fiber.dispose())
  21. await ctx.plugin(SessionStore)
  22. await ctx.plugin(LocalSubprocessRuntime)
  23. const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config)
  24. return { ctx, fiber }
  25. }
  26. async function repository(): Promise<string> {
  27. const cwd = await scratchDir('dsh-workspace-changes-repo-', cleanups)
  28. git(cwd, 'init', '-q', '-b', 'main')
  29. await writeFile(join(cwd, 'a.txt'), 'l1\nl2\nl3\n')
  30. await writeFile(join(cwd, 'b.txt'), 'x\n')
  31. await writeFile(join(cwd, 'same.txt'), 'same\n')
  32. await writeFile(join(cwd, '.gitignore'), '.env*\n')
  33. git(cwd, 'add', '-A')
  34. git(cwd, 'commit', '-q', '-m', 'init')
  35. return cwd
  36. }
  37. describe('workspace-changes in a repository', () => {
  38. it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => {
  39. const cwd = await repository()
  40. await writeFile(join(cwd, 'b.txt'), 'x user\n')
  41. await writeFile(join(cwd, 'u.txt'), 'user untracked\n')
  42. const { ctx } = await boot()
  43. const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } })
  44. startTurn(session, 1)
  45. await settle(ctx, session)
  46. await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n')
  47. toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
  48. await mkdir(join(cwd, 'sub', 'dir'), { recursive: true })
  49. await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n')
  50. await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n')
  51. await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
  52. toolCall(session, 1, 'bash', { command: 'printf > files' })
  53. await writeFile(join(cwd, '.env'), 'A=1\nB=2\n')
  54. toolCall(session, 1, 'write', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: null, newText: 'A=1\n' }] } })
  55. toolCall(session, 1, 'edit', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] } })
  56. toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt') }, {
  57. meta: { diffs: [{ path: join(tmpdir(), 'scratch.txt'), oldText: null, newText: 'scratch\n' }] },
  58. })
  59. toolCall(session, 1, 'write', { file_path: 'ignored-error' }, { isError: true, meta: { diffs: [{ path: 'failed.txt', oldText: null, newText: 'x' }] } })
  60. toolCall(session, 1, 'write', { file_path: '.env.gone' }, { meta: { diffs: [{ path: '.env.gone', oldText: null, newText: 'x' }] } })
  61. toolCall(session, 1, 'write', { file_path: 'same.txt' }, { meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] } })
  62. toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } })
  63. endTurn(session, 1)
  64. await settle(ctx, session)
  65. const [recorded, ...rest] = changes(session)
  66. expect(rest).toEqual([])
  67. expect(recorded).toMatchObject({ turn: 1, total: 6 })
  68. expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]{40,64}$/)
  69. expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]{40,64}$/)
  70. expect(recorded!.files).toEqual([
  71. { path: '.env', display: '.env', added: 2, deleted: 0 },
  72. { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 },
  73. { path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 },
  74. { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
  75. { path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 },
  76. { path: 'sub/dir/c.txt', display: 'sub/dir/c.txt', added: 1, deleted: 0 },
  77. ])
  78. expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([
  79. ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt',
  80. ])
  81. })
  82. it('places files above the working directory and outside the repository by their display rule', async () => {
  83. const root = await repository()
  84. const cwd = join(root, 'pkg')
  85. await mkdir(cwd)
  86. const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-test-'))
  87. cleanups.push(() => rm(outside, { recursive: true, force: true }))
  88. const { ctx } = await boot()
  89. const session = ctx.sessions.create(SessionId('nested'), { meta: { cwd } })
  90. startTurn(session, 1)
  91. await settle(ctx, session)
  92. await writeFile(join(root, 'a.txt'), 'changed\n')
  93. await writeFile(join(cwd, 'inner.txt'), 'inner\n')
  94. toolCall(session, 1, 'write', { file_path: join(outside, 'note.txt') }, {
  95. meta: { diffs: [{ path: join(outside, 'note.txt'), oldText: null, newText: 'one\ntwo\nthree\n' }] },
  96. })
  97. endTurn(session, 1, 'blocked')
  98. await settle(ctx, session)
  99. const [recorded] = changes(session)
  100. expect(recorded!.files).toEqual([
  101. { path: join(await realpath(root), 'a.txt'), display: '../a.txt', added: 1, deleted: 3 },
  102. { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 },
  103. { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 },
  104. ])
  105. })
  106. it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => {
  107. const cwd = await repository()
  108. const { ctx } = await boot()
  109. const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } })
  110. const agent = { session } as never
  111. const signal = new AbortController().signal
  112. startTurn(session, 1)
  113. await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
  114. expect(changes(session)).toEqual([])
  115. await writeFile(join(cwd, 'one.txt'), '1\n')
  116. toolCall(session, 1, 'bash', { command: 'x' })
  117. await ctx.serial('agent/turn-stopping', { agent, turn: 7, signal })
  118. await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
  119. const inTurn = session.snapshotEvents().find(event => event.type === 'workspace/changes')
  120. expect(inTurn?.data).toMatchObject({ turn: 1, total: 1 })
  121. endTurn(session, 1)
  122. await settle(ctx, session)
  123. expect(changes(session)).toHaveLength(1)
  124. expect(session.snapshotEvents().find(event => event.type === 'turn/end')!.seq).toBeGreaterThan(inTurn!.seq)
  125. startTurn(session, 2)
  126. await settle(ctx, session)
  127. await writeFile(join(cwd, 'two.txt'), '2\n')
  128. toolCall(session, 2, 'bash', { command: 'x' })
  129. await ctx.serial('agent/turn-stopping', { agent, turn: 2, signal })
  130. await rm(join(cwd, 'two.txt'))
  131. toolCall(session, 2, 'bash', { command: 'steered' })
  132. endTurn(session, 2)
  133. await settle(ctx, session)
  134. const second = changes(session).filter(data => data.turn === 2)
  135. expect(second.map(data => data.files.map(file => file.path))).toEqual([['two.txt'], []])
  136. expect(second[1]).toMatchObject({ total: 0 })
  137. startTurn(session, 3)
  138. await settle(ctx, session)
  139. toolCall(session, 3, 'read', { file_path: 'a.txt' })
  140. endTurn(session, 3)
  141. await settle(ctx, session)
  142. expect(changes(session).filter(data => data.turn === 3)).toEqual([])
  143. })
  144. it('caps the file list while reporting the complete count', async () => {
  145. const cwd = await repository()
  146. const { ctx } = await boot({ maxFiles: 2 })
  147. const session = ctx.sessions.create(SessionId('capped'), { meta: { cwd } })
  148. startTurn(session, 1)
  149. await settle(ctx, session)
  150. for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`)
  151. toolCall(session, 1, 'bash', { command: 'x' })
  152. toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } })
  153. endTurn(session, 1)
  154. await settle(ctx, session)
  155. const [recorded] = changes(session)
  156. expect(recorded!.total).toBe(3)
  157. expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt'])
  158. })
  159. it('warns and skips the turn when its git work fails', async () => {
  160. const cwd = await repository()
  161. const { ctx } = await boot()
  162. vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(join(cwd, 'missing-git'))
  163. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  164. const session = ctx.sessions.create(SessionId('warn'), { meta: { cwd } })
  165. startTurn(session, 1)
  166. await settle(ctx, session)
  167. await writeFile(join(cwd, 'x.txt'), 'x\n')
  168. toolCall(session, 1, 'bash', { command: 'x' })
  169. endTurn(session, 1)
  170. await settle(ctx, session)
  171. expect(changes(session)).toEqual([])
  172. const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:'))
  173. expect(own).toHaveLength(1)
  174. expect(own[0]).toContain('missing-git')
  175. })
  176. it('rejects non-positive bounds at load', async () => {
  177. const ctx = new Context()
  178. cleanups.push(() => ctx.fiber.dispose())
  179. await ctx.plugin(SessionStore)
  180. await ctx.plugin(LocalSubprocessRuntime)
  181. await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles')
  182. })
  183. })
  184. describe('workspace-changes without a repository', () => {
  185. it('records nothing for a working directory outside any git repository', async () => {
  186. const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups)
  187. await writeFile(join(cwd, 'existing.txt'), 'before\n')
  188. const { ctx } = await boot()
  189. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  190. const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
  191. startTurn(session, 1)
  192. await settle(ctx, session)
  193. await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')
  194. toolCall(session, 1, 'write', { file_path: 'existing.txt' }, {
  195. meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] },
  196. })
  197. endTurn(session, 1)
  198. await settle(ctx, session)
  199. expect(changes(session)).toEqual([])
  200. expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
  201. await expect(stat(join(cwd, '.git'))).rejects.toThrow()
  202. })
  203. it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
  204. const cwd = await repository()
  205. const { ctx, fiber } = await boot()
  206. const session = ctx.sessions.create(SessionId('disposed'), { meta: { cwd } })
  207. startTurn(session, 1)
  208. await settle(ctx, session)
  209. ctx.emit('session/disposed', session)
  210. await writeFile(join(cwd, 'one.txt'), '1\n')
  211. toolCall(session, 1, 'bash', { command: 'x' })
  212. endTurn(session, 1)
  213. await settle(ctx, session)
  214. expect(changes(session)).toEqual([])
  215. startTurn(session, 2)
  216. await settle(ctx, session)
  217. await writeFile(join(cwd, 'two.txt'), '2\n')
  218. toolCall(session, 2, 'bash', { command: 'x' })
  219. endTurn(session, 2)
  220. await settle(ctx, session)
  221. expect(changes(session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']])
  222. startTurn(session, 3)
  223. await settle(ctx, session)
  224. await fiber.dispose()
  225. await writeFile(join(cwd, 'three.txt'), '3\n')
  226. toolCall(session, 3, 'bash', { command: 'x' })
  227. endTurn(session, 3)
  228. await settle(ctx, session)
  229. expect(changes(session).filter(data => data.turn === 3)).toEqual([])
  230. })
  231. it('ignores subagent sessions and sessions without a working directory', async () => {
  232. const cwd = await repository()
  233. const { ctx } = await boot()
  234. const sessions = [
  235. ctx.sessions.create(SessionId('child'), { meta: { cwd, delegationDepth: 1 } }),
  236. ctx.sessions.create(SessionId('origin'), { meta: { cwd, origin: 'subagent' } }),
  237. ctx.sessions.create(SessionId('nowhere')),
  238. ]
  239. for (const session of sessions) {
  240. startTurn(session, 1)
  241. await settle(ctx, session)
  242. toolCall(session, 1, 'bash', { command: 'x' })
  243. endTurn(session, 1)
  244. await settle(ctx, session)
  245. expect(changes(session)).toEqual([])
  246. }
  247. await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never))
  248. })
  249. })
  250. describe('workspace-changes without git', () => {
  251. it('records nothing and reports the absence once', async () => {
  252. const cwd = await scratchDir('dsh-workspace-changes-nogit-', cleanups)
  253. const { ctx } = await boot()
  254. vi.spyOn(ctx.subprocess, 'resolveExecutable').mockRejectedValue(new Error('git: not found'))
  255. const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
  256. const session = ctx.sessions.create(SessionId('nogit'), { meta: { cwd } })
  257. for (const turn of [1, 2]) {
  258. startTurn(session, turn)
  259. await settle(ctx, session)
  260. await writeFile(join(cwd, `${turn}.txt`), 'x\n')
  261. toolCall(session, turn, 'bash', { command: 'x' })
  262. endTurn(session, turn)
  263. await settle(ctx, session)
  264. }
  265. expect(changes(session)).toEqual([])
  266. expect(info).toHaveBeenCalledTimes(1)
  267. expect(info.mock.calls[0]![0]).toContain('git is unavailable')
  268. })
  269. it('treats the macOS developer-tools stub as absent until a developer directory is selected', async () => {
  270. const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
  271. Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
  272. cleanups.push(async () => { Object.defineProperty(process, 'platform', platform) })
  273. const probes: Array<{ done: Promise<{ exitCode: number | null; signal: null }>; available: boolean; executable?: string }> = [
  274. { done: Promise.reject(new Error('spawn failed')), available: false },
  275. { done: Promise.resolve({ exitCode: 1, signal: null }), available: false },
  276. { done: Promise.resolve({ exitCode: 0, signal: null }), available: true },
  277. { done: Promise.resolve({ exitCode: 1, signal: null }), available: true, executable: '/opt/homebrew/bin/git' },
  278. ]
  279. for (const probe of probes) {
  280. probe.done.catch(() => undefined)
  281. const cwd = await scratchDir('dsh-workspace-changes-stub-', cleanups)
  282. const { ctx } = await boot()
  283. vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(probe.executable ?? '/usr/bin/git')
  284. const real = ctx.subprocess.spawn.bind(ctx.subprocess)
  285. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(spec =>
  286. spec.argv[0] === '/usr/bin/xcode-select' ? { done: probe.done } as never : real(spec))
  287. const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
  288. vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  289. const session = ctx.sessions.create(SessionId('stub'), { meta: { cwd } })
  290. startTurn(session, 1)
  291. await settle(ctx, session)
  292. expect(spawn.mock.calls.some(call => call[0].argv[0] === '/usr/bin/xcode-select')).toBe(probe.executable === undefined)
  293. expect(info).toHaveBeenCalledTimes(probe.available ? 0 : 1)
  294. await ctx.fiber.dispose()
  295. }
  296. })
  297. })