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:
@@ -14,11 +14,12 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
@@ -66,7 +67,7 @@ export function applyEditTool(ctx: Context): void {
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseEditArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
@@ -82,7 +83,17 @@ export function applyEditTool(ctx: Context): void {
|
||||
)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }]
|
||||
// The result-time applied-hunk diff (before→after with context lines). An
|
||||
// edit always changes content (parseEditArgs requires old_string to differ
|
||||
// and editText matches at least once), so there is always at least one hunk.
|
||||
// The bridge renders these as an inline diff that supersedes the call-time
|
||||
// snippet; the display path is the model-facing `file_path` (the bridge
|
||||
// relativizes it).
|
||||
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
|
||||
return {
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }],
|
||||
meta: { diffs },
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card of the literal replacement (old_string →
|
||||
// new_string), derived from the call args. `oldText: old_string || null`
|
||||
@@ -96,5 +107,15 @@ export function applyEditTool(ctx: Context): void {
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
// Result-time display: the applied contextual-diff hunks carried on `meta`.
|
||||
// On success with diffs, a `diff` result card supersedes the call-time
|
||||
// snippet; on error (nothing applied) or malformed meta, fall through to the
|
||||
// generic "updated successfully" rendering.
|
||||
presentResult(args, result: ToolResult): DiffResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const diffs = diffsFromMeta(result.meta)
|
||||
if (diffs === undefined) return undefined
|
||||
return { card: 'diff', title: `Edit ${args.file_path}`, diffs }
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user