|
|
@@ -1,14 +1,14 @@
|
|
|
-/** The plugin records each top-level turn's file changes from real git snapshots. */
|
|
|
-import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
|
|
+/** The plugin records each top-level turn's file changes from real git snapshots and whole-file captures, and serves their comparisons. */
|
|
|
+import { mkdir, mkdtemp, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
|
import { homedir, tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
|
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
+import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
|
|
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
|
|
import * as WorkspaceChanges from '../src/index.ts'
|
|
|
-import { changes, endTurn, git, scratchDir, settle, startTurn, toolCall } from './support.ts'
|
|
|
+import { changes, endTurn, git, mutate, scratchDir, settle, startTurn, toolCall } from './support.ts'
|
|
|
|
|
|
const cleanups: Array<() => Promise<unknown>> = []
|
|
|
afterEach(async () => {
|
|
|
@@ -17,6 +17,8 @@ afterEach(async () => {
|
|
|
vi.restoreAllMocks()
|
|
|
})
|
|
|
|
|
|
+const signal = new AbortController().signal
|
|
|
+
|
|
|
async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
|
|
|
const ctx = new Context()
|
|
|
cleanups.push(() => ctx.fiber.dispose())
|
|
|
@@ -43,6 +45,13 @@ async function repository(): Promise<string> {
|
|
|
return cwd
|
|
|
}
|
|
|
|
|
|
+/** The sequence of the latest announcement for one session. */
|
|
|
+function announcedSeq(session: Session): number {
|
|
|
+ const event = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
|
|
|
+ if (event === undefined) throw new Error('no workspace/changes announcement')
|
|
|
+ return event.seq
|
|
|
+}
|
|
|
+
|
|
|
describe('workspace-changes in a repository', () => {
|
|
|
it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => {
|
|
|
const cwd = await repository()
|
|
|
@@ -54,26 +63,21 @@ describe('workspace-changes in a repository', () => {
|
|
|
startTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
|
|
|
- await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n')
|
|
|
- toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
|
|
|
+ await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l2', new_string: 'l2 model' },
|
|
|
+ () => writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n'), { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
|
|
|
await mkdir(join(cwd, 'sub', 'dir'), { recursive: true })
|
|
|
await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n')
|
|
|
await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n')
|
|
|
await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
|
|
|
toolCall(session, 1, 'bash', { command: 'printf > files' })
|
|
|
- await writeFile(join(cwd, '.env'), 'A=1\nB=2\n')
|
|
|
- // A created file persists an empty hunk list; its arguments supply the content.
|
|
|
- toolCall(session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, { meta: { diffs: [] } })
|
|
|
- toolCall(session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, {
|
|
|
- meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] },
|
|
|
- })
|
|
|
- toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } })
|
|
|
- toolCall(session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, { isError: true, meta: { diffs: [] } })
|
|
|
- // The editor persists no hunks at all; its create arguments count, and the file may already be gone.
|
|
|
- toolCall(session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' })
|
|
|
- toolCall(session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, {
|
|
|
- meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] },
|
|
|
- })
|
|
|
+ // An ignored file is captured before its first edit and read again at turn end; repeated edits count once.
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, () => writeFile(join(cwd, '.env'), 'A=1\n'))
|
|
|
+ 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'))
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve())
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, () => Promise.resolve(), { isError: true })
|
|
|
+ // A created ignored file that is gone again by turn end is not a change.
|
|
|
+ await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' }, () => Promise.resolve())
|
|
|
+ await mutate(ctx, session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, () => Promise.resolve())
|
|
|
toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } })
|
|
|
endTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
@@ -82,12 +86,11 @@ describe('workspace-changes in a repository', () => {
|
|
|
expect(events.map(event => event.data)).toEqual([{ turn: 1 }])
|
|
|
const [recorded, ...rest] = changes(ctx, session)
|
|
|
expect(rest).toEqual([])
|
|
|
- expect(recorded).toMatchObject({ turn: 1, cwd, total: 6, added: 8, deleted: 1 })
|
|
|
+ expect(recorded).toMatchObject({ turn: 1, cwd, total: 5, added: 7, deleted: 1 })
|
|
|
expect(recorded!.snapshot!.before).toMatch(/^[0-9a-f]{40,64}$/)
|
|
|
expect(recorded!.snapshot!.after).toMatch(/^[0-9a-f]{40,64}$/)
|
|
|
expect(recorded!.files).toEqual([
|
|
|
{ path: '.env', display: '.env', added: 2, deleted: 0 },
|
|
|
- { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 },
|
|
|
{ path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 },
|
|
|
{ path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
|
|
|
{ path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 },
|
|
|
@@ -107,6 +110,51 @@ describe('workspace-changes in a repository', () => {
|
|
|
expect(ctx.workspaceChanges.summary(session.id, seq)).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
+ it('serves each listed file’s comparison from the snapshots or the captured copies', async () => {
|
|
|
+ const cwd = await repository()
|
|
|
+ await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\n`)
|
|
|
+ git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'long')
|
|
|
+ const { ctx } = await boot({ maxFileBytes: 64 })
|
|
|
+ const session = ctx.sessions.create(SessionId('compare'), { meta: { cwd } })
|
|
|
+ startTurn(session, 1)
|
|
|
+ await settle(ctx, session)
|
|
|
+ await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\n')
|
|
|
+ await writeFile(join(cwd, 'new.txt'), 'n1\n')
|
|
|
+ await rm(join(cwd, 'b.txt'))
|
|
|
+ await rename(join(cwd, 'same.txt'), join(cwd, 'moved.txt'))
|
|
|
+ await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\nmore\n`)
|
|
|
+ await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
|
|
|
+ toolCall(session, 1, 'bash', { command: 'x' })
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\nB=2\n' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n'))
|
|
|
+ endTurn(session, 1)
|
|
|
+ await settle(ctx, session)
|
|
|
+ const seq = announcedSeq(session)
|
|
|
+ const [recorded] = changes(ctx, session)
|
|
|
+ expect(recorded!.files.map(file => file.display)).toEqual(['.env', 'a.txt', 'b.txt', 'bin.dat', 'long.txt', 'moved.txt', 'new.txt'])
|
|
|
+ const diff = (index: number) => ctx.workspaceChanges.diff(session.id, seq, index, signal)
|
|
|
+ expect(await diff(0)).toEqual({
|
|
|
+ kind: 'text', path: '.env', display: '.env', before: false, after: true, coarse: false,
|
|
|
+ hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2, lines: ['+A=1', '+B=2'] }],
|
|
|
+ })
|
|
|
+ expect(await diff(1)).toEqual({
|
|
|
+ kind: 'text', path: 'a.txt', display: 'a.txt', before: true, after: true, coarse: false,
|
|
|
+ hunks: [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, lines: [' l1', '-l2', '+l2 model', ' l3'] }],
|
|
|
+ })
|
|
|
+ expect(await diff(2)).toMatchObject({ kind: 'text', path: 'b.txt', before: true, after: false, hunks: [{ lines: ['-x'] }] })
|
|
|
+ expect(await diff(3)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' })
|
|
|
+ // A snapshot blob beyond the byte cap keeps its git counts but serves no lines.
|
|
|
+ expect(recorded!.files[4]).toMatchObject({ path: 'long.txt', added: 1, deleted: 0 })
|
|
|
+ expect(await diff(4)).toEqual({ kind: 'oversized', path: 'long.txt', display: 'long.txt' })
|
|
|
+ // A rename compares the old path's turn-start content with the new path's turn-end content.
|
|
|
+ expect(await diff(5)).toEqual({ kind: 'text', path: 'moved.txt', display: 'moved.txt', before: true, after: true, coarse: false, hunks: [] })
|
|
|
+ expect(await diff(6)).toMatchObject({ kind: 'text', path: 'new.txt', before: false, after: true, hunks: [{ lines: ['+n1'] }] })
|
|
|
+ expect(await diff(7)).toBeUndefined()
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, seq + 1, 0, signal)).toBeUndefined()
|
|
|
+ expect(await ctx.workspaceChanges.diff(SessionId('elsewhere'), seq, 0, signal)).toBeUndefined()
|
|
|
+ ctx.emit('session/disposed', session)
|
|
|
+ expect(await diff(0)).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
it('places files above the working directory and outside the repository by their display rule', async () => {
|
|
|
const root = await repository()
|
|
|
const cwd = join(root, 'pkg')
|
|
|
@@ -119,7 +167,8 @@ describe('workspace-changes in a repository', () => {
|
|
|
await settle(ctx, session)
|
|
|
await writeFile(join(root, 'a.txt'), 'changed\n')
|
|
|
await writeFile(join(cwd, 'inner.txt'), 'inner\n')
|
|
|
- toolCall(session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' })
|
|
|
+ await mutate(ctx, session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' },
|
|
|
+ () => writeFile(join(outside, 'note.txt'), 'one\ntwo\nthree\n'))
|
|
|
endTurn(session, 1, 'blocked')
|
|
|
await settle(ctx, session)
|
|
|
const [recorded] = changes(ctx, session)
|
|
|
@@ -128,6 +177,7 @@ describe('workspace-changes in a repository', () => {
|
|
|
{ path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 },
|
|
|
{ path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 },
|
|
|
])
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+one', '+two', '+three'] }] })
|
|
|
})
|
|
|
|
|
|
it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => {
|
|
|
@@ -135,7 +185,6 @@ describe('workspace-changes in a repository', () => {
|
|
|
const { ctx } = await boot()
|
|
|
const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } })
|
|
|
const agent = { session } as never
|
|
|
- const signal = new AbortController().signal
|
|
|
startTurn(session, 1)
|
|
|
await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
|
|
|
expect(changes(ctx, session)).toEqual([])
|
|
|
@@ -197,12 +246,13 @@ describe('workspace-changes in a repository', () => {
|
|
|
await settle(ctx, session)
|
|
|
for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`)
|
|
|
toolCall(session, 1, 'bash', { command: 'x' })
|
|
|
- toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } })
|
|
|
+ await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l1', new_string: 'l1' }, () => Promise.resolve())
|
|
|
endTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
const [recorded] = changes(ctx, session)
|
|
|
expect(recorded!.total).toBe(3)
|
|
|
expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt'])
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
it('warns and skips the turn when its git work fails', async () => {
|
|
|
@@ -215,8 +265,8 @@ describe('workspace-changes in a repository', () => {
|
|
|
await settle(ctx, session)
|
|
|
await writeFile(join(cwd, 'x.txt'), 'x\n')
|
|
|
toolCall(session, 1, 'bash', { command: 'x' })
|
|
|
- // A repository whose snapshot failed is not summarized from file-tool hunks as if it had no repository.
|
|
|
- toolCall(session, 1, 'write', { file_path: 'x.txt', content: 'x\n' }, { meta: { operation: 'create', diffs: [] } })
|
|
|
+ // A repository whose snapshot failed is not summarized from captures as if it had no repository.
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'y.txt', content: 'y\n' }, () => writeFile(join(cwd, 'y.txt'), 'y\n'))
|
|
|
endTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
expect(changes(ctx, session)).toEqual([])
|
|
|
@@ -237,11 +287,10 @@ describe('workspace-changes in a repository', () => {
|
|
|
const session = ctx.sessions.create(SessionId('gitlink'), { meta: { cwd } })
|
|
|
startTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
- await writeFile(join(sub, 'inner.txt'), 'inner\nedited\n')
|
|
|
- toolCall(session, 1, 'edit', { file_path: 'sub/inner.txt' }, { meta: { diffs: [{ path: 'sub/inner.txt', oldText: 'inner', newText: 'inner\nedited' }] } })
|
|
|
- await writeFile(join(cwd, 'top.txt'), 'top\n')
|
|
|
- toolCall(session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, { meta: { operation: 'create', diffs: [] } })
|
|
|
- toolCall(session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, { meta: { operation: 'update', diffs: [] } })
|
|
|
+ await mutate(ctx, session, 1, 'edit', { file_path: 'sub/inner.txt', old_string: 'inner', new_string: 'inner\nedited' },
|
|
|
+ () => writeFile(join(sub, 'inner.txt'), 'inner\nedited\n'))
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, () => writeFile(join(cwd, 'top.txt'), 'top\n'))
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, () => writeFile(join(cwd, 'same.txt'), 'same\n'))
|
|
|
endTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
expect(changes(ctx, session).map(summary => summary.files.map(file => file.display))).toEqual([['top.txt']])
|
|
|
@@ -253,6 +302,7 @@ describe('workspace-changes in a repository', () => {
|
|
|
await ctx.plugin(SessionStore)
|
|
|
await ctx.plugin(LocalSubprocessRuntime)
|
|
|
await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles')
|
|
|
+ await expect(ctx.plugin(WorkspaceChanges, { diffTimeoutMs: 1.5 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer diffTimeoutMs')
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -268,31 +318,68 @@ describe('workspace-changes without a repository', () => {
|
|
|
const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
|
|
|
startTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
- await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')
|
|
|
- toolCall(session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, {
|
|
|
- meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] },
|
|
|
- })
|
|
|
- toolCall(session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, {
|
|
|
- meta: { diffs: [{ path: 'existing.txt', oldText: 'more', newText: 'more\nagain' }] },
|
|
|
- })
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\n'))
|
|
|
+ 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'))
|
|
|
// Shell edits and scratch files under a temporary root stay out; a file elsewhere outside the workspace counts.
|
|
|
await writeFile(join(cwd, 'shell.txt'), 'shell\nedited\n')
|
|
|
toolCall(session, 1, 'bash', { command: 'x' })
|
|
|
- toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } })
|
|
|
- toolCall(session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' })
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve())
|
|
|
+ await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' },
|
|
|
+ () => writeFile(join(outside, 'note.txt'), 'one\ntwo\n'))
|
|
|
+ // A file the shell edits after a file tool touched it is compared by its final content.
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'mixed.txt', content: 'tool\n' }, () => writeFile(join(cwd, 'mixed.txt'), 'tool\n'))
|
|
|
+ await writeFile(join(cwd, 'mixed.txt'), 'tool\nshell\n')
|
|
|
+ toolCall(session, 1, 'bash', { command: 'x' })
|
|
|
endTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
expect(changes(ctx, session)).toEqual([{
|
|
|
- turn: 1, cwd, total: 2, added: 5, deleted: 1,
|
|
|
+ turn: 1, cwd, total: 3, added: 7, deleted: 1,
|
|
|
files: [
|
|
|
{ path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 },
|
|
|
+ { path: 'mixed.txt', display: 'mixed.txt', added: 2, deleted: 0 },
|
|
|
{ path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 },
|
|
|
],
|
|
|
}])
|
|
|
+ const seq = announcedSeq(session)
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({
|
|
|
+ kind: 'text', path: 'existing.txt', display: 'existing.txt', before: true, after: true, coarse: false,
|
|
|
+ hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 3, lines: ['-before', '+after', '+more', '+again'] }],
|
|
|
+ })
|
|
|
expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
|
|
|
await expect(stat(join(cwd, '.git'))).rejects.toThrow()
|
|
|
})
|
|
|
|
|
|
+ it('lists oversized and binary captured files without counts and serves no lines for them', async () => {
|
|
|
+ const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups)
|
|
|
+ await writeFile(join(cwd, 'grows.txt'), 'small\n')
|
|
|
+ const { ctx } = await boot({ maxFileBytes: 16 })
|
|
|
+ const session = ctx.sessions.create(SessionId('bounds'), { meta: { cwd } })
|
|
|
+ startTurn(session, 1)
|
|
|
+ await settle(ctx, session)
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'grows.txt', content: 'x' }, () => writeFile(join(cwd, 'grows.txt'), 'x'.repeat(17)))
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'shrinks.txt', content: 'x' }, () => writeFile(join(cwd, 'shrinks.txt'), 'x'))
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'bin.dat', content: 'x' }, () => writeFile(join(cwd, 'bin.dat'), Uint8Array.of(65, 0, 66)))
|
|
|
+ // A directory at the path is neither absent nor a file, so the path is not tracked.
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'dir', content: 'x' }, () => mkdir(join(cwd, 'dir')))
|
|
|
+ endTurn(session, 1)
|
|
|
+ await settle(ctx, session)
|
|
|
+ const [recorded] = changes(ctx, session)
|
|
|
+ expect(recorded!.files).toEqual([
|
|
|
+ { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
|
|
|
+ { path: 'grows.txt', display: 'grows.txt', added: 0, deleted: 0, oversized: true },
|
|
|
+ { path: 'shrinks.txt', display: 'shrinks.txt', added: 1, deleted: 0 },
|
|
|
+ ])
|
|
|
+ const seq = announcedSeq(session)
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' })
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, seq, 1, signal)).toEqual({ kind: 'oversized', path: 'grows.txt', display: 'grows.txt' })
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, seq, 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] })
|
|
|
+ // Every copy lives under the Session's temporary directory and goes with it.
|
|
|
+ const scratch = (await readdir(tmpdir())).filter(name => name.startsWith('dsh-workspace-changes-'))
|
|
|
+ expect(scratch.length).toBeGreaterThan(0)
|
|
|
+ ctx.emit('session/disposed', session)
|
|
|
+ await settle(ctx, session)
|
|
|
+ })
|
|
|
+
|
|
|
it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
|
|
|
const cwd = await repository()
|
|
|
const { ctx, fiber } = await boot()
|
|
|
@@ -334,10 +421,11 @@ describe('workspace-changes without a repository', () => {
|
|
|
for (const session of sessions) {
|
|
|
startTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
- toolCall(session, 1, 'bash', { command: 'x' })
|
|
|
+ await mutate(ctx, session, 1, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n'))
|
|
|
endTurn(session, 1)
|
|
|
await settle(ctx, session)
|
|
|
expect(changes(ctx, session)).toEqual([])
|
|
|
+ await rm(join(cwd, 'w.txt'))
|
|
|
}
|
|
|
await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never))
|
|
|
})
|
|
|
@@ -355,11 +443,12 @@ describe('workspace-changes without git', () => {
|
|
|
await settle(ctx, session)
|
|
|
await writeFile(join(cwd, `${turn}.txt`), 'x\n')
|
|
|
toolCall(session, turn, 'bash', { command: 'x' })
|
|
|
- if (turn === 2) toolCall(session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, { meta: { diffs: [] } })
|
|
|
+ if (turn === 2) await mutate(ctx, session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n'))
|
|
|
endTurn(session, turn)
|
|
|
await settle(ctx, session)
|
|
|
}
|
|
|
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 }] }])
|
|
|
+ expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 0, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+w'] }] })
|
|
|
expect(info).toHaveBeenCalledTimes(1)
|
|
|
expect(info.mock.calls[0]![0]).toContain('git is unavailable')
|
|
|
})
|