@wierdbytes/pi-facelift 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  import * as childProcess from "node:child_process";
27
27
  import { existsSync, readFileSync } from "node:fs";
28
- import { basename, dirname, extname, join, relative } from "node:path";
28
+ import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path";
29
29
 
30
30
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
31
31
  import type {
@@ -1782,6 +1782,52 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1782
1782
  },
1783
1783
  });
1784
1784
 
1785
+ // ===================================================================
1786
+ // Per-path mutation lock
1787
+ // -------------------------------------------------------------------
1788
+ // Both `write` and `edit` snapshot the file *before* delegating to the
1789
+ // built-in tool's execute(). When two mutations to the same path are
1790
+ // queued concurrently the built-in tool serializes the actual write,
1791
+ // but our pre-execute snapshots run in parallel — so the second call
1792
+ // would diff against the file state from *before* the first call, not
1793
+ // the state the first call left behind. Result: stale, misleading
1794
+ // diffs in the rendered output.
1795
+ //
1796
+ // We fix it here (not in the built-in tool) by chaining
1797
+ // snapshot→execute pairs per resolved path. The chain is purely
1798
+ // in-process and only covers mutations going through our wrapper;
1799
+ // external writers (other processes, the user) are still racy, but
1800
+ // that's the same correctness contract the built-in tool already has.
1801
+ // ===================================================================
1802
+
1803
+ const pathMutationTails = new Map<string, Promise<void>>();
1804
+ function serializePerPath<T>(path: string, fn: () => Promise<T>): Promise<T> {
1805
+ if (!path) return fn();
1806
+ const key = (() => {
1807
+ try {
1808
+ return resolvePath(path);
1809
+ } catch {
1810
+ return path;
1811
+ }
1812
+ })();
1813
+ const prev = pathMutationTails.get(key) ?? Promise.resolve();
1814
+ // `prev.then(fn, fn)` — run fn after prev settles, regardless of
1815
+ // whether prev rejected. We must not let an earlier failure poison
1816
+ // later snapshots on the same path.
1817
+ const result = prev.then(fn, fn);
1818
+ const tail = result.then(
1819
+ () => undefined,
1820
+ () => undefined,
1821
+ );
1822
+ pathMutationTails.set(key, tail);
1823
+ tail.then(() => {
1824
+ // Drop the entry only if no newer caller has chained onto us;
1825
+ // otherwise we'd unblock callers waiting in the chain.
1826
+ if (pathMutationTails.get(key) === tail) pathMutationTails.delete(key);
1827
+ });
1828
+ return result;
1829
+ }
1830
+
1785
1831
  // ===================================================================
1786
1832
  // write — split diff (or syntax-highlighted preview for new files)
1787
1833
  // ===================================================================
@@ -1802,14 +1848,19 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1802
1848
  ctx: ExtensionContext,
