mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-05 17:01:44 +00:00
Claude supplies structured patches for Edit and Write, but ACP diff blocks previously carried only the reconstructed texts. AIR had to compare those texts again to display added and removed counts. Count patch operations in the existing conversion loop and publish `_meta.jetbrains.air.diffStats` on each diff block. This uses the same contract as https://github.com/agentclientprotocol/codex-acp/pull/501. Each block has its own counts. No extra traversal, full file comparison, or navigation calculation is needed. Omit statistics when patches are absent or inconsistent, so clients retain their existing fallback. Exclude EOF markers from the displayed texts. Document the extension and cover per-block counts, Edit and Write hooks, EOF changes, and fallback behavior. Validation: - `npm run build` - `npm run check` - `env -u ANTHROPIC_BASE_URL npm run test:run` (the local endpoint override affects unrelated provider tests)
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
|
import { AIR_DIFF_STATS_KEY, withAirMeta } from "./air-extension.js";
|
|
|
|
interface DiffToolResponseHunk {
|
|
oldStart: number;
|
|
oldLines: number;
|
|
newStart: number;
|
|
newLines: number;
|
|
lines: string[];
|
|
}
|
|
|
|
interface DiffToolResponse {
|
|
filePath?: string;
|
|
structuredPatch?: DiffToolResponseHunk[];
|
|
/** 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;
|
|
/** FileWriteOutput only: the pre-write content — null on create, or on an
|
|
* update whose previous content was too large to include. */
|
|
originalFile?: string | null;
|
|
}
|
|
|
|
/**
|
|
* 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 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[] = [];
|
|
let previousOldEnd = 0;
|
|
let previousNewEnd = 0;
|
|
let accumulatedDelta = 0;
|
|
let coordinatesRemainConsistent = true;
|
|
|
|
for (const { lines, oldStart, newStart, oldLines, newLines } of response.structuredPatch) {
|
|
const oldText: string[] = [];
|
|
const newText: string[] = [];
|
|
let added = 0;
|
|
let removed = 0;
|
|
let validPrefixes = true;
|
|
let validEofMarkers = true;
|
|
const oldPosition = oldStart + (oldLines === 0 ? 1 : 0);
|
|
const newPosition = newStart + (newLines === 0 ? 1 : 0);
|
|
const oldEnd = oldPosition + oldLines;
|
|
const newEnd = newPosition + newLines;
|
|
const nextDelta = accumulatedDelta + newLines - oldLines;
|
|
const validCoordinates: boolean =
|
|
coordinatesRemainConsistent &&
|
|
Number.isSafeInteger(oldStart) &&
|
|
Number.isSafeInteger(newStart) &&
|
|
Number.isSafeInteger(oldLines) &&
|
|
Number.isSafeInteger(newLines) &&
|
|
oldStart >= (oldLines === 0 ? 0 : 1) &&
|
|
newStart >= (newLines === 0 ? 0 : 1) &&
|
|
oldLines >= 0 &&
|
|
newLines >= 0 &&
|
|
Number.isSafeInteger(oldPosition) &&
|
|
Number.isSafeInteger(newPosition) &&
|
|
Number.isSafeInteger(oldEnd) &&
|
|
Number.isSafeInteger(newEnd) &&
|
|
Number.isSafeInteger(nextDelta) &&
|
|
oldPosition >= previousOldEnd &&
|
|
newPosition >= previousNewEnd &&
|
|
newPosition - oldPosition === accumulatedDelta;
|
|
coordinatesRemainConsistent = validCoordinates;
|
|
if (validCoordinates) {
|
|
previousOldEnd = oldEnd;
|
|
previousNewEnd = newEnd;
|
|
accumulatedDelta = nextDelta;
|
|
}
|
|
for (const [index, line] of lines.entries()) {
|
|
if (line.startsWith("-")) {
|
|
oldText.push(line.slice(1));
|
|
removed++;
|
|
} else if (line.startsWith("+")) {
|
|
newText.push(line.slice(1));
|
|
added++;
|
|
} else if (line === "\\ No newline at end of file") {
|
|
const previousLine = lines[index - 1];
|
|
if (
|
|
typeof previousLine !== "string" ||
|
|
(!previousLine.startsWith("-") &&
|
|
!previousLine.startsWith("+") &&
|
|
!previousLine.startsWith(" "))
|
|
) {
|
|
validEofMarkers = false;
|
|
}
|
|
continue;
|
|
} else {
|
|
if (!line.startsWith(" ")) validPrefixes = false;
|
|
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"),
|
|
...(validCoordinates &&
|
|
validPrefixes &&
|
|
validEofMarkers &&
|
|
oldText.length === oldLines &&
|
|
newText.length === newLines
|
|
? { _meta: withAirMeta(undefined, AIR_DIFF_STATS_KEY, { version: 1, added, removed }) }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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;
|
|
}
|