Files
claude-agent-acp/src/diff.ts
Ben Brandt b9a6ec00a6 refactor: serve permission requests on the experimental ACP v2 surface (#1234)
First part of step 5 of opt-in ACP v2 support (after #1231, #1232): the
v2 surface now sends the agent's permission requests. Nothing changes on
ACP v1: no v1 golden file changes.

## What differs in v2

A v1 request asks about a tool call, and clients show the tool call's
title as the prompt. A v2 request gives the prompt a required `title`
and an optional `description` of its own, apart from the tool call. It
asks about a `subject`, which can be a tool call or a command.

## Change

- **Prompt text.** The agent already computes the prompt's heading and
explanation, but only AIR gets them, in
`_meta.jetbrains.air.permission`. The agent's request type,
`AcpPermissionRequest`, now carries them as `title` and `description`
for a v2 client. It's the same rule #1232 uses for diffs: build what v1
can't express only for a v2 client. So v1 requests are byte-identical,
and the v1 scenario golden files, which record what the agent hands
`requestPermission`, don't change. The v2 surface fails on a request
without a title, so a missed site fails loudly.
- **Subject.** Always `{ type: "tool_call", toolCall }`, where
`toolCall` is the same patch a `tool_call_update` carries
(`v2ToolCallPatch`). The `command` subject would suit Bash once display
terminals exist, since its `terminalId` refers to one.
- **Approval preview.** The exact patch before an Edit or Write runs is
now built in v2 form for a v2 client, and the request shows it. This
fixed a real gap: the agent pins the preview as content the client
holds, but only AIR's request shape included it. Without the fix, a v2
client saw no diff at all, because the hook's identical patch was then
dropped as unchanged.
- **Answer.** v2's outcomes map back unchanged. An outcome v1 doesn't
know, which the draft says is no approval, becomes `cancelled`, and the
agent reads that as an aborted tool use.
- **`requires_action`** while a request is open was already in place
since #1226.

## Scenario suite

All 12 scenarios that stopped at a permission request now pass: Bash,
Edit (approved and rejected), Write, NotebookEdit, WebFetch, MCP,
network, ExitPlanMode (approved and rejected), and two subagent
scenarios. Each recording shows `requires_action`, the request, then
`running`. Now only replay and the two plan scenarios remain.

## Verification

- **End to end:** a new test drives the agent's real `canUseTool`
through a v2 client. Allow, reject, and an unknown outcome become allow,
deny, and an aborted tool use.
- **Unit tests:** the prompt's title is separate from the tool call's
(ExitPlanMode: "Ready to code?" vs "Approve Plan"), the preview shows in
the request, the v2 preview fallback works, and the title guard rejects
a missing title.
- **Mutations:** each rule fails a test when removed. Sending
`title`/`description` to every client fails 37 tests, including v1
golden files.
- Build, lint and all 2380 tests pass.
2026-10-02 18:15:39 +02:00

708 lines
26 KiB
TypeScript

import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { structuredPatch } from "diff";
import { constants } from "node:fs";
import { type FileHandle, open } from "node:fs/promises";
import path from "node:path";
import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
/**
* The largest file, in bytes, that the adapter turns into a git patch.
*
* A larger file, a larger new text, or an Edit whose result can be larger
* gets no patch. The tool call then keeps its standard ACP content. The limit
* bounds the file read, the replacement, and the line diff that run while
* Claude waits for an approval.
*/
export const MAX_PATCH_FILE_BYTES = 1024 * 1024;
/**
* The wall-clock budget, in milliseconds, of one line diff for a patch.
*
* The diff runs synchronously, so the budget is also the longest block of the
* event loop.
*/
const PATCH_DIFF_TIMEOUT_MS = 100;
/** The number of context lines around a change in a hunk. */
const PATCH_CONTEXT_LINES = 3;
/** Git reads this many leading bytes to decide that a file is binary. */
const BINARY_SNIFF_BYTES = 8000;
const NO_NEWLINE_MARKER = "\\ No newline at end of file";
/**
* One unified-diff hunk in the `diff` package convention.
*
* A side with zero lines stores the number of the line after the change as its
* start. {@link hunkHeader} converts that start to the git convention.
*/
interface PatchHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
interface DiffToolResponse {
filePath?: string;
structuredPatch?: PatchHunk[];
/** FileWriteOutput only (FileEditOutput carries no `type`): whether the
* write created the file or overwrote an existing one. */
type?: "create" | "update";
/** FileWriteOutput only: the content that was written. */
content?: string;
/** The pre-change content. It is null on a Write create, or on a Write
* update whose previous content was too large to include. An Edit that
* creates a file reports an empty string, as for an existing empty file. */
originalFile?: string | null;
/** FileEditOutput only: the text that the Edit replaced. It is empty when
* the Edit created the file or filled an existing empty file. */
oldString?: string;
}
interface EditPreviewInput {
file_path?: unknown;
old_string?: unknown;
new_string?: unknown;
replace_all?: unknown;
}
/** The kind of file change that a git patch describes. */
type FileChange = "create" | "update";
/** A file change and the hunks of its git patch. */
interface FilePatch {
change: FileChange;
hunks: PatchHunk[];
}
/**
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
* diff, or an ACP v2 diff.
*/
export type PatchForm = "air" | "v2";
/**
* How a git patch names its file. `git` is git's own form: the leading slash
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
*/
type GitPatchPaths = "git" | "absolute";
/** An ACP v2 diff, as tool call content. */
type V2DiffContent = v2.Diff & { type: "diff" };
/**
* Builds the exact patch shown before Claude runs an Edit or Write tool.
*
* Returns undefined when the adapter cannot predict the file change exactly.
* The caller then keeps the standard tool-call content. The adapter declines
* a file that is missing for an Edit, too large, binary, or that has CR line
* endings. Claude converts the line endings of an edited file, so an in-memory
* replacement would not match the bytes that Claude writes. It also declines
* an `old_string` that does not match exactly once, because Claude then
* normalizes quotes or fails.
*
* A Write always gets content that shows the change: the patch, the standard
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
* a notice that the Write overwrites a file that the adapter cannot show.
*
* The preview uses the tool input as it is. Claude can still remove the
* trailing whitespace of a line before it writes. The PostToolUse hook then
* sends the patch of the written file.
*/
export async function previewPatchContent(
toolName: string,
input: Record<string, unknown>,
cwd?: string,
form: PatchForm = "air",
): Promise<ToolCallContent[] | undefined> {
if (toolName === "Edit") {
const edit = input as EditPreviewInput;
if (
typeof edit.file_path !== "string" ||
typeof edit.old_string !== "string" ||
typeof edit.new_string !== "string"
) {
return undefined;
}
const oldString = edit.old_string;
const filePath = resolveToolPath(edit.file_path, cwd);
const oldText = await readPatchSource(filePath);
if (oldText === undefined) return undefined;
const newString = edit.new_string;
if (oldString === newString || !isPatchableText(newString)) return undefined;
if (oldString.length === 0) {
// An empty old_string creates the file, or fills an existing file that
// holds only whitespace. `trim` also removes a byte order mark, whose
// handling the adapter does not predict, so such a file is declined.
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
return undefined;
}
return exactPatch(filePath, oldText, newString, form);
}
if (oldText === null) return undefined;
const occurrences = oldText.split(oldString).length - 1;
if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1)) return undefined;
if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
return undefined;
}
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
return exactPatch(filePath, oldText, newText, form);
}
if (toolName === "Write") {
const write = normalizeWriteInput(input);
if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
return undefined;
}
// The Write tool call shows no diff, so the approval is the only place that
// shows what the Write changes.
const content = write.content;
const filePath = resolveToolPath(write.file_path, cwd);
const oldText = await readPatchSource(filePath);
if (oldText === undefined) {
return [
{
type: "content",
content: {
type: "text",
text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
},
},
];
}
if (oldText === content) return undefined;
const standard: ToolCallContent[] = [
form === "v2"
? v2DiffContent(filePath, oldText === null ? "create" : "update")
: { type: "diff", path: write.file_path, oldText, newText: content },
];
if (!isPatchableText(content)) return standard;
// A dense change can exceed the diff budget. The approval then shows the standard diff.
return exactPatch(filePath, oldText, content, form) ?? standard;
}
return undefined;
}
/**
* An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
*
* The caller checks the bound before it builds the text. A `replace_all` of
* many short matches with a long `newString` can otherwise build a text of
* gigabytes before the timed line diff starts.
*/
function replacedTextBytesBound(
fileText: string,
oldString: string,
newString: string,
occurrences: number,
): number {
const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
}
/**
* The file text after Claude replaces `oldString` with `newString`.
*
* An empty `newString` deletes a line: when `oldString` does not end with a
* line break and the file holds `oldString` and a line break, Claude also
* removes that line break. With `replaceAll`, it then replaces only the
* occurrences that a line break follows.
*/
function replacedText(
fileText: string,
oldString: string,
newString: string,
replaceAll: boolean,
): string {
const target =
newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
? `${oldString}\n`
: oldString;
return replaceAll
? fileText.split(target).join(newString)
: fileText.replace(target, () => newString);
}
/**
* Builds the git patch for a finished Edit or Write from the file on disk.
*
* The Claude SDK `structuredPatch` is display data: Claude converts leading
* tabs to spaces and CRLF line endings to LF before it computes the hunks. A
* patch built from those hunks does not match the file. This function diffs
* `originalFile` against the written file instead. It returns undefined when
* it cannot build an exact patch, and the caller then sends the standard diff.
* A written file that contains a CR or a byte order mark is declined, because
* Claude removed those bytes from `originalFile`.
*/
export async function patchUpdateFromDiffToolResponse(
toolResponse: unknown,
form: PatchForm = "air",
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
if (!toolResponse || typeof toolResponse !== "object") return undefined;
const response = toolResponse as DiffToolResponse;
if (typeof response.filePath !== "string") return undefined;
// An Edit with an empty old_string reports "" as the original of a created
// file and of an existing empty file. The response does not tell the two
// apart, so the caller keeps the standard diff.
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
return undefined;
}
// A Write whose content is the previous text changed nothing. The file on
// disk can hold a later change, and that change is not the change of this Write.
if (
response.type === "update" &&
typeof response.content === "string" &&
response.originalFile === response.content
) {
return undefined;
}
const oldText =
response.type === "create"
? null
: typeof response.originalFile === "string"
? response.originalFile
: undefined;
if (oldText === undefined) return undefined;
// The same size limit as the new side: a larger text gets the standard diff.
if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
return undefined;
const newText = await readPatchSource(response.filePath);
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
if (response.type === "create" && newText !== response.content) return undefined;
const patch = filePatch(oldText, newText);
if (!patch) return undefined;
return {
content: [patchContent(response.filePath, patch, form)],
// A created file keeps the location of its Write tool call.
locations:
oldText === null
? [{ path: response.filePath }]
: patch.hunks.map(({ newStart }) => ({ path: response.filePath!, line: newStart })),
};
}
/**
* Builds standard ACP diff 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 toolUpdateFromDiffToolResponse(toolResponse: unknown): {
content?: ToolCallContent[];
locations?: ToolCallLocation[];
} {
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as DiffToolResponse;
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
for (const { lines, newStart } of response.structuredPatch) {
const oldText: string[] = [];
const newText: string[] = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else if (line === NO_NEWLINE_MARKER) {
continue;
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: response.filePath, line: newStart });
content.push({
type: "diff",
path: response.filePath,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
// A Write `update` can arrive with an empty structuredPatch — nothing
// changed, the diff timed out, or the previous content was too large to
// diff (originalFile null; SDK 0.3.252 documents the lane). Returning `{}`
// would leave Write's optimistic tool_use-time content standing, and that
// was built with `oldText: null` — "creation" semantics — so an overwrite
// of a large existing file would render as creating it. Emit a truthful
// replacement instead. Gated on `type` so Edit (whose output carries no
// `type` and whose optimistic old/new diff is already truthful) keeps the
// empty-return behavior. A `create` needs nothing here: the
// tool_use-time diff shows the created file, and a client with diffPatch
// gets it from the Write reporter.
if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
locations.push({ path: response.filePath });
content.push(
typeof response.originalFile === "string"
? {
type: "diff",
path: response.filePath,
oldText: response.originalFile,
newText: response.content,
}
: {
type: "content",
content: {
type: "text",
text: `Updated \`${response.filePath}\` (previous content too large to diff)`,
},
},
);
}
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
if (content.length > 0) result.content = content;
if (locations.length > 0) result.locations = locations;
return result;
}
/**
* The ACP v2 diff of a finished Edit or Write: the exact patch of
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
* change with a patch from what the toolResponse holds.
*
* The fallback patch of an update comes from the `structuredPatch`, which is
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
* for a v2 patch, which is renderable text, not a patch to apply. An update
* without hunks, such as one whose previous content was too large to diff,
* gets the change alone. An Edit with an empty `old_string` reports the same
* response for a created file and a filled empty one, so it gets no diff.
*/
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
content?: ToolCallContent[];
locations?: ToolCallLocation[];
}> {
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
if (exact) return exact;
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as DiffToolResponse;
const filePath = response.filePath;
if (typeof filePath !== "string") return {};
if (response.type === "create") {
const hunk =
typeof response.content === "string" && isPatchableText(response.content)
? wholeFileHunk(response.content)
: undefined;
return {
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
locations: [{ path: filePath }],
};
}
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
return {};
}
// A Write whose content is the previous text changed nothing.
if (response.type === "update" && response.content === response.originalFile) return {};
const hunks =
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
? response.structuredPatch
: undefined;
return {
content: [v2DiffContent(filePath, "update", hunks)],
locations: hunks
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
: [{ path: filePath }],
};
}
/**
* The text of one git patch for `filePath`.
*
* The headers follow `git diff`: the path is quoted when git would quote it,
* and a created file gets its mode line and a `/dev/null` side. With `git`
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
*/
export function gitPatchText(
filePath: string,
change: FileChange,
hunks: PatchHunk[],
paths: GitPatchPaths = "git",
): string {
return [
...gitPatchHeader(filePath, change, paths),
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
"",
].join("\n");
}
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
// A Windows path gets forward slashes so the header names the same file on
// every platform.
const slashed = filePath.replaceAll("\\", "/");
// git drops one leading slash of an absolute path.
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
const tab = name.includes(" ") ? "\t" : "";
return [
`diff --git ${oldName} ${newName}`,
...(change === "create" ? ["new file mode 100644"] : []),
`--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
`+++ ${newName}${tab}`,
];
}
const GIT_PATH_ESCAPES: Record<number, string> = {
0x07: "a",
0x08: "b",
0x09: "t",
0x0a: "n",
0x0b: "v",
0x0c: "f",
0x0d: "r",
0x22: '"',
0x5c: "\\",
};
/**
* Quotes `prefix + name` like git with the default `core.quotePath`.
*
* A double quote, a backslash, a control byte, or a non-ASCII byte makes git
* put the whole name in double quotes. git then writes a C escape or a
* three-digit octal escape for each such UTF-8 byte.
*/
function quoteGitPath(prefix: string, name: string): string {
const full = `${prefix}${name}`;
let quoted = "";
let needsQuotes = false;
for (const byte of Buffer.from(full, "utf8")) {
const escape = GIT_PATH_ESCAPES[byte];
if (escape !== undefined) {
quoted += `\\${escape}`;
needsQuotes = true;
} else if (byte < 0x20 || byte >= 0x7f) {
quoted += `\\${byte.toString(8).padStart(3, "0")}`;
needsQuotes = true;
} else {
quoted += String.fromCharCode(byte);
}
}
return needsQuotes ? `"${quoted}"` : full;
}
function hunkHeader({ oldStart, oldLines, newStart, newLines }: PatchHunk): string {
return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
}
/** A git hunk range. A side with zero lines names the line before the change. */
function hunkRange(start: number, count: number): string {
if (count === 0) return `${Math.max(0, start - 1)},0`;
return count === 1 ? String(start) : `${start},${count}`;
}
/** The hunk that adds every line of `text` to an empty file. */
function wholeFileHunk(text: string): PatchHunk | undefined {
if (text.length === 0) return undefined;
const terminated = text.endsWith("\n");
const lines = (terminated ? text.slice(0, -1) : text).split("\n");
return {
oldStart: 1,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
};
}
/**
* The line-diff hunks between two texts. Returns undefined when the diff runs
* out of its time budget.
*/
function diffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
if (oldText === null) {
const hunk = wholeFileHunk(newText);
return hunk ? [hunk] : [];
}
const oldLines = textLines(oldText);
const newLines = textLines(newText);
// The diff gets only the changed middle and its context lines. An edit
// usually changes a small part of a large file.
const common = Math.min(oldLines.length, newLines.length);
let prefix = 0;
while (prefix < common && oldLines[prefix] === newLines[prefix]) prefix++;
let suffix = 0;
while (
suffix < common - prefix &&
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
) {
suffix++;
}
const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
const patch = structuredPatch(
"",
"",
oldLines.slice(skipped, oldLines.length - skippedEnd).join(""),
newLines.slice(skipped, newLines.length - skippedEnd).join(""),
"",
"",
{ context: PATCH_CONTEXT_LINES, timeout: PATCH_DIFF_TIMEOUT_MS },
);
return patch?.hunks.map((hunk) => ({
...hunk,
oldStart: hunk.oldStart + skipped,
newStart: hunk.newStart + skipped,
}));
}
/** The lines of a text, each with its line break. The last line can have none. */
function textLines(text: string): string[] {
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
}
/**
* The patch of a change from `oldText`, null for a missing file, to
* `newText`. Returns undefined when nothing changed or the diff runs out of
* its time budget.
*/
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
const hunks = diffHunks(oldText, newText);
if (!hunks || hunks.length === 0) return undefined;
return { change: oldText === null ? "create" : "update", hunks };
}
/**
* Reads a file as patch input.
*
* Returns null when the file does not exist. Returns undefined when the file
* cannot be read, is not a regular file, is larger than
* {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
* CR.
*/
async function readPatchSource(filePath: string): Promise<string | null | undefined> {
let handle: FileHandle;
try {
// O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
} catch (error) {
return isMissingFileError(error) ? null : undefined;
}
try {
// The checks and the read use one open file, so a swap of the path between them has no effect.
const stats = await handle.stat();
if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES) return undefined;
// One byte more than the checked size shows a file that grew after the check.
const buffer = Buffer.alloc(stats.size + 1);
let length = 0;
while (length < buffer.length) {
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
if (bytesRead === 0) break;
length += bytesRead;
}
if (length > stats.size) return undefined;
const text = decodeFileText(buffer.subarray(0, length));
return text?.includes("\r") ? undefined : text;
} catch {
return undefined;
} finally {
await handle.close();
}
}
/** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
function decodeFileText(bytes: Uint8Array): string | undefined {
if (bytes.length > MAX_PATCH_FILE_BYTES) return undefined;
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
} catch {
return undefined;
}
}
/** Whether a tool input text can be the new side of an exact patch. */
function isPatchableText(text: string): boolean {
return (
Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
!text.includes("\0") &&
!text.includes("\r")
);
}
/** The exact patch content of a change, or undefined when it has no patch. */
function exactPatch(
filePath: string,
oldText: string | null,
newText: string,
form: PatchForm,
): ToolCallContent[] | undefined {
const patch = filePatch(oldText, newText);
return patch && [patchContent(filePath, patch, form)];
}
/** The content that carries a patch, in the given form. */
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
return form === "v2"
? v2DiffContent(filePath, patch.change, patch.hunks)
: airPatchContent(filePath, patch);
}
/**
* A diff block in the AIR patch form.
*
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
* sends this form only to a client that advertised `diffPatch`.
*/
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
return {
type: "diff",
path: filePath,
oldText: null,
newText: "",
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
version: 1,
format: "git_patch",
text: gitPatchText(filePath, change, hunks),
}),
};
}
/**
* The ACP v2 diff of a change to the text file `filePath`: the change and,
* given hunks, its git patch.
*
* The agent builds tool call content in v1 types, and v1 cannot express a v2
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
* diff, and only a v2 client gets one: the v2 surface sends it as it is
* (`src/v2/tool-call.ts`).
*/
export function v2DiffContent(
filePath: string,
change: FileChange,
hunks?: PatchHunk[],
): ToolCallContent {
const fileChange: v2.DiffChange =
change === "create"
? { operation: "add", path: filePath, fileType: "text" }
: { operation: "modify", path: filePath, fileType: "text" };
const diff: V2DiffContent = {
type: "diff",
changes: [fileChange],
...(hunks?.length
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
: {}),
};
return diff as unknown as ToolCallContent;
}
function resolveToolPath(filePath: string, cwd?: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
}
function isMissingFileError(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}