1803
1849
  ) {
1804
1850
  const fp = params.path ?? "";
1805
- let oldContent: string | null = null;
1806
- try {
1807
- if (fp && existsSync(fp)) oldContent = readFileSync(fp, "utf-8");
1808
- } catch {
1809
- oldContent = null;
1810
- }
1811
-
1812
- const result = (await origWrite.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1851
+ // Snapshot + execute must run atomically per-path so concurrent
1852
+ // writes to the same file don't both diff against the same
1853
+ // stale pre-state.
1854
+ const { oldContent, result } = await serializePerPath(fp, async () => {
1855
+ let snapshot: string | null = null;
1856
+ try {
1857
+ if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
1858
+ } catch {
1859
+ snapshot = null;
1860
+ }
1861
+ const r = (await origWrite.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1862
+ return { oldContent: snapshot, result: r };
1863
+ });
1813
1864
  const newContent = params.content ?? "";
1814
1865
  const language = diffLang(fp);
1815
1866
 
@@ -1875,6 +1926,9 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1875
1926
 
1876
1927
  if (d?._type === "writeNew") {
1877
1928
  const lab = `${FG_GREEN}✓ new file${RST} ${FG_DIM}(${d.lineCount} lines)${RST}`;
1929
+ // Diff/preview output is always rendered in full — no compact
1930
+ // mode, no `… N more lines` footer — so `ctx.expanded` is
1931
+ // irrelevant to the rendered body and stays out of the key.
1878
1932
  const key = `writeNew:${themeCacheKey(theme)}:${d.filePath}:${d.lineCount}:${w}:${status}`;
1879
1933
  if (ctx.state._wk !== key) {
1880
1934
  ctx.state._wk = key;
@@ -1883,11 +1937,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1883
1937
  hlDiffBlock(d.content, d.language)
1884
1938
  .then((hlLines: string[]) => {
1885
1939
  if (ctx.state._wk !== key) return;
1886
- const maxShow = ctx.expanded ? hlLines.length : MAX_PREVIEW_LINES;
1887
- const preview = hlLines.slice(0, maxShow).join("\n");
1888
- const rem = hlLines.length - maxShow;
1889
- const body =
1890
- rem > 0 ? `${preview}\n${FG_DIM}… ${rem} more lines${RST}` : preview;
1940
+ const body = hlLines.join("\n");
1891
1941
  ctx.state._wt = frameResultWithBottomLabel(body, lab, status, t, w);
1892
1942
  ctx.invalidate();
1893
1943
  })
@@ -1903,11 +1953,14 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1903
1953
 
1904
1954
  if (d?._type === "writeDiff") {
1905
1955
  const lab = `${summarizeDiff(d.diff.added, d.diff.removed)} ${FG_DIM}(${d.diff.lines.length} diff lines)${RST}`;
1956
+ // Diff output is always rendered in full — no compact mode,
1957
+ // no `… N more lines` footer — so `ctx.expanded` is
1958
+ // irrelevant to the rendered body and stays out of the key.
1906
1959
  const key = `writeDiff:${themeCacheKey(theme)}:${d.filePath}:${d.diff.added}:${d.diff.removed}:${d.diff.lines.length}:${w}:${status}`;
1907
1960
  if (ctx.state._wk !== key) {
1908
1961
  ctx.state._wk = key;
1909
1962
  ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
1910
- const maxLines = ctx.expanded ? d.diff.lines.length : MAX_PREVIEW_LINES;
1963
+ const maxLines = d.diff.lines.length;
1911
1964
  const innerW = Math.max(40, w - 1);
1912
1965
  const layout = decideDiffLayout([d.diff], innerW, maxLines);
1913
1966
  // Reserve 1 col for the outer frame rail.
@@ -1994,14 +2047,21 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1994
2047
  // `parseDiff(op.oldText, op.newText)` numbers every diff from
1995
2048
  // line 1 and the rendered gutter ends up off by the line
1996
2049
  // offset of the edit inside the file.
1997
- let preEditFile: string | null = null;
1998
- try {
1999
- if (fp && existsSync(fp)) preEditFile = readFileSync(fp, "utf-8");
2000
- } catch {
2001
- preEditFile = null;
2002
- }
2003
-
2004
- const result = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
2050
+ //
2051
+ // Pair the snapshot with the underlying execute() inside a
2052
+ // per-path lock so concurrent edits to the same file don't
2053
+ // both snapshot the same stale pre-state and render diffs
2054
+ // against the wrong baseline.
2055
+ const { preEditFile, result } = await serializePerPath(fp, async () => {
2056
+ let snapshot: string | null = null;
2057
+ try {
2058
+ if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
2059
+ } catch {
2060
+ snapshot = null;
2061
+ }
2062
+ const r = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
2063
+ return { preEditFile: snapshot, result: r };
2064
+ });
2005
2065
  const ops = getEditOperations(params);
2006
2066
  if (ops.length === 0) return result;
2007
2067
 
@@ -2082,35 +2142,39 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
2082
2142
  editCount === 1 ? `1 edit ${summary}` : `${editCount} edits ${summary}`;
2083
2143
  const lab = `${editsLabel} ${FG_DIM}(${diffLineTotal} diff lines)${RST}`;
2084
2144
 
2085
- const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${diffLineTotal}:${w}:${status}:${ctx.expanded ? 1 : 0}`;
2145
+ // Every edit block is rendered in full — no compact mode,
2146
+ // no `… N more edit blocks` footer, no per-edit line cap —
2147
+ // so `ctx.expanded` doesn't affect the body and stays out of
2148
+ // the key.
2149
+ const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${diffLineTotal}:${w}:${status}`;
2086
2150
  if (ctx.state._ek !== key) {
2087
2151
  ctx.state._ek = key;
2088
2152
  ctx.state._et = frameResultWithBottomLabel("", lab, status, t, w);
2089
2153
 
2090
2154
  const innerW = Math.max(40, w - 1);
2091
- const perEditCap = ctx.expanded
2092
- ? Number.POSITIVE_INFINITY
2093
- : Math.max(8, Math.floor(MAX_PREVIEW_LINES / Math.max(1, Math.min(editCount, 3))));
2094
- const shownEdits = ctx.expanded ? d.edits : d.edits.slice(0, 3);
2095
- const remaining = d.edits.length - shownEdits.length;
2096
2155
 
2097
2156
  // Pick one layout for every edit in this call ("consistent" mode,
2098
2157
  // the default) so we never end up with `Edit 1 split, Edit 2
2099
2158
  // unified`. The user can override via .pi/settings.json's
2100
- // `diffLayout` ("split"/"unified"/"per-edit").
2101
- const probeCap = Number.isFinite(perEditCap) ? perEditCap : MAX_PREVIEW_LINES;
2159
+ // `diffLayout` ("split"/"unified"/"per-edit"). We probe layout
2160
+ // with the longest edit so a single tall diff doesn't force
2161
+ // every other edit into unified mode unnecessarily.
2162
+ const probeCap = Math.max(
2163
+ 1,
2164
+ ...d.edits.map((e) => e.diff.lines.length),
2165
+ );
2102
2166
  const layout = decideDiffLayout(
2103
- shownEdits.map((e) => e.diff),
2167
+ d.edits.map((e) => e.diff),
2104
2168
  innerW,
2105
2169
  probeCap,
2106
2170
  );
2107
2171
 
2108
2172
  Promise.all(
2109
- shownEdits.map((edit, idx) =>
2173
+ d.edits.map((edit, idx) =>
2110
2174
  renderSplit(
2111
2175
  edit.diff,
2112
2176
  d.language,
2113
- Number.isFinite(perEditCap) ? perEditCap : edit.diff.lines.length,
2177
+ edit.diff.lines.length,
2114
2178
  dc,
2115
2179
  innerW,
2116
2180
  { frameless: true, layout },
@@ -2129,12 +2193,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
2129
2193
  )
2130
2194
  .then((sections) => {
2131
2195
  if (ctx.state._ek !== key) return;
2132
- const joined = sections.join("\n\n");
2133
- const suffix =
2134
- remaining > 0
2135
- ? `\n\n${FG_DIM}… ${remaining} more edit block${remaining === 1 ? "" : "s"}${RST}`
2136
- : "";
2137
- ctx.state._et = frameResultWithBottomLabel(`${joined}${suffix}`, lab, status, t, w);
2196
+ ctx.state._et = frameResultWithBottomLabel(sections.join("\n\n"), lab, status, t, w);
2138
2197
  ctx.invalidate();
2139
2198
  })
2140
2199
  .catch((err: unknown) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Cosmetic facelift for built-in pi tool output — open-right rounded frames, syntax-highlighted reads, status-aware bash summaries, find/grep.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Regression tests for the per-path mutation lock that pairs the
3
+ * pre-execute file snapshot with the underlying tool's execute() call.
4
+ *
5
+ * Bug history: when two `write` (or `edit`) calls for the *same* path
6
+ * landed in the agent's tool queue concurrently, both wrappers ran
7
+ *
8
+ * oldContent = readFileSync(path)
9
+ * result = await origExecute(...)
10
+ *
11
+ * in parallel. The built-in tool serialized the actual writes, but both
12
+ * wrappers had already captured the same pre-state snapshot — so the
13
+ * second wrapper rendered its diff against the *original* file rather
14
+ * than against whatever the first wrapper had just persisted.
15
+ *
16
+ * The fix routes snapshot + execute through `serializePerPath()`, an
17
+ * in-process per-path promise chain. These tests pin that ordering by
18
+ * making the mock origExecute() the thing that actually updates the
19
+ * file on disk: if the snapshot ran before our chained predecessor's
20
+ * execute(), the second snapshot would be the original contents and
21
+ * the diff details would prove it.
22
+ */
23
+
24
+ import { describe, expect, it } from "vitest";
25
+
26
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+
30
+ import piFaceliftExtension from "./index.ts";
31
+
32
+ class MockText {
33
+ private text = "";
34
+ constructor(_text = "", _x = 0, _y = 0) {}
35
+ setText(value: string) {
36
+ this.text = value;
37
+ }
38
+ getText() {
39
+ return this.text;
40
+ }
41
+ }
42
+
43
+ function mockToolFactory(exec: any) {
44
+ return (_cwd: string) => ({
45
+ name: "mock",
46
+ description: "mock",
47
+ parameters: { type: "object", properties: {} },
48
+ execute: exec,
49
+ });
50
+ }
51
+
52
+ function loadTools(opts: { writeExec?: any; editExec?: any } = {}) {
53
+ const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
54
+ const tools = new Map<string, any>();
55
+ const pi = {
56
+ registerTool: (tool: any) => tools.set(tool.name, tool),
57
+ registerCommand: () => {},
58
+ on: () => {},
59
+ };
60
+
61
+ piFaceliftExtension(pi, {
62
+ sdk: {
63
+ createReadToolDefinition: mockToolFactory(noopExec),
64
+ createBashToolDefinition: mockToolFactory(noopExec),
65
+ createLsToolDefinition: mockToolFactory(noopExec),
66
+ createFindToolDefinition: mockToolFactory(noopExec),
67
+ createGrepToolDefinition: mockToolFactory(noopExec),
68
+ createWriteToolDefinition: mockToolFactory(opts.writeExec ?? noopExec),
69
+ createEditToolDefinition: mockToolFactory(opts.editExec ?? noopExec),
70
+ getAgentDir: () => "/tmp/pi-facelift-test",
71
+ },
72
+ TextComponent: MockText,
73
+ });
74
+
75
+ return tools;
76
+ }
77
+
78
+ interface DiffLine {
79
+ type: "ctx" | "add" | "del" | "sep";
80
+ oldNum: number | null;
81
+ newNum: number | null;
82
+ content: string;
83
+ }
84
+
85
+ interface WriteDiffDetails {
86
+ _type: "writeDiff";
87
+ filePath: string;
88
+ diff: { lines: DiffLine[]; added: number; removed: number };
89
+ }
90
+
91
+ interface EditDiffDetails {
92
+ _type: "editDiff";
93
+ edits: Array<{ oldText: string; newText: string; diff: { lines: DiffLine[] } }>;
94
+ }
95
+
96
+ describe("write execute: per-path snapshot/execute serialization", () => {
97
+ it("the second concurrent write diffs against the first write's result, not the original file", async () => {
98
+ const dir = mkdtempSync(join(tmpdir(), "facelift-write-concur-"));
99
+ const file = join(dir, "state.txt");
100
+ writeFileSync(file, "A\n", "utf-8");
101
+
102
+ // `writeExec` is what the agent SDK's built-in write tool would
103
+ // do: actually persist `params.content` to disk. By doing this
104
+ // synchronously inside the mock we make the "second snapshot
105
+ // must run after the first execute" property *observable* —
106
+ // the resulting `details.diff` shows which baseline was used.
107
+ const writeExec = async (
108
+ _tid: string,
109
+ params: { path: string; content: string },
110
+ ) => {
111
+ // Yield once so a parallel caller has a chance to race with us.
112
+ await Promise.resolve();
113
+ writeFileSync(params.path, params.content, "utf-8");
114
+ return { content: [{ type: "text", text: `wrote ${params.path}` }] };
115
+ };
116
+ const tools = loadTools({ writeExec });
117
+ const writeTool = tools.get("write");
118
+
119
+ // Fire both calls without awaiting — the lock should serialize
120
+ // them so the second one sees "B\n", not "A\n".
121
+ const [r1, r2] = await Promise.all([
122
+ writeTool.execute("t1", { path: file, content: "B\n" }, null, null, {}),
123
+ writeTool.execute("t2", { path: file, content: "C\n" }, null, null, {}),
124
+ ]);
125
+
126
+ const d1 = r1.details as WriteDiffDetails;
127
+ const d2 = r2.details as WriteDiffDetails;
128
+
129
+ expect(d1._type).toBe("writeDiff");
130
+ expect(d2._type).toBe("writeDiff");
131
+
132
+ // First call: A → B. One deletion of "A", one addition of "B".
133
+ const d1Del = d1.diff.lines.find((l) => l.type === "del");
134
+ const d1Add = d1.diff.lines.find((l) => l.type === "add");
135
+ expect(d1Del?.content).toBe("A");
136
+ expect(d1Add?.content).toBe("B");
137
+
138
+ // Second call: with the bug, oldContent would still be "A" and
139
+ // the diff would say "A → C". With the lock, oldContent is the
140
+ // state the first call left behind ("B"), so the diff is B → C.
141
+ const d2Del = d2.diff.lines.find((l) => l.type === "del");
142
+ const d2Add = d2.diff.lines.find((l) => l.type === "add");
143
+ expect(d2Del?.content).toBe("B");
144
+ expect(d2Add?.content).toBe("C");
145
+
146
+ // And the file on disk reflects the second write.
147
+ expect(readFileSync(file, "utf-8")).toBe("C\n");
148
+
149
+ rmSync(dir, { recursive: true, force: true });
150
+ });
151
+
152
+ it("a failing first write does not poison subsequent writes to the same path", async () => {
153
+ const dir = mkdtempSync(join(tmpdir(), "facelift-write-poison-"));
154
+ const file = join(dir, "state.txt");
155
+ writeFileSync(file, "A\n", "utf-8");
156
+
157
+ let call = 0;
158
+ const writeExec = async (
159
+ _tid: string,
160
+ params: { path: string; content: string },
161
+ ) => {
162
+ call += 1;
163
+ await Promise.resolve();
164
+ if (call === 1) throw new Error("boom");
165
+ writeFileSync(params.path, params.content, "utf-8");
166
+ return { content: [{ type: "text", text: "ok" }] };
167
+ };
168
+ const tools = loadTools({ writeExec });
169
+ const writeTool = tools.get("write");
170
+
171
+ // First call rejects; the chain must still let the second call
172
+ // proceed and snapshot the (untouched) "A\n" baseline.
173
+ const p1 = writeTool
174
+ .execute("t1", { path: file, content: "B\n" }, null, null, {})
175
+ .catch((e: Error) => e);
176
+ const p2 = writeTool.execute("t2", { path: file, content: "C\n" }, null, null, {});
177
+ const [r1, r2] = await Promise.all([p1, p2]);
178
+
179
+ expect(r1).toBeInstanceOf(Error);
180
+ const d2 = (r2 as any).details as WriteDiffDetails;
181
+ expect(d2._type).toBe("writeDiff");
182
+ // First call never wrote, so the snapshot for the second call
183
+ // is the original "A\n".
184
+ const del = d2.diff.lines.find((l) => l.type === "del");
185
+ const add = d2.diff.lines.find((l) => l.type === "add");
186
+ expect(del?.content).toBe("A");
187
+ expect(add?.content).toBe("C");
188
+
189
+ rmSync(dir, { recursive: true, force: true });
190
+ });
191
+ });
192
+
193
+ describe("edit execute: per-path snapshot/execute serialization", () => {
194
+ it("the second concurrent edit sees the file state produced by the first edit", async () => {
195
+ const dir = mkdtempSync(join(tmpdir(), "facelift-edit-concur-"));
196
+ const file = join(dir, "src.ts");
197
+ const original = ["alpha", "beta", "gamma"].join("\n");
198
+ writeFileSync(file, original, "utf-8");
199
+
200
+ // Mock built-in edit: replace oldText → newText in the file so
201
+ // the second snapshot can prove it ran after the first execute.
202
+ const editExec = async (
203
+ _tid: string,
204
+ params: { path: string; edits: Array<{ oldText: string; newText: string }> },
205
+ ) => {
206
+ await Promise.resolve();
207
+ let body = readFileSync(params.path, "utf-8");
208
+ for (const e of params.edits) body = body.replace(e.oldText, e.newText);
209
+ writeFileSync(params.path, body, "utf-8");
210
+ return { content: [{ type: "text", text: "ok" }] };
211
+ };
212
+ const tools = loadTools({ editExec });
213
+ const editTool = tools.get("edit");
214
+
215
+ const [r1, r2] = await Promise.all([
216
+ editTool.execute(
217
+ "t1",
218
+ { path: file, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
219
+ null,
220
+ null,
221
+ {},
222
+ ),
223
+ // Targets the *result* of edit 1. Only succeeds if the
224
+ // snapshot for edit 2 happens after edit 1's execute().
225
+ editTool.execute(
226
+ "t2",
227
+ { path: file, edits: [{ oldText: "ALPHA", newText: "Alpha!" }] },
228
+ null,
229
+ null,
230
+ {},
231
+ ),
232
+ ]);
233
+
234
+ const d2 = r2.details as EditDiffDetails;
235
+ expect(d2._type).toBe("editDiff");
236
+ // The edit operated on the line that previously was "alpha"
237
+ // (file line 1). Without the lock the snapshot would still show
238
+ // "alpha", `findLineOffset` for "ALPHA" would miss, and the
239
+ // diff would silently number from line 1 of a fake baseline.
240
+ const del = d2.edits[0].diff.lines.find((l) => l.type === "del");
241
+ const add = d2.edits[0].diff.lines.find((l) => l.type === "add");
242
+ expect(del?.content).toBe("ALPHA");
243
+ expect(add?.content).toBe("Alpha!");
244
+ expect(del?.oldNum).toBe(1);
245
+ expect(add?.newNum).toBe(1);
246
+
247
+ // Final on-disk content reflects both edits applied in order.
248
+ expect(readFileSync(file, "utf-8")).toBe(["Alpha!", "beta", "gamma"].join("\n"));
249
+
250
+ rmSync(dir, { recursive: true, force: true });
251
+ });
252
+
253
+ it("does not serialize unrelated paths against each other", async () => {
254
+ const dir = mkdtempSync(join(tmpdir(), "facelift-edit-indep-"));
255
+ const fileA = join(dir, "a.txt");
256
+ const fileB = join(dir, "b.txt");
257
+ writeFileSync(fileA, "a\n", "utf-8");
258
+ writeFileSync(fileB, "b\n", "utf-8");
259
+
260
+ // First call to `fileA` is intentionally slow. Second call to
261
+ // `fileB` must NOT wait for it.
262
+ let order: string[] = [];
263
+ const editExec = async (
264
+ _tid: string,
265
+ params: { path: string; edits: Array<{ oldText: string; newText: string }> },
266
+ ) => {
267
+ order.push(`start:${params.path}`);
268
+ if (params.path === fileA) await new Promise((r) => setTimeout(r, 30));
269
+ order.push(`end:${params.path}`);
270
+ return { content: [{ type: "text", text: "ok" }] };
271
+ };
272
+ const tools = loadTools({ editExec });
273
+ const editTool = tools.get("edit");
274
+
275
+ await Promise.all([
276
+ editTool.execute(
277
+ "a",
278
+ { path: fileA, edits: [{ oldText: "a", newText: "aa" }] },
279
+ null,
280
+ null,
281
+ {},
282
+ ),
283
+ editTool.execute(
284
+ "b",
285
+ { path: fileB, edits: [{ oldText: "b", newText: "bb" }] },
286
+ null,
287
+ null,
288
+ {},
289
+ ),
290
+ ]);
291
+
292
+ // fileB should finish before fileA — proves the lock is
293
+ // per-path, not a global mutex.
294
+ const endA = order.indexOf(`end:${fileA}`);
295
+ const endB = order.indexOf(`end:${fileB}`);
296
+ expect(endB).toBeLessThan(endA);
297
+
298
+ rmSync(dir, { recursive: true, force: true });
299
+ });
300
+ });