Files
deepseek-harness/packages/fs/tool-fs/tests/patch/commit.spec.ts
T
sjw1231 2bd9913e2f refactor(apply-patch): drop unrelated changes and redundant prose
Restore files outside the patch feature to their merge-base state: the
invariant-check refactors across acp, session-controller, client,
compaction, agent-loop, agent, session-title, and subprocess, the cast
baseline edit, and the rescope-vendor skip list added during the merge.

Rename tool-fs.enableApplyPatch to applyPatch, matching the read, write,
and edit switches. Remove the unused collectMutationPaths export and the
unconsumed PatchHunk.contextLineIndices field, including its SSH schema
and fixtures. Report preflight refusals and syntax failures directly
instead of padding every phase with outcome, affectedPaths, and
cancelled fields. Add apply_patch to the shipped tool catalog test.

Fold the default-activation Agent Note into the apply_patch file-editing
note and delete it. Delete the guarded-filesystem-mutations upgrade
guide: every change is additive and all FileSystem subclasses live in
this repository. Trim defensive and reviewer-addressed sentences from
the package READMEs, subsystem docs, FileSystem JSDoc, and the Agent
Note, keeping the English and Chinese pairs aligned.
2026-10-20 20:25:24 +08:00

191 lines
11 KiB
TypeScript

