mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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.
708 lines
26 KiB
TypeScript
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";
|
|
}
|