feat(tool-fs): result-time applied-hunk diffs for write/edit

fs write/edit now emit a result-time contextual-diff tool_call_update
(the applied hunk with ±3 context lines, one hunk per replace_all site),
matching what claude-agent-acp sends and what makes an editor render the
change in place. The call-time snippet diff stays; the result hunk
supersedes it (ACP content-replace).

Mechanism:
- A persisted tool-private `meta` channel: execute may return
  `{ content, meta }`; `meta` (JsonValue) rides on the tool/result event
  and is handed back to presentResult, so the diff reproduces on replay
  (event-sourced). JsonValue is now exported from dsh-session.
- The backend returns raw before/after text (storage facts) on
  FsWriteOutcome/FsEditOutcome; the tool computes the hunk via the npm
  `diff` package's structuredPatch. A create has no before → no result
  diff; a failed/aborted mutation carries no meta.
- ToolResultView gains a DiffResultView; the bridge's result-side switch
  renders it as {type:'diff'} content blocks.

RFC: docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
(justifies the npm `diff` runtime dep over vendoring and the meta channel);
the render-intent-union RFC's Non-goal is updated to record this shipped.
All fs snapshot goldens re-recorded; edit/overwrite gain the contextual
result diff, create/read/policy-reject unchanged in structure.
This commit is contained in:
Tianyi Cui
2026-07-03 17:12:00 +08:00
parent af79ceea1c
commit d8fd3225af
48 changed files with 2217 additions and 1073 deletions

View File

@@ -0,0 +1,113 @@
/**
* Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
* the pure before/after → {@link FileDiff}[] hunk builder and the defensive
* `meta` narrowing. These pin the exact hunk reconstruction (context lines,
* multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders.
*/
import { describe, expect, it } from 'vitest'
import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs'
import type { JsonValue } from '@deepseek-ai/dsh-session'
const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n'
describe('computeHunkDiffs', () => {
it('a single-line change yields one hunk with ±context lines on both sides', () => {
const before = lines(8)
const after = before.replace('line4', 'CHANGED')
const diffs = computeHunkDiffs('f.txt', before, after)
expect(diffs).toEqual([{
path: 'f.txt',
oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7',
newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7',
}])
})
it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => {
const before = lines(20)
const after = before.replace('line3', 'A').replace('line16', 'B')
const diffs = computeHunkDiffs('f.txt', before, after)
expect(diffs).toHaveLength(2)
expect(diffs[0]?.path).toBe('f.txt')
expect(diffs[0]?.oldText).toContain('line3')
expect(diffs[0]?.newText).toContain('A')
expect(diffs[1]?.oldText).toContain('line16')
expect(diffs[1]?.newText).toContain('B')
// The two hunks are distinct sites, not one merged block.
expect(diffs[0]?.newText).not.toContain('B')
expect(diffs[1]?.newText).not.toContain('A')
})
it('identical before/after (a no-op) yields no hunks', () => {
expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([])
})
it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => {
const diffs = computeHunkDiffs('f.txt', '', 'brand new\n')
expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }])
})
it('a pure deletion of the whole file reports newText empty', () => {
const diffs = computeHunkDiffs('f.txt', 'gone\n', '')
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }])
})
it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => {
const diffs = computeHunkDiffs('f.txt', 'x', 'y')
// The marker line (starting with "\\") must never leak into a diff block.
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }])
expect(diffs[0]?.oldText).not.toContain('\\')
expect(diffs[0]?.newText).not.toContain('\\')
})
it('uses DIFF_CONTEXT (3) surrounding lines', () => {
expect(DIFF_CONTEXT).toBe(3)
const before = lines(20)
const after = before.replace('line10', 'CHANGED')
const [diff] = computeHunkDiffs('f.txt', before, after)
// 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side.
expect(diff?.oldText?.split('\n')).toHaveLength(7)
expect(diff?.newText.split('\n')).toHaveLength(7)
expect(diff?.oldText?.split('\n')[0]).toBe('line7')
})
})
describe('diffsFromMeta (defensive narrowing)', () => {
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
// statically-valid JsonValue, so route every case through one cast helper that
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] }
it('narrows a well-formed { diffs } payload', () => {
expect(diffsFromMeta(m(good))).toEqual(good.diffs)
})
it('accepts a diff whose oldText is null (a create-style hunk)', () => {
const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }
expect(diffsFromMeta(m(meta))).toEqual(meta.diffs)
})
it('rejects undefined / non-object / array meta', () => {
expect(diffsFromMeta(undefined)).toBeUndefined()
expect(diffsFromMeta(null)).toBeUndefined()
expect(diffsFromMeta(m('nope'))).toBeUndefined()
expect(diffsFromMeta(m([]))).toBeUndefined()
})
it('rejects a missing / empty / non-array diffs field', () => {
expect(diffsFromMeta(m({}))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined()
})
it('rejects a diffs array containing a malformed entry', () => {
expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined()
})
})

View File

@@ -57,16 +57,17 @@ class FakeFs extends FileSystem {
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
this.throwIfArmed()
this.writeIntents.push(expected)
const existed = this.files.has(target.targetKey)
const before = this.files.get(target.targetKey) ?? null
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
}
override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
this.throwIfArmed()
this.editIntents.push(expected)
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
const after = content.split(edit.oldString).join(edit.newString)
this.files.set(target.targetKey, after)
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after }
}
}
@@ -395,3 +396,80 @@ describe('tool-owned presentation (pure presentCall)', () => {
})
})
})
describe('result-time contextual diff (meta + presentResult)', () => {
// An edit records the applied contextual hunk on `tool/result` meta, and the
// tool's presentResult narrows it back into a `diff` result card the bridge
// renders. Drive execute end-to-end so the meta is the REAL computed hunk.
const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', withContext)
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toEqual({
diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
})
})
it('edit: presentResult turns the meta into a diff result card', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', withContext)
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result)
expect(view).toEqual({
card: 'diff', title: 'Edit a.txt',
diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
})
})
it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', withContext)
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result)
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' }] })
})
it('write CREATE: no before-version → no meta, presentResult returns undefined (call-time card stands)', async () => {
const { ctx } = await setup()
const session = { header: {} }
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)).toBeUndefined()
})
it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'same\n')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
})
it('presentResult returns undefined on an error result (nothing applied)', async () => {
const { ctx } = await setup()
const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true }
expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined()
expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined()
})
it('presentResult returns undefined on malformed meta (defensive narrowing)', async () => {
const { ctx } = await setup()
const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined()
expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)).toBeUndefined()
})
})