/** Sequential commits retain known effects and distinguish unchanged from uncertain failures. */
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { FsError } from '@deepseek-ai/dsh-fs'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import { expect, it, vi } from 'vitest'
import { add, move, patch, remove, setup, update } from './harness.ts'
class LostReplyFileSystem extends LocalFileSystem {
override async patchText(...args: Parameters<LocalFileSystem['patchText']>): ReturnType<LocalFileSystem['patchText']> {
await super.patchText(...args)
throw new FsError('reply lost', 'FS_IO_ERROR')
}
}
it.each(['delete', 'move'] as const)('retains the first commit when %s removal fails and observes only published changes', async (kind) => {
const t = await setup()
await t.file('source', 'old\n')
const sourceGuard = await t.editIntent('source')
t.fs.internals.removeFile = async () => { throw Object.assign(new Error('removal denied'), { code: 'EACCES' }) }
const a = t.record('add', 'a')
const b = t.record(kind, 'source', 1, 4, kind === 'move' ? 'destination' : undefined)
const c = t.record('add', 'c', 2, kind === 'move' ? 9 : 5)
const destination = resolve(t.workspace, 'destination')
expect(await t.run(patch(add('a'), kind === 'move' ? move('source', 'destination') : remove('source'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'failed', operation: b, failure: { code: 'FS_PERMISSION_DENIED', message: `cannot delete "${b.path}": removal denied` },
changes: kind === 'move' ? [{ path: destination, before: null, after: 'new\n' }] : [] },
{ status: 'skipped', operation: c },
], affectedPaths: kind === 'move' ? [a.path, destination] : [a.path], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
expect(await t.bytes('source')).toBe('old\n')
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
expect(await t.editIntent('source')).toEqual(sourceGuard)
expect(t.observations.map(event => event.path)).toEqual(kind === 'move' ? [a.path, destination] : [a.path])
if (kind === 'move') {
expect(await t.bytes('destination')).toBe('new\n')
expect(await t.editIntent('destination')).toEqual({ version: await t.version('destination') })
}
})
it('stops between operations after cancellation without undoing the first write', async () => {
const t = await setup()
t.ctx.on('fs/observed', () => { t.controller.abort() })
const a = t.record('add', 'a')
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'skipped', operation: t.record('add', 'b', 1, 4) },
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
], affectedPaths: [a.path], cancelled: true })
expect(await t.bytes('a')).toBe('new\n')
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('reports cancellation during staged writing as failed when stat confirms no change', async () => {
const t = await setup()
let writes = 0
t.fs.internals.inspectTemp = () => { if (++writes === 2) t.controller.abort() }
const a = t.record('add', 'a')
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'failed', operation: t.record('add', 'b', 1, 4), failure: { code: 'FS_ABORTED', message: 'write aborted' }, changes: [] },
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
], affectedPaths: [a.path], cancelled: true })
expect(await t.bytes('a')).toBe('new\n')
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('does not mark a fully completed patch cancelled when its final observation aborts', async () => {
const t = await setup()
t.ctx.on('fs/observed', () => { t.controller.abort() })
const a = t.record('add', 'a')
expect(await t.run(patch(add('a')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
], affectedPaths: [a.path], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
})
it('reports unknown after the provider changes disk and loses its reply without recording an observation', async () => {
const t = await setup({ provider: LostReplyFileSystem })
await t.file('a', 'old\n')
const observed = await t.editIntent('a')
expect(await t.run(patch(update('a'), add('b')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'reply lost — re-read the affected files before retrying' } },
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
], affectedPaths: [], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual(observed)
})
it.each(['unchanged', 'stat fails', 'stat defect'] as const)('verifies an I/O failure after provider entry: %s', async (scenario) => {
const t = await setup()
await t.file('a', 'old\n')
const observed = await t.editIntent('a')
const defect = new Error('stat defect')
vi.spyOn(t.fs, 'patchText').mockImplementation(async () => {
if (scenario !== 'unchanged') vi.spyOn(t.fs, 'stat').mockRejectedValue(scenario === 'stat fails' ? new FsError('stat failed', 'FS_IO_ERROR') : defect)
throw new FsError('write failed', 'FS_IO_ERROR')
})
const request = t.run(patch(update('a'), add('b')))
if (scenario === 'stat defect') await expect(request).rejects.toBe(defect)
else expect(await request).toEqual({ phase: 'commit', outcomes: [
scenario === 'unchanged'
? { status: 'failed', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed' }, changes: [] }
: { status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed — re-read the affected files before retrying' } },
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
], affectedPaths: [], cancelled: false })
expect(await t.bytes('a')).toBe('old\n')
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual(observed)
})
it('checks both move targets when its destination was published but the reply is lost', async () => {
const t = await setup()
await t.file('a', 'old\n')
const observed = await t.editIntent('a')
vi.spyOn(t.fs, 'moveFile').mockImplementation(async (_source, destination) => {
await writeFile(destination.targetKey, 'published\n')
throw new FsError('move reply lost', 'FS_IO_ERROR')
})
expect(await t.run(patch(move('a', 'b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'unknown', operation: t.record('move', 'a', 0, 2, 'b'), failure: { code: 'FS_IO_ERROR', message: 'move reply lost — re-read the affected files before retrying' } },
{ status: 'skipped', operation: t.record('add', 'c', 1, 7) },
], affectedPaths: [], cancelled: false })
expect(await t.bytes('a')).toBe('old\n')
expect(await t.bytes('b')).toBe('published\n')
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual(observed)
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('retains preceding changes when an external edit invalidates the next commit guard', async () => {
const t = await setup()
await t.file('b', 'old\n')
const observed = await t.editIntent('b')
const write = t.fs.writeText.bind(t.fs)
vi.spyOn(t.fs, 'writeText').mockImplementation(async (...args) => {
const result = await write(...args)
await writeFile(resolve(t.workspace, 'b'), 'external edit\n')
return result
})
const a = t.record('add', 'a')
const b = t.record('update', 'b', 1, 4)
expect(await t.run(patch(add('a'), update('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'failed', operation: b, failure: { code: 'FS_STALE_VERSION', message: expect.stringContaining('re-read the file, then retry') as string }, changes: [] },
{ status: 'skipped', operation: t.record('add', 'c', 2, 8) },
], affectedPaths: [a.path], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
expect(await t.bytes('b')).toBe('external edit\n')
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
expect(await t.editIntent('b')).toEqual(observed)
})
it('propagates programmer defects from mutation and preserves observed state', async () => {
const t = await setup()
await t.file('a', 'old\n')
const defect = new Error('mutation defect')
vi.spyOn(t.fs, 'patchText').mockRejectedValue(defect)
await expect(t.run(patch(update('a')))).rejects.toBe(defect)
expect(await t.bytes('a')).toBe('old\n')
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
})
it('reports the locked move source after an external edit following preflight', async () => {
const t = await setup({ policy: false })
await t.file('a', 'old\n')
const originalMove = t.fs.moveFile.bind(t.fs)
vi.spyOn(t.fs, 'moveFile').mockImplementation(async (...args) => {
await writeFile(resolve(t.workspace, 'a'), 'old\nexternal\n')
return originalMove(...args)
})
const reads = vi.spyOn(t.fs, 'readText')
expect(await t.run(patch(move('a', 'b')))).toMatchObject({ phase: 'commit', outcomes: [{ status: 'completed', operation: t.record('move', 'a', 0, 2, 'b'), changes: [
{ path: resolve(t.workspace, 'b'), before: null, after: 'new\nexternal\n' },
{ path: resolve(t.workspace, 'a'), before: 'old\nexternal\n', after: null },
] }] })
expect(reads).toHaveBeenCalledTimes(1)
expect(await t.bytes('b')).toBe('new\nexternal\n')
})