fix: emit a real diff when Write overwrites an existing file (#618)

## What's broken

The PostToolUse hook fan-out (`src/acp-agent.ts`) only invokes
`toolUpdateFromEditToolResponse` for tools named `Edit`. Any `Write`
that overwrites an existing file (`FileWriteOutput.type === "update"`)
keeps the optimistic content built at tool-use time by
`toolInfoFromToolUse` — `oldText: null, newText: input.content` —
permanently. The user sees a "creation" diff regardless of whether the
file already existed, so overwrites visually look indistinguishable from
brand-new files.

This affects every Write that touches an existing file, which Claude
does routinely (rewriting configs, replacing small files, etc.).

## Fix

The SDK's `FileWriteOutput`
(`@anthropic-ai/claude-agent-sdk/sdk-tools.d.ts`) ships the same
hunk-shaped `structuredPatch` for `Write` that we already render for
`Edit`. Extending the dispatch to cover both tools replays the hook's
payload through `toolUpdateFromEditToolResponse` so update mode shows
the actual diff.

Create mode is unchanged in practice because the structuredPatch hunk's
lines are all additions, which collapse to the same `oldText: null,
newText: <content>` shape the optimistic path produced.

## Tests

Two regression tests added in `src/tests/tools.test.ts` under a new
`describe("post-tool-use hook sends diff content for Write tool")`:

- `Write` with `type: "update"` asserts the corrected diff (real
`oldText` / `newText` derived from `structuredPatch`)
- `Write` with `type: "create"` asserts the existing optimistic-style
diff is preserved

All existing tests pass. Total: 240 → 242.

## Validation

- `npm run check` ✅
- `npm run build` ✅
- `npm run test:run` ✅ (242 passed, 13 integration skipped)

---------

Co-authored-by: dave <dave@mint.lan>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
davethedev3
2026-05-03 17:44:47 -04:00
committed by GitHub
parent c78ac62d05
commit 8d7e22026e
4 changed files with 190 additions and 23 deletions

View File

@@ -73,7 +73,7 @@ import {
planEntries,
registerHookCallback,
toolInfoFromToolUse,
toolUpdateFromEditToolResponse,
toolUpdateFromDiffToolResponse,
toolUpdateFromToolResult,
} from "./tools.js";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
@@ -2603,8 +2603,18 @@ export function toAcpNotifications(
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
const toolUse = toolUseCache[toolUseId];
if (toolUse) {
// Both `Edit` and `Write` produce a structuredPatch in their
// PostToolUse tool_response. For Edit the diff replaces the
// optimistic content built at tool_use time. For Write the
// optimistic content (built from `input.content` alone with
// `oldText: null`) shows "creation" semantics regardless of
// whether the file existed; the structuredPatch from the
// hook lets us emit the real diff for `type: "update"`. The
// helper returns `{}` if the response shape isn't usable.
const editDiff =
toolUse.name === "Edit" ? toolUpdateFromEditToolResponse(toolResponse) : {};
toolUse.name === "Edit" || toolUse.name === "Write"
? toolUpdateFromDiffToolResponse(toolResponse)
: {};
const update: SessionNotification["update"] = {
_meta: {
claudeCode: {

View File

@@ -22,7 +22,7 @@ import {
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
toolUpdateFromDiffToolResponse,
} from "../tools.js";
import {
toAcpNotifications,
@@ -998,17 +998,17 @@ describe("toDisplayPath", () => {
});
});
describe("toolUpdateFromEditToolResponse", () => {
describe("toolUpdateFromDiffToolResponse", () => {
it("should return empty for non-object input", () => {
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
expect(toolUpdateFromEditToolResponse(undefined)).toEqual({});
expect(toolUpdateFromEditToolResponse("string")).toEqual({});
expect(toolUpdateFromDiffToolResponse(null)).toEqual({});
expect(toolUpdateFromDiffToolResponse(undefined)).toEqual({});
expect(toolUpdateFromDiffToolResponse("string")).toEqual({});
});
it("should return empty when filePath or structuredPatch is missing", () => {
expect(toolUpdateFromEditToolResponse({})).toEqual({});
expect(toolUpdateFromEditToolResponse({ filePath: "/foo.ts" })).toEqual({});
expect(toolUpdateFromEditToolResponse({ structuredPatch: [] })).toEqual({});
expect(toolUpdateFromDiffToolResponse({})).toEqual({});
expect(toolUpdateFromDiffToolResponse({ filePath: "/foo.ts" })).toEqual({});
expect(toolUpdateFromDiffToolResponse({ structuredPatch: [] })).toEqual({});
});
it("should build diff content from a single-hunk structuredPatch", () => {
@@ -1025,7 +1025,7 @@ describe("toolUpdateFromEditToolResponse", () => {
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
@@ -1059,7 +1059,7 @@ describe("toolUpdateFromEditToolResponse", () => {
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
@@ -1095,7 +1095,7 @@ describe("toolUpdateFromEditToolResponse", () => {
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
@@ -1114,7 +1114,7 @@ describe("toolUpdateFromEditToolResponse", () => {
structuredPatch: [],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({});
expect(toolUpdateFromDiffToolResponse(toolResponse)).toEqual({});
});
});

View File

@@ -1056,6 +1056,162 @@ describe("Bash terminal output", () => {
});
});
describe("post-tool-use hook sends diff content for Write tool", () => {
// Regression: previously the dispatch only invoked
// toolUpdateFromDiffToolResponse for `Edit`, so a Write that
// overwrote an existing file (FileWriteOutput.type === "update")
// showed a "creation" diff (oldText: null, full new content) at
// tool_use time and was never corrected after the tool ran.
it("should emit a real diff for Write of an existing file (type: update)", async () => {
const toolUseCache: ToolUseCache = {};
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
toAcpNotifications(
[
{
type: "tool_use" as const,
id: "toolu_write_update",
name: "Write",
input: {
file_path: "/Users/test/project/file.ts",
content: "line1\nNEW line2\nline3",
},
},
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
);
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Write",
tool_input: {
file_path: "/Users/test/project/file.ts",
content: "line1\nNEW line2\nline3",
},
tool_response: {
type: "update",
filePath: "/Users/test/project/file.ts",
content: "line1\nNEW line2\nline3",
originalFile: "line1\nold line2\nline3",
structuredPatch: [
{
oldStart: 1,
oldLines: 3,
newStart: 1,
newLines: 3,
lines: [" line1", "-old line2", "+NEW line2", " line3"],
},
],
userModified: false,
},
tool_use_id: "toolu_write_update",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_write_update",
{ signal: AbortSignal.abort() },
);
expect(hookUpdates).toHaveLength(1);
const hookUpdate = hookUpdates[0].update;
expect(hookUpdate._meta.claudeCode.toolName).toBe("Write");
expect(hookUpdate.content).toEqual([
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "line1\nold line2\nline3",
newText: "line1\nNEW line2\nline3",
},
]);
expect(hookUpdate.locations).toEqual([{ path: "/Users/test/project/file.ts", line: 1 }]);
});
it("should still emit a sensible diff for Write of a brand-new file (type: create)", async () => {
const toolUseCache: ToolUseCache = {};
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
toAcpNotifications(
[
{
type: "tool_use" as const,
id: "toolu_write_create",
name: "Write",
input: {
file_path: "/Users/test/project/new.ts",
content: "first\nsecond",
},
},
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
);
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Write",
tool_input: {
file_path: "/Users/test/project/new.ts",
content: "first\nsecond",
},
tool_response: {
type: "create",
filePath: "/Users/test/project/new.ts",
content: "first\nsecond",
originalFile: null,
structuredPatch: [
{
oldStart: 0,
oldLines: 0,
newStart: 1,
newLines: 2,
lines: ["+first", "+second"],
},
],
},
tool_use_id: "toolu_write_create",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_write_create",
{ signal: AbortSignal.abort() },
);
expect(hookUpdates).toHaveLength(1);
const hookUpdate = hookUpdates[0].update;
expect(hookUpdate.content).toEqual([
{
type: "diff",
path: "/Users/test/project/new.ts",
oldText: null,
newText: "first\nsecond",
},
]);
});
});
describe("post-tool-use hook preserves terminal _meta", () => {
it("should send terminal_output and terminal_exit as separate notifications, and hook should only have claudeCode", async () => {
const clientCapabilities: ClientCapabilities = {

View File

@@ -679,7 +679,7 @@ export function markdownEscape(text: string): string {
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
interface EditToolResponseHunk {
interface DiffToolResponseHunk {
oldStart: number;
oldLines: number;
newStart: number;
@@ -687,23 +687,24 @@ interface EditToolResponseHunk {
lines: string[];
}
interface EditToolResponse {
interface DiffToolResponse {
filePath?: string;
structuredPatch?: EditToolResponseHunk[];
structuredPatch?: DiffToolResponseHunk[];
}
/**
* Builds diff ToolUpdate content from the structured Edit toolResponse provided
* by the PostToolUse hook. Unlike parsing the plain unified diff string, this uses
* the pre-parsed structuredPatch which supports multiple replacement sites (replaceAll)
* and always includes context lines for better readability.
* Builds diff ToolUpdate content from the structured toolResponse provided by
* the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
* the plain unified diff string, this uses the pre-parsed structuredPatch
* which supports multiple replacement sites (replaceAll) and always includes
* context lines for better readability.
*/
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
content?: ToolCallContent[];
locations?: ToolCallLocation[];
} {
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as EditToolResponse;
const response = toolResponse as DiffToolResponse;
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
const content: ToolCallContent[] = [];