fix(coding-agent): queue file mutations across edit and write

closes #2327
This commit is contained in:
Mario Zechner
2026-03-20 01:10:56 +01:00
parent 527c2af5e7
commit 74a46fc7ea
12 changed files with 481 additions and 220 deletions

View File

@@ -1354,6 +1354,36 @@ Use `promptGuidelines` to add tool-specific bullets to the default system prompt
Note: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well. Note: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well.
If your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`. This matters because tool calls run in parallel by default. Without the queue, two tools can read the same old file contents, compute different updates, and then whichever write lands last overwrites the other.
Example failure case: your custom tool edits `foo.ts` while built-in `edit` also changes `foo.ts` in the same assistant turn. If your tool does not participate in the queue, both can read the original `foo.ts`, apply separate changes, and one of those changes is lost.
Pass the real target file path to `withFileMutationQueue()`, not the raw user argument. Resolve it to an absolute path first, relative to `ctx.cwd` or your tool's working directory. For existing files, the helper canonicalizes through `realpath()`, so symlink aliases for the same file share one queue. For new files, it falls back to the resolved absolute path because there is nothing to `realpath()` yet.
Queue the entire mutation window on that target path. That includes read-modify-write logic, not just the final write.
```typescript
import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const absolutePath = resolve(ctx.cwd, params.path);
return withFileMutationQueue(absolutePath, async () => {
await mkdir(dirname(absolutePath), { recursive: true });
const current = await readFile(absolutePath, "utf8");
const next = current.replace(params.oldText, params.newText);
await writeFile(absolutePath, next, "utf8");
return {
content: [{ type: "text", text: `Updated ${params.path}` }],
details: {},
};
});
}
```
### Tool Definition ### Tool Definition
```typescript ```typescript
@@ -1617,7 +1647,7 @@ renderResult(result, { expanded, isPartial }, theme) {
#### Keybinding Hints #### Keybinding Hints
Use `keyHint()` to display keybinding hints that respect user's keybinding configuration: Use `keyHint()` to display keybinding hints that respect the active keybinding configuration:
```typescript ```typescript
import { keyHint } from "@mariozechner/pi-coding-agent"; import { keyHint } from "@mariozechner/pi-coding-agent";
@@ -1625,18 +1655,23 @@ import { keyHint } from "@mariozechner/pi-coding-agent";
renderResult(result, { expanded }, theme) { renderResult(result, { expanded }, theme) {
let text = theme.fg("success", "✓ Done"); let text = theme.fg("success", "✓ Done");
if (!expanded) { if (!expanded) {
text += ` (${keyHint("expandTools", "to expand")})`; text += ` (${keyHint("app.tools.expand", "to expand")})`;
} }
return new Text(text, 0, 0); return new Text(text, 0, 0);
} }
``` ```
Available functions: Available functions:
- `keyHint(action, description)` - Editor actions (e.g., `"expandTools"`, `"selectConfirm"`) - `keyHint(keybinding, description)` - Formats a configured keybinding id such as `"app.tools.expand"` or `"tui.select.confirm"`
- `appKeyHint(keybindings, action, description)` - App actions (requires `KeybindingsManager`) - `keyText(keybinding)` - Returns the raw configured key text for a keybinding id
- `editorKey(action)` - Get raw key string for editor action
- `rawKeyHint(key, description)` - Format a raw key string - `rawKeyHint(key, description)` - Format a raw key string
Use namespaced keybinding ids:
- Coding-agent ids use the `app.*` namespace, for example `app.tools.expand`, `app.editor.external`, `app.session.rename`
- Shared TUI ids use the `tui.*` namespace, for example `tui.select.confirm`, `tui.select.cancel`, `tui.input.tab`
Custom editors and `ctx.ui.custom()` components receive `keybindings: KeybindingsManager` as an injected argument. They should use that injected manager directly instead of calling `getKeybindings()` or `setKeybindings()`.
#### Best Practices #### Best Practices
- Use `Text` with padding `(0, 0)` - the Box handles padding - Use `Text` with padding `(0, 0)` - the Box handles padding

View File

@@ -30,7 +30,7 @@ import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { StringEnum } from "@mariozechner/pi-ai"; import { StringEnum } from "@mariozechner/pi-ai";
import { type ExtensionAPI, getAgentDir } from "@mariozechner/pi-coding-agent"; import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from "@mariozechner/pi-coding-agent";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
const PROVIDER = "google-antigravity"; const PROVIDER = "google-antigravity";
@@ -228,12 +228,14 @@ function imageExtension(mimeType: string): string {
} }
async function saveImage(base64Data: string, mimeType: string, outputDir: string): Promise<string> { async function saveImage(base64Data: string, mimeType: string, outputDir: string): Promise<string> {
await mkdir(outputDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const ext = imageExtension(mimeType); const ext = imageExtension(mimeType);
const filename = `image-${timestamp}-${randomUUID().slice(0, 8)}.${ext}`; const filename = `image-${timestamp}-${randomUUID().slice(0, 8)}.${ext}`;
const filePath = join(outputDir, filename); const filePath = join(outputDir, filename);
await withFileMutationQueue(filePath, async () => {
await mkdir(outputDir, { recursive: true });
await writeFile(filePath, Buffer.from(base64Data, "base64")); await writeFile(filePath, Buffer.from(base64Data, "base64"));
});
return filePath; return filePath;
} }

View File

@@ -19,7 +19,7 @@ import * as path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai"; import type { Message } from "@mariozechner/pi-ai";
import { StringEnum } from "@mariozechner/pi-ai"; import { StringEnum } from "@mariozechner/pi-ai";
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@mariozechner/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
@@ -207,11 +207,13 @@ async function mapWithConcurrencyLimit<TIn, TOut>(
return results; return results;
} }
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } { async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-")); const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_"); const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`); const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 }); await withFileMutationQueue(filePath, async () => {
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
});
return { dir: tmpDir, filePath }; return { dir: tmpDir, filePath };
} }
@@ -274,7 +276,7 @@ async function runSingleAgent(
try { try {
if (agent.systemPrompt.trim()) { if (agent.systemPrompt.trim()) {
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt); const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
tmpPromptDir = tmp.dir; tmpPromptDir = tmp.dir;
tmpPromptPath = tmp.filePath; tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath); args.push("--append-system-prompt", tmpPromptPath);

View File

@@ -21,10 +21,10 @@
*/ */
import type { TextContent } from "@mariozechner/pi-ai"; import type { TextContent } from "@mariozechner/pi-ai";
import { type ExtensionAPI, getAgentDir } from "@mariozechner/pi-coding-agent"; import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { appendFileSync, constants, readFileSync } from "fs"; import { constants, readFileSync } from "fs";
import { access, readFile } from "fs/promises"; import { access, appendFile, readFile } from "fs/promises";
import { join, resolve } from "path"; import { join, resolve } from "path";
const LOG_FILE = join(getAgentDir(), "read-access.log"); const LOG_FILE = join(getAgentDir(), "read-access.log");
@@ -44,14 +44,16 @@ function isBlockedPath(path: string): boolean {
return BLOCKED_PATTERNS.some((pattern) => pattern.test(path)); return BLOCKED_PATTERNS.some((pattern) => pattern.test(path));
} }
function logAccess(path: string, allowed: boolean, reason?: string) { async function logAccess(path: string, allowed: boolean, reason?: string) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const status = allowed ? "ALLOWED" : "BLOCKED"; const status = allowed ? "ALLOWED" : "BLOCKED";
const msg = reason ? ` (${reason})` : ""; const msg = reason ? ` (${reason})` : "";
const line = `[${timestamp}] ${status}: ${path}${msg}\n`; const line = `[${timestamp}] ${status}: ${path}${msg}\n`;
try { try {
appendFileSync(LOG_FILE, line); await withFileMutationQueue(LOG_FILE, async () => {
await appendFile(LOG_FILE, line);
});
} catch { } catch {
// Ignore logging errors // Ignore logging errors
} }
@@ -77,7 +79,7 @@ export default function (pi: ExtensionAPI) {
// Check if path is blocked // Check if path is blocked
if (isBlockedPath(absolutePath)) { if (isBlockedPath(absolutePath)) {
logAccess(absolutePath, false, "matches blocked pattern"); await logAccess(absolutePath, false, "matches blocked pattern");
return { return {
content: [ content: [
{ {
@@ -90,7 +92,7 @@ export default function (pi: ExtensionAPI) {
} }
// Log allowed access // Log allowed access
logAccess(absolutePath, true); await logAccess(absolutePath, true);
// Perform the actual read (simplified implementation) // Perform the actual read (simplified implementation)
try { try {

View File

@@ -14,6 +14,7 @@
* built-in `grep` tool in src/core/tools/grep.ts for a more complete implementation. * built-in `grep` tool in src/core/tools/grep.ts for a more complete implementation.
*/ */
import { mkdtemp, writeFile } from "node:fs/promises";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { import {
DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES,
@@ -21,11 +22,11 @@ import {
formatSize, formatSize,
type TruncationResult, type TruncationResult,
truncateHead, truncateHead,
withFileMutationQueue,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui"; import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { mkdtempSync, writeFileSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
@@ -108,9 +109,11 @@ export default function (pi: ExtensionAPI) {
if (truncation.truncated) { if (truncation.truncated) {
// Save full output to a temp file so LLM can access it if needed // Save full output to a temp file so LLM can access it if needed
const tempDir = mkdtempSync(join(tmpdir(), "pi-rg-")); const tempDir = await mkdtemp(join(tmpdir(), "pi-rg-"));
const tempFile = join(tempDir, "output.txt"); const tempFile = join(tempDir, "output.txt");
writeFileSync(tempFile, output); await withFileMutationQueue(tempFile, async () => {
await writeFile(tempFile, output, "utf8");
});
details.truncation = truncation; details.truncation = truncation;
details.fullOutputPath = tempFile; details.fullOutputPath = tempFile;

View File

@@ -35,6 +35,7 @@ import {
readTool, readTool,
type Tool, type Tool,
type ToolName, type ToolName,
withFileMutationQueue,
writeTool, writeTool,
} from "./tools/index.js"; } from "./tools/index.js";
@@ -109,6 +110,7 @@ export {
codingTools, codingTools,
readOnlyTools, readOnlyTools,
allTools as allBuiltInTools, allTools as allBuiltInTools,
withFileMutationQueue,
// Tool factories (for custom cwd) // Tool factories (for custom cwd)
createCodingTools, createCodingTools,
createReadOnlyTools, createReadOnlyTools,

View File

@@ -11,6 +11,7 @@ import {
restoreLineEndings, restoreLineEndings,
stripBom, stripBom,
} from "./edit-diff.js"; } from "./edit-diff.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js"; import { resolveToCwd } from "./path-utils.js";
const editSchema = Type.Object({ const editSchema = Type.Object({
@@ -68,7 +69,10 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
) => { ) => {
const absolutePath = resolveToCwd(path, cwd); const absolutePath = resolveToCwd(path, cwd);
return new Promise<{ return withFileMutationQueue(
absolutePath,
() =>
new Promise<{
content: Array<{ type: "text"; text: string }>; content: Array<{ type: "text"; text: string }>;
details: EditToolDetails | undefined; details: EditToolDetails | undefined;
}>((resolve, reject) => { }>((resolve, reject) => {
@@ -218,7 +222,8 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
} }
} }
})(); })();
}); }),
);
}, },
}; };
} }

View File

@@ -0,0 +1,39 @@
import { realpath } from "node:fs/promises";
import { resolve } from "node:path";
const fileMutationQueues = new Map<string, Promise<void>>();
async function getMutationQueueKey(filePath: string): Promise<string> {
const resolvedPath = resolve(filePath);
try {
return await realpath(resolvedPath);
} catch {
return resolvedPath;
}
}
/**
* Serialize file mutation operations targeting the same file.
* Operations for different files still run in parallel.
*/
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const key = await getMutationQueueKey(filePath);
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const nextQueue = new Promise<void>((resolveQueue) => {
releaseNext = resolveQueue;
});
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
await currentQueue;
try {
return await fn();
} finally {
releaseNext();
if (fileMutationQueues.get(key) === chainedQueue) {
fileMutationQueues.delete(key);
}
}
}

View File

@@ -17,6 +17,7 @@ export {
type EditToolOptions, type EditToolOptions,
editTool, editTool,
} from "./edit.js"; } from "./edit.js";
export { withFileMutationQueue } from "./file-mutation-queue.js";
export { export {
createFindTool, createFindTool,
type FindOperations, type FindOperations,

View File

@@ -2,6 +2,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path"; import { dirname } from "path";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js"; import { resolveToCwd } from "./path-utils.js";
const writeSchema = Type.Object({ const writeSchema = Type.Object({
@@ -49,7 +50,10 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
const absolutePath = resolveToCwd(path, cwd); const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath); const dir = dirname(absolutePath);
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>( return withFileMutationQueue(
absolutePath,
() =>
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
(resolve, reject) => { (resolve, reject) => {
// Check if already aborted // Check if already aborted
if (signal?.aborted) { if (signal?.aborted) {
@@ -94,7 +98,9 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
} }
resolve({ resolve({
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], content: [
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
],
details: undefined, details: undefined,
}); });
} catch (error: any) { } catch (error: any) {
@@ -109,6 +115,7 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
} }
})(); })();
}, },
),
); );
}, },
}; };

