feat(protocol): add DiffPreview type and wire into can_use_tool emission (#1151)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -73,6 +73,7 @@ import {
|
||||
validateRegistryHandleOrThrow,
|
||||
} from "./cli/startupFlagValidation";
|
||||
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "./constants";
|
||||
import { computeDiffPreviews } from "./helpers/diffPreview";
|
||||
import {
|
||||
mergeQueuedTurnInput,
|
||||
type QueuedTurnInput,
|
||||
@@ -2548,6 +2549,9 @@ async function runBidirectionalMode(
|
||||
}> {
|
||||
const requestId = `perm-${toolCallId}`;
|
||||
|
||||
// Compute diff previews for file-modifying tools
|
||||
const diffs = await computeDiffPreviews(toolName, toolInput);
|
||||
|
||||
// Build can_use_tool control request (Claude SDK format)
|
||||
const canUseToolRequest: CanUseToolControlRequest = {
|
||||
subtype: "can_use_tool",
|
||||
@@ -2556,6 +2560,7 @@ async function runBidirectionalMode(
|
||||
tool_call_id: toolCallId, // Letta-specific
|
||||
permission_suggestions: [], // TODO: not implemented
|
||||
blocked_path: null, // TODO: not implemented
|
||||
...(diffs.length > 0 ? { diffs } : {}),
|
||||
};
|
||||
|
||||
const controlRequest: ControlRequest = {
|
||||
|
||||
191
src/helpers/diffPreview.ts
Normal file
191
src/helpers/diffPreview.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Converts internal diff results (AdvancedDiffResult) to wire-safe DiffPreview
|
||||
* for the bidirectional protocol. Strips full file contents (oldStr/newStr)
|
||||
* and only sends hunks, which is sufficient for rendering.
|
||||
*/
|
||||
|
||||
import { basename } from "node:path";
|
||||
import type { AdvancedDiffResult, AdvancedHunk } from "../cli/helpers/diff";
|
||||
import type { DiffHunk, DiffHunkLine, DiffPreview } from "../types/protocol";
|
||||
|
||||
function parseHunkLinePrefix(raw: string): DiffHunkLine | null {
|
||||
if (raw.length === 0) {
|
||||
return { type: "context", content: "" };
|
||||
}
|
||||
if (raw[0] === "\\") {
|
||||
// Metadata line (e.g. "\ No newline at end of file"), not a diff row.
|
||||
return null;
|
||||
}
|
||||
const prefix = raw[0];
|
||||
const content = raw.slice(1);
|
||||
if (prefix === "+") return { type: "add", content };
|
||||
if (prefix === "-") return { type: "remove", content };
|
||||
if (prefix === " ") return { type: "context", content };
|
||||
// Unknown prefix: preserve full line as context rather than dropping first char.
|
||||
return { type: "context", content: raw };
|
||||
}
|
||||
|
||||
function convertHunk(hunk: AdvancedHunk): DiffHunk {
|
||||
const lines: DiffHunkLine[] = [];
|
||||
for (const line of hunk.lines) {
|
||||
const parsed = parseHunkLinePrefix(line.raw);
|
||||
if (parsed) {
|
||||
lines.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
let oldLines = 0;
|
||||
let newLines = 0;
|
||||
for (const line of lines) {
|
||||
if (line.type === "context") {
|
||||
oldLines++;
|
||||
newLines++;
|
||||
} else if (line.type === "remove") {
|
||||
oldLines++;
|
||||
} else if (line.type === "add") {
|
||||
newLines++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oldStart: hunk.oldStart,
|
||||
oldLines,
|
||||
newStart: hunk.newStart,
|
||||
newLines,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single AdvancedDiffResult to a wire-safe DiffPreview.
|
||||
* For multi-file patch tools, call this once per file operation.
|
||||
*/
|
||||
export function toDiffPreview(
|
||||
result: AdvancedDiffResult,
|
||||
fileNameOverride?: string,
|
||||
): DiffPreview {
|
||||
switch (result.mode) {
|
||||
case "advanced":
|
||||
return {
|
||||
mode: "advanced",
|
||||
fileName: fileNameOverride ?? result.fileName,
|
||||
hunks: result.hunks.map(convertHunk),
|
||||
};
|
||||
case "fallback":
|
||||
return {
|
||||
mode: "fallback",
|
||||
fileName: fileNameOverride ?? "unknown",
|
||||
reason: result.reason,
|
||||
};
|
||||
case "unpreviewable":
|
||||
return {
|
||||
mode: "unpreviewable",
|
||||
fileName: fileNameOverride ?? "unknown",
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type DiffDeps = {
|
||||
computeAdvancedDiff: typeof import("../cli/helpers/diff").computeAdvancedDiff;
|
||||
parsePatchToAdvancedDiff: typeof import("../cli/helpers/diff").parsePatchToAdvancedDiff;
|
||||
isFileWriteTool: typeof import("../cli/helpers/toolNameMapping").isFileWriteTool;
|
||||
isFileEditTool: typeof import("../cli/helpers/toolNameMapping").isFileEditTool;
|
||||
isPatchTool: typeof import("../cli/helpers/toolNameMapping").isPatchTool;
|
||||
parsePatchOperations: typeof import("../cli/helpers/formatArgsDisplay").parsePatchOperations;
|
||||
};
|
||||
|
||||
let cachedDiffDeps: DiffDeps | null = null;
|
||||
|
||||
async function getDiffDeps(): Promise<DiffDeps> {
|
||||
if (cachedDiffDeps) return cachedDiffDeps;
|
||||
const [diffMod, toolNameMod, formatMod] = await Promise.all([
|
||||
import("../cli/helpers/diff"),
|
||||
import("../cli/helpers/toolNameMapping"),
|
||||
import("../cli/helpers/formatArgsDisplay"),
|
||||
]);
|
||||
cachedDiffDeps = {
|
||||
computeAdvancedDiff: diffMod.computeAdvancedDiff,
|
||||
parsePatchToAdvancedDiff: diffMod.parsePatchToAdvancedDiff,
|
||||
isFileWriteTool: toolNameMod.isFileWriteTool,
|
||||
isFileEditTool: toolNameMod.isFileEditTool,
|
||||
isPatchTool: toolNameMod.isPatchTool,
|
||||
parsePatchOperations: formatMod.parsePatchOperations,
|
||||
};
|
||||
return cachedDiffDeps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute diff previews for a tool call. Returns an array of DiffPreview
|
||||
* (one per file for patch tools, one for Write/Edit tools).
|
||||
*
|
||||
* Mirrors the diff computation logic in App.tsx:4372-4438.
|
||||
*/
|
||||
export async function computeDiffPreviews(
|
||||
toolName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
): Promise<DiffPreview[]> {
|
||||
const {
|
||||
computeAdvancedDiff,
|
||||
parsePatchToAdvancedDiff,
|
||||
isFileWriteTool,
|
||||
isFileEditTool,
|
||||
isPatchTool,
|
||||
parsePatchOperations,
|
||||
} = await getDiffDeps();
|
||||
const previews: DiffPreview[] = [];
|
||||
|
||||
try {
|
||||
if (isFileWriteTool(toolName)) {
|
||||
const filePath = toolArgs.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "write",
|
||||
filePath,
|
||||
content: (toolArgs.content as string) || "",
|
||||
});
|
||||
previews.push(toDiffPreview(result, basename(filePath)));
|
||||
}
|
||||
} else if (isFileEditTool(toolName)) {
|
||||
const filePath = toolArgs.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
if (toolArgs.edits && Array.isArray(toolArgs.edits)) {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "multi_edit",
|
||||
filePath,
|
||||
edits: toolArgs.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>,
|
||||
});
|
||||
previews.push(toDiffPreview(result, basename(filePath)));
|
||||
} else {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "edit",
|
||||
filePath,
|
||||
oldString: (toolArgs.old_string as string) || "",
|
||||
newString: (toolArgs.new_string as string) || "",
|
||||
replaceAll: toolArgs.replace_all as boolean | undefined,
|
||||
});
|
||||
previews.push(toDiffPreview(result, basename(filePath)));
|
||||
}
|
||||
}
|
||||
} else if (isPatchTool(toolName) && toolArgs.input) {
|
||||
const operations = parsePatchOperations(toolArgs.input as string);
|
||||
for (const op of operations) {
|
||||
if (op.kind === "add" || op.kind === "update") {
|
||||
const result = parsePatchToAdvancedDiff(op.patchLines, op.path);
|
||||
if (result) {
|
||||
previews.push(toDiffPreview(result, basename(op.path)));
|
||||
}
|
||||
}
|
||||
// Delete operations don't produce diffs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore diff computation errors — return whatever we have so far
|
||||
}
|
||||
|
||||
return previews;
|
||||
}
|
||||
216
src/tests/helpers/diffPreview.test.ts
Normal file
216
src/tests/helpers/diffPreview.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type {
|
||||
AdvancedDiffFallback,
|
||||
AdvancedDiffSuccess,
|
||||
AdvancedDiffUnpreviewable,
|
||||
} from "../../cli/helpers/diff";
|
||||
import { computeDiffPreviews, toDiffPreview } from "../../helpers/diffPreview";
|
||||
|
||||
describe("toDiffPreview", () => {
|
||||
it("converts an AdvancedDiffSuccess to an advanced DiffPreview", () => {
|
||||
const input: AdvancedDiffSuccess = {
|
||||
mode: "advanced",
|
||||
fileName: "foo.ts",
|
||||
oldStr: "const a = 1;\n",
|
||||
newStr: "const a = 2;\n",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [{ raw: "-const a = 1;" }, { raw: "+const a = 2;" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input);
|
||||
|
||||
expect(result.mode).toBe("advanced");
|
||||
if (result.mode !== "advanced") throw new Error("unreachable");
|
||||
|
||||
expect(result.fileName).toBe("foo.ts");
|
||||
// oldStr/newStr must NOT appear on the wire type
|
||||
expect("oldStr" in result).toBe(false);
|
||||
expect("newStr" in result).toBe(false);
|
||||
|
||||
expect(result.hunks).toHaveLength(1);
|
||||
const hunk = result.hunks[0];
|
||||
expect(hunk?.oldStart).toBe(1);
|
||||
expect(hunk?.newStart).toBe(1);
|
||||
expect(hunk?.oldLines).toBe(1); // one remove
|
||||
expect(hunk?.newLines).toBe(1); // one add
|
||||
expect(hunk?.lines).toEqual([
|
||||
{ type: "remove", content: "const a = 1;" },
|
||||
{ type: "add", content: "const a = 2;" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes oldLines/newLines correctly for mixed hunks", () => {
|
||||
const input: AdvancedDiffSuccess = {
|
||||
mode: "advanced",
|
||||
fileName: "test.ts",
|
||||
oldStr: "",
|
||||
newStr: "",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 5,
|
||||
newStart: 5,
|
||||
lines: [
|
||||
{ raw: " context line" },
|
||||
{ raw: "-removed line 1" },
|
||||
{ raw: "-removed line 2" },
|
||||
{ raw: "+added line" },
|
||||
{ raw: " more context" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input);
|
||||
if (result.mode !== "advanced") throw new Error("unreachable");
|
||||
|
||||
const hunk = result.hunks[0];
|
||||
// context: 2 (contributes to both), remove: 2, add: 1
|
||||
expect(hunk?.oldLines).toBe(4); // 2 context + 2 remove
|
||||
expect(hunk?.newLines).toBe(3); // 2 context + 1 add
|
||||
});
|
||||
|
||||
it("parses empty raw lines as context", () => {
|
||||
const input: AdvancedDiffSuccess = {
|
||||
mode: "advanced",
|
||||
fileName: "empty.ts",
|
||||
oldStr: "",
|
||||
newStr: "",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [{ raw: "" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input);
|
||||
if (result.mode !== "advanced") throw new Error("unreachable");
|
||||
|
||||
expect(result.hunks[0]?.lines[0]).toEqual({
|
||||
type: "context",
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts fallback results with fileName", () => {
|
||||
const input: AdvancedDiffFallback = {
|
||||
mode: "fallback",
|
||||
reason: "File not readable",
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input, "myfile.ts");
|
||||
expect(result).toEqual({
|
||||
mode: "fallback",
|
||||
fileName: "myfile.ts",
|
||||
reason: "File not readable",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts unpreviewable results with fileName", () => {
|
||||
const input: AdvancedDiffUnpreviewable = {
|
||||
mode: "unpreviewable",
|
||||
reason: "Edit not found in file",
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input, "target.ts");
|
||||
expect(result).toEqual({
|
||||
mode: "unpreviewable",
|
||||
fileName: "target.ts",
|
||||
reason: "Edit not found in file",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses 'unknown' fileName when no override provided for fallback/unpreviewable", () => {
|
||||
const fallback: AdvancedDiffFallback = {
|
||||
mode: "fallback",
|
||||
reason: "File not readable",
|
||||
};
|
||||
expect(toDiffPreview(fallback).fileName).toBe("unknown");
|
||||
|
||||
const unpreviewable: AdvancedDiffUnpreviewable = {
|
||||
mode: "unpreviewable",
|
||||
reason: "reason",
|
||||
};
|
||||
expect(toDiffPreview(unpreviewable).fileName).toBe("unknown");
|
||||
});
|
||||
|
||||
it("allows fileName override on advanced results", () => {
|
||||
const input: AdvancedDiffSuccess = {
|
||||
mode: "advanced",
|
||||
fileName: "original.ts",
|
||||
oldStr: "",
|
||||
newStr: "",
|
||||
hunks: [],
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input, "overridden.ts");
|
||||
expect(result.fileName).toBe("overridden.ts");
|
||||
});
|
||||
|
||||
it("ignores no-newline metadata lines in advanced hunks", () => {
|
||||
const input: AdvancedDiffSuccess = {
|
||||
mode: "advanced",
|
||||
fileName: "x.txt",
|
||||
oldStr: "old-no-newline",
|
||||
newStr: "new-no-newline",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ raw: "-old-no-newline" },
|
||||
{ raw: "\\ No newline at end of file" },
|
||||
{ raw: "+new-no-newline" },
|
||||
{ raw: "\\ No newline at end of file" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = toDiffPreview(input);
|
||||
if (result.mode !== "advanced") throw new Error("unreachable");
|
||||
|
||||
const hunk = result.hunks[0];
|
||||
expect(hunk?.oldLines).toBe(1);
|
||||
expect(hunk?.newLines).toBe(1);
|
||||
expect(hunk?.lines).toEqual([
|
||||
{ type: "remove", content: "old-no-newline" },
|
||||
{ type: "add", content: "new-no-newline" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDiffPreviews", () => {
|
||||
it("returns one preview for write tool", async () => {
|
||||
const previews = await computeDiffPreviews("write", {
|
||||
file_path: "sample.txt",
|
||||
content: "hello",
|
||||
});
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0]?.mode).toBe("advanced");
|
||||
expect(previews[0]?.fileName).toBe("sample.txt");
|
||||
});
|
||||
|
||||
it("returns one preview per file for apply_patch", async () => {
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: a.txt",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: b.txt",
|
||||
"+hello",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const previews = await computeDiffPreviews("apply_patch", { input: patch });
|
||||
expect(previews).toHaveLength(2);
|
||||
expect(previews.map((p) => p.fileName).sort()).toEqual(["a.txt", "b.txt"]);
|
||||
});
|
||||
});
|
||||
@@ -369,6 +369,26 @@ export interface ExternalToolDefinition {
|
||||
parameters: Record<string, unknown>; // JSON Schema
|
||||
}
|
||||
|
||||
// --- Diff preview types (wire-safe, no CLI imports) ---
|
||||
|
||||
export interface DiffHunkLine {
|
||||
type: "context" | "add" | "remove";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: DiffHunkLine[];
|
||||
}
|
||||
|
||||
export type DiffPreview =
|
||||
| { mode: "advanced"; fileName: string; hunks: DiffHunk[] }
|
||||
| { mode: "fallback"; fileName: string; reason: string }
|
||||
| { mode: "unpreviewable"; fileName: string; reason: string };
|
||||
|
||||
// CLI → SDK request subtypes
|
||||
export interface CanUseToolControlRequest {
|
||||
subtype: "can_use_tool";
|
||||
@@ -379,6 +399,8 @@ export interface CanUseToolControlRequest {
|
||||
permission_suggestions: unknown[];
|
||||
/** TODO: Not implemented - path that triggered the permission check */
|
||||
blocked_path: string | null;
|
||||
/** Pre-computed diff previews for file-modifying tools (Write/Edit/Patch) */
|
||||
diffs?: DiffPreview[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user