openclaw / src /agents /sessions /tools /write.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
18.2 kB
// Write tool tests cover session path resolution and post-write recovery when
// remote or sandbox operations fail after persisting content.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it } from "vitest";
import { generateDiffString, generateUnifiedPatch } from "./edit-diff.js";
import { createWriteTool, type WriteOperations } from "./write.js";
describe("write tool", () => {
let tmpDir = "";
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = "";
}
});
async function createTempPath(name = "demo.txt") {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-tool-"));
return path.join(tmpDir, name);
}
function createRecoverableOperations(writeFile: WriteOperations["writeFile"]): WriteOperations {
return {
mkdir: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}),
writeFile,
readFile: (absolutePath) => fs.readFile(absolutePath),
statFile: async (absolutePath) => {
try {
const stat = await fs.stat(absolutePath);
return {
type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other",
size: stat.size,
mtimeMs: stat.mtimeMs,
} as const;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
) {
return null;
}
throw error;
}
},
};
}
it("recovers success after a post-write abort when readback matches requested content", async () => {
// Remote transports can report cancellation after the write landed; verify
// by readback before surfacing a false failure to the model.
const filePath = await createTempPath();
const expectedContent = "finished 😀\n";
const controller = new AbortController();
const tool = createWriteTool(tmpDir, {
operations: createRecoverableOperations(async (absolutePath, content) => {
await fs.writeFile(absolutePath, content, "utf-8");
controller.abort();
throw new Error("Operation aborted");
}),
});
const result = await tool.execute(
"call-1",
{ path: filePath, content: expectedContent },
controller.signal,
);
expect(result.content[0]).toEqual({
type: "text",
text: `Successfully wrote ${Buffer.byteLength(expectedContent, "utf8")} bytes to ${filePath}`,
});
});
it("keeps the original abort when the file already matched before execution", async () => {
// Matching pre-existing content is not proof this call wrote successfully.
const filePath = await createTempPath();
await fs.writeFile(filePath, "finished\n", "utf-8");
const controller = new AbortController();
controller.abort();
const tool = createWriteTool(tmpDir, {
operations: createRecoverableOperations(async () => {
throw new Error("Operation aborted");
}),
});
await expect(
tool.execute("call-1", { path: filePath, content: "finished\n" }, controller.signal),
).rejects.toThrow("Operation aborted");
});
it("recovers timeout-like post-write errors when readback matches requested content", async () => {
const filePath = await createTempPath();
const tool = createWriteTool(tmpDir, {
operations: createRecoverableOperations(async (absolutePath, content) => {
await fs.writeFile(absolutePath, content, "utf-8");
throw new Error("node invoke timed out");
}),
});
const result = await tool.execute(
"call-1",
{ path: filePath, content: "finished\n" },
undefined,
);
expect(result.content[0]?.type).toBe("text");
});
it("rejects a delegated write that resolves without creating the file", async () => {
const filePath = await createTempPath("missing.txt");
const tool = createWriteTool(tmpDir, {
operations: createRecoverableOperations(async () => {}),
});
await expect(
tool.execute("call-1", { path: filePath, content: "expected\n" }, undefined),
).rejects.toThrow("Write verification failed");
await expect(fs.stat(filePath)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each([
{ name: "text", original: Buffer.from("stale\n"), content: "fresh\n" },
{ name: "invalid UTF-8", original: Buffer.from([0xf0, 0x90, 0x80]), content: "\uFFFD" },
])(
"rejects a delegated write that leaves stale same-size bytes: $name",
async ({ original, content }) => {
const filePath = await createTempPath("stale.txt");
await fs.writeFile(filePath, original);
const tool = createWriteTool(tmpDir, {
operations: createRecoverableOperations(async () => {}),
});
await expect(tool.execute("call-1", { path: filePath, content }, undefined)).rejects.toThrow(
"Write verification failed",
);
await expect(fs.readFile(filePath)).resolves.toEqual(original);
},
);
it("rejects a delegated write that leaves a non-file target", async () => {
const filePath = await createTempPath("directory");
await fs.mkdir(filePath);
const tool = createWriteTool(tmpDir, {
operations: createRecoverableOperations(async () => {}),
});
await expect(
tool.execute("call-1", { path: filePath, content: "expected\n" }, undefined),
).rejects.toThrow("Write verification failed");
});
it.each(["buffer", "text"] as const)(
"verifies delegated UTF-8 bytes with %s readback",
async (readback) => {
const filePath = await createTempPath("surrogate.txt");
const content = "unpaired \ud800 surrogate\n";
const operations = createRecoverableOperations((absolutePath, requestedContent) =>
fs.writeFile(absolutePath, requestedContent, "utf-8"),
);
if (readback === "text") {
operations.readFile = (absolutePath) => fs.readFile(absolutePath, "utf8");
}
const tool = createWriteTool(tmpDir, { operations });
await expect(
tool.execute("call-1", { path: filePath, content }, undefined),
).resolves.toMatchObject({ details: { changed: true, created: true } });
await expect(fs.readFile(filePath)).resolves.toEqual(Buffer.from(content, "utf8"));
const noOpResult = await tool.execute("call-2", { path: filePath, content }, undefined);
expect(noOpResult).toMatchObject({ details: { changed: false } });
expect((noOpResult as { terminate?: boolean }).terminate).toBeUndefined();
},
);
it("overwrites invalid UTF-8 bytes that decode to the requested text", async () => {
const filePath = await createTempPath("invalid.txt");
await fs.writeFile(filePath, Buffer.from([0xf0, 0x90, 0x80]));
const tool = createWriteTool(tmpDir);
await expect(
tool.execute("call-1", { path: filePath, content: "\uFFFD" }, undefined),
).resolves.toMatchObject({ details: { changed: true, created: false } });
await expect(fs.readFile(filePath)).resolves.toEqual(Buffer.from("\uFFFD", "utf8"));
});
it("writes file URL paths through the shared session path resolver", async () => {
const filePath = await createTempPath("notes.md");
const tool = createWriteTool(tmpDir);
await tool.execute(
"call-1",
{ path: pathToFileURL(filePath).href, content: "finished\n" },
undefined,
);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("finished\n");
});
it("writes the literal Unicode-space path instead of an ASCII-space sibling", async () => {
const nnbspPath = await createTempPath("report 2026.md");
const asciiPath = path.join(tmpDir, "report 2026.md");
await fs.writeFile(asciiPath, "ascii\n", "utf-8");
const tool = createWriteTool(tmpDir);
await tool.execute("call-1", { path: nnbspPath, content: "nnbsp\n" }, undefined);
await expect(fs.readFile(nnbspPath, "utf-8")).resolves.toBe("nnbsp\n");
await expect(fs.readFile(asciiPath, "utf-8")).resolves.toBe("ascii\n");
});
it.each(["hello\n", "café 🦀\r\n日本語 e\u0301\r\n", "\uFFFD\r\n"])(
"returns a non-terminal no-op for identical UTF-8 content: %j",
async (content) => {
const filePath = await createTempPath("identical.txt");
await fs.writeFile(filePath, content, "utf-8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute("call-1", { path: "identical.txt", content }, undefined);
const tc0 = expectDefined(result.content[0], "result.content[0] test invariant");
expect("text" in tc0 ? tc0.text : "").toContain("No changes made");
expect((result as { terminate?: boolean }).terminate).toBeUndefined();
expect(result.details).toEqual({ changed: false });
await expect(fs.readFile(filePath)).resolves.toEqual(Buffer.from(content, "utf8"));
},
);
it("reports a created file with its authoritative diff", async () => {
await createTempPath("created.txt");
const content = "first\nsecond\n";
const tool = createWriteTool(tmpDir);
const result = await tool.execute("call-1", { path: "created.txt", content }, undefined);
const diffResult = generateDiffString("", content);
expect(result.details).toEqual({
changed: true,
created: true,
diff: diffResult.diff,
patch: generateUnifiedPatch("created.txt", "", content),
firstChangedLine: diffResult.firstChangedLine,
});
});
it("keeps oversized created-file details bounded", async () => {
await createTempPath("large-created.txt");
const content = "x".repeat(1024 * 1024 + 1);
const tool = createWriteTool(tmpDir);
const result = await tool.execute("call-1", { path: "large-created.txt", content }, undefined);
expect(result.details).toEqual({ changed: true, created: true });
});
it("reports an overwrite with the readable old-content diff", async () => {
const filePath = await createTempPath("different.txt");
const content = "new 😀\n";
const oldContent = "old\n";
await fs.writeFile(filePath, oldContent, "utf-8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute("call-1", { path: "different.txt", content }, undefined);
const diffResult = generateDiffString(oldContent, content);
expect(result.content[0]).toEqual({
type: "text",
text: `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to different.txt`,
});
expect(result.details).toEqual({
changed: true,
created: false,
diff: diffResult.diff,
patch: generateUnifiedPatch("different.txt", oldContent, content),
firstChangedLine: diffResult.firstChangedLine,
});
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(content);
});
it.each([
{ name: "insert at start", oldContent: "a\nb\n", content: "first\na\nb\n" },
{ name: "delete at end", oldContent: "a\nb\nlast\n", content: "a\nb\n" },
{ name: "empty overwrite", oldContent: "last\n", content: "" },
{ name: "remove final newline", oldContent: "last\n", content: "last" },
{ name: "add final newline", oldContent: "last", content: "last\n" },
{
name: "CRLF and Unicode without final newline",
oldContent: "café 🦀\r\n日本語 e\u0301\r\nlast",
content: "café 😀\r\n日本語 é\r\nlast",
},
...[7, 8, 9].map((gap) => {
const middle = Array.from({ length: gap }, (_, i) => `context-${i}\n`).join("");
return {
name: `${gap} context lines between edits`,
oldContent: `before\n${middle}after\n`,
content: `BEFORE\n${middle}AFTER\n`,
};
}),
])("preserves both receipt formats for $name", async ({ oldContent, content }) => {
const filePath = await createTempPath("receipt.txt");
await fs.writeFile(filePath, oldContent, "utf8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute("call-1", { path: "receipt.txt", content }, undefined);
const diffResult = generateDiffString(oldContent, content);
expect(result.details).toEqual({
changed: true,
created: false,
diff: diffResult.diff,
patch: generateUnifiedPatch("receipt.txt", oldContent, content),
firstChangedLine: diffResult.firstChangedLine,
});
await expect(fs.readFile(filePath)).resolves.toEqual(Buffer.from(content, "utf8"));
});
it.each([1999, 2000, 2001])(
"preserves the overwrite receipt budget at edit distance %i",
async (editDistance) => {
const filePath = await createTempPath("edit-limit.txt");
const oldContent = "anchor\n";
const content = oldContent + "added\n".repeat(editDistance);
await fs.writeFile(filePath, oldContent, "utf8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute("call-1", { path: "edit-limit.txt", content }, undefined);
if (editDistance <= 2000) {
const diffResult = generateDiffString(oldContent, content);
expect(result.details).toEqual({
changed: true,
created: false,
diff: diffResult.diff,
patch: generateUnifiedPatch("edit-limit.txt", oldContent, content),
firstChangedLine: diffResult.firstChangedLine,
});
} else {
expect(result.details).toEqual({ changed: true, created: false });
}
await expect(fs.readFile(filePath)).resolves.toEqual(Buffer.from(content, "utf8"));
},
);
it("omits the diff when the old content is not valid UTF-8 text", async () => {
const filePath = await createTempPath("binary.bin");
await fs.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe]));
const tool = createWriteTool(tmpDir);
const result = await tool.execute(
"call-1",
{ path: "binary.bin", content: "text now\n" },
undefined,
);
expect(result.details).toEqual({ changed: true, created: false });
});
it("omits the diff when the rewrite's edit distance blows the budget", async () => {
const filePath = await createTempPath("distinct-lines.txt");
const oldContent = Array.from({ length: 10_000 }, (_, i) => `old-${i}`).join("\n");
await fs.writeFile(filePath, oldContent, "utf-8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute(
"call-1",
{
path: "distinct-lines.txt",
content: Array.from({ length: 10_000 }, (_, i) => `new-${i}`).join("\n"),
},
undefined,
);
expect(result.details).toEqual({ changed: true, created: false });
});
it("omits the diff for created files with excessive line counts", async () => {
await createTempPath("many-lines-created.txt");
const tool = createWriteTool(tmpDir);
const result = await tool.execute(
"call-1",
{ path: "many-lines-created.txt", content: "a\n".repeat(25_000) },
undefined,
);
expect(result.details).toEqual({ changed: true, created: true });
});
it("omits the diff when combined line counts exceed the diff budget", async () => {
const filePath = await createTempPath("many-lines.txt");
await fs.writeFile(filePath, "a\n".repeat(15_000), "utf-8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute(
"call-1",
{ path: "many-lines.txt", content: "b\n".repeat(15_000) },
undefined,
);
expect(result.details).toEqual({ changed: true, created: false });
});
it("omits the diff when combined old and new content exceeds the diff budget", async () => {
const filePath = await createTempPath("combined.txt");
await fs.writeFile(filePath, "a".repeat(600 * 1024), "utf-8");
const tool = createWriteTool(tmpDir);
const result = await tool.execute(
"call-1",
{ path: "combined.txt", content: "b".repeat(600 * 1024) },
undefined,
);
expect(result.details).toEqual({ changed: true, created: false });
});
it("reports an overwrite without a fabricated diff when the old file is too large", async () => {
const filePath = await createTempPath("large.txt");
await fs.writeFile(filePath, "x".repeat(1024 * 1024 + 1), "utf-8");
let readCount = 0;
const operations = createRecoverableOperations((absolutePath, content) =>
fs.writeFile(absolutePath, content, "utf-8"),
);
operations.readFile = async (absolutePath) => {
readCount += 1;
return fs.readFile(absolutePath);
};
const tool = createWriteTool(tmpDir, { operations });
const result = await tool.execute(
"call-1",
{ path: "large.txt", content: "replacement\n" },
undefined,
);
expect(readCount).toBe(1);
expect(result.details).toEqual({ changed: true, created: false });
});
it("keeps oversized overwrite details bounded", async () => {
const filePath = await createTempPath("large-replacement.txt");
await fs.writeFile(filePath, "old\n", "utf-8");
const content = "x".repeat(1024 * 1024 + 1);
const tool = createWriteTool(tmpDir);
const result = await tool.execute(
"call-1",
{ path: "large-replacement.txt", content },
undefined,
);
expect(result.details).toEqual({ changed: true, created: false });
});
it("rejects success when the post-write stat is unavailable", async () => {
await createTempPath("unknown.txt");
const operations: WriteOperations = {
mkdir: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}),
writeFile: (absolutePath, content) => fs.writeFile(absolutePath, content, "utf-8"),
readFile: (absolutePath) => fs.readFile(absolutePath),
statFile: async () => {
throw new Error("remote stat unavailable");
},
};
const tool = createWriteTool(tmpDir, { operations });
await expect(
tool.execute("call-1", { path: "unknown.txt", content: "new\n" }, undefined),
).rejects.toThrow("Write verification failed");
});
});