View File

@@ -53,7 +53,7 @@ export type {
AgentStartEvent, AgentStartEvent,
AgentToolResult, AgentToolResult,
AgentToolUpdateCallback, AgentToolUpdateCallback,
AppAction, AppKeybinding,
BashToolCallEvent, BashToolCallEvent,
BeforeAgentStartEvent, BeforeAgentStartEvent,
BeforeProviderRequestEvent, BeforeProviderRequestEvent,
@@ -261,6 +261,7 @@ export {
type WriteOperations, type WriteOperations,
type WriteToolInput, type WriteToolInput,
type WriteToolOptions, type WriteToolOptions,
withFileMutationQueue,
writeTool, writeTool,
} from "./core/tools/index.js"; } from "./core/tools/index.js";
// Main entry point // Main entry point
@@ -277,8 +278,6 @@ export {
export { export {
ArminComponent, ArminComponent,
AssistantMessageComponent, AssistantMessageComponent,
appKey,
appKeyHint,
BashExecutionComponent, BashExecutionComponent,
BorderedLoader, BorderedLoader,
BranchSummaryMessageComponent, BranchSummaryMessageComponent,
@@ -289,9 +288,9 @@ export {
ExtensionEditorComponent, ExtensionEditorComponent,
ExtensionInputComponent, ExtensionInputComponent,
ExtensionSelectorComponent, ExtensionSelectorComponent,
editorKey,
FooterComponent, FooterComponent,
keyHint, keyHint,
keyText,
LoginDialogComponent, LoginDialogComponent,
ModelSelectorComponent, ModelSelectorComponent,
OAuthSelectorComponent, OAuthSelectorComponent,

View File

@@ -0,0 +1,164 @@
import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createEditTool } from "../src/core/tools/edit.js";
import { withFileMutationQueue } from "../src/core/tools/file-mutation-queue.js";
import { createWriteTool } from "../src/core/tools/write.js";
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const tempDirs: string[] = [];
async function createTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "pi-file-mutation-queue-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("withFileMutationQueue", () => {
it("serializes operations for the same file", async () => {
const order: string[] = [];
const path = "/tmp/file-mutation-queue-same";
const first = withFileMutationQueue(path, async () => {
order.push("first:start");
await delay(30);
order.push("first:end");
});
const second = withFileMutationQueue(path, async () => {
order.push("second:start");
order.push("second:end");
});
await Promise.all([first, second]);
expect(order).toEqual(["first:start", "first:end", "second:start", "second:end"]);
});
it("allows different files to proceed in parallel", async () => {
const order: string[] = [];
await Promise.all([
withFileMutationQueue("/tmp/file-mutation-queue-a", async () => {
order.push("a:start");
await delay(30);
order.push("a:end");
}),
withFileMutationQueue("/tmp/file-mutation-queue-b", async () => {
order.push("b:start");
await delay(30);
order.push("b:end");
}),
]);
expect(order.indexOf("a:start")).toBeLessThan(order.indexOf("a:end"));
expect(order.indexOf("b:start")).toBeLessThan(order.indexOf("b:end"));
expect(order.indexOf("b:start")).toBeLessThan(order.indexOf("a:end"));
});
it("uses the same queue for symlink aliases", async () => {
const dir = await createTempDir();
const targetPath = join(dir, "target.txt");
const symlinkPath = join(dir, "alias.txt");
await writeFile(targetPath, "hello\n", "utf8");
await symlink(targetPath, symlinkPath);
const order: string[] = [];
await Promise.all([
withFileMutationQueue(targetPath, async () => {
order.push("target:start");
await delay(30);
order.push("target:end");
}),
withFileMutationQueue(symlinkPath, async () => {
order.push("alias:start");
order.push("alias:end");
}),
]);
expect(order).toEqual(["target:start", "target:end", "alias:start", "alias:end"]);
});
});
describe("built-in edit and write tools", () => {
it("preserves both parallel edits on the same file", async () => {
const dir = await createTempDir();
const filePath = join(dir, "parallel-edit.txt");
await writeFile(filePath, "alpha\nbeta\ngamma\n", "utf8");
const editTool = createEditTool(dir, {
operations: {
access,
readFile: async (path) => {
const buffer = await readFile(path);
await delay(30);
return buffer;
},
writeFile: async (path, content) => {
await delay(30);
await writeFile(path, content, "utf8");
},
},
});
await Promise.all([
editTool.execute("call-1", { path: filePath, oldText: "alpha", newText: "ALPHA" }),
editTool.execute("call-2", { path: filePath, oldText: "beta", newText: "BETA" }),
]);
const content = await readFile(filePath, "utf8");
expect(content).toBe("ALPHA\nBETA\ngamma\n");
});
it("shares the queue between edit and write", async () => {
const dir = await createTempDir();
const filePath = join(dir, "mixed.txt");
await writeFile(filePath, "original\n", "utf8");
const editTool = createEditTool(dir, {
operations: {
access,
readFile: async (path) => {
const buffer = await readFile(path);
await delay(30);
return buffer;
},
writeFile: async (path, content) => {
await delay(30);
await writeFile(path, content, "utf8");
},
},
});
const writeTool = createWriteTool(dir, {
operations: {
mkdir: async () => {},
writeFile: async (path, content) => {
await delay(10);
await writeFile(path, content, "utf8");
},
},
});
const editPromise = editTool.execute("call-1", {
path: filePath,
oldText: "original",
newText: "edited",
});
await delay(5);
const writePromise = writeTool.execute("call-2", {
path: filePath,
content: "replacement\n",
});
await Promise.all([editPromise, writePromise]);
const content = await readFile(filePath, "utf8");
expect(content).toBe("replacement\n");
});
});