mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 04:51:50 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
19
src/tools.ts
19
src/tools.ts
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user