@wierdbytes/pi-facelift 0.4.0 → 0.4.2

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
  })
@@ -1902,12 +1952,20 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1902
1952
  }
1903
1953
 
1904
1954
  if (d?._type === "writeDiff") {
1905
- const lab = `${summarizeDiff(d.diff.added, d.diff.removed)} ${FG_DIM}(${d.diff.lines.length} diff lines)${RST}`;
1955
+ // `+A -B` already conveys the change size. We used to append
1956
+ // `(N diff lines)` to hint at how much was hidden under the
1957
+ // preview cap, but that cap is gone (we always render the
1958
+ // whole diff). N also included context + hunk separators, so
1959
+ // it never matched A+B and just looked like a miscount.
1960
+ const lab = summarizeDiff(d.diff.added, d.diff.removed);
1961
+ // Diff output is always rendered in full — no compact mode,
1962
+ // no `… N more lines` footer — so `ctx.expanded` is
1963
+ // irrelevant to the rendered body and stays out of the key.
1906
1964
  const key = `writeDiff:${themeCacheKey(theme)}:${d.filePath}:${d.diff.added}:${d.diff.removed}:${d.diff.lines.length}:${w}:${status}`;
1907
1965
  if (ctx.state._wk !== key) {
1908
1966
  ctx.state._wk = key;
1909
1967
  ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
1910
- const maxLines = ctx.expanded ? d.diff.lines.length : MAX_PREVIEW_LINES;
1968
+ const maxLines = d.diff.lines.length;
1911
1969
  const innerW = Math.max(40, w - 1);
1912
1970
  const layout = decideDiffLayout([d.diff], innerW, maxLines);
1913
1971
  // Reserve 1 col for the outer frame rail.
@@ -1994,14 +2052,21 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1994
2052
  // `parseDiff(op.oldText, op.newText)` numbers every diff from
1995
2053
  // line 1 and the rendered gutter ends up off by the line
1996
2054
  // 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;
2055
+ //
2056
+ // Pair the snapshot with the underlying execute() inside a
2057
+ // per-path lock so concurrent edits to the same file don't
2058
+ // both snapshot the same stale pre-state and render diffs
2059
+ // against the wrong baseline.
2060
+ const { preEditFile, result } = await serializePerPath(fp, async () => {
2061
+ let snapshot: string | null = null;
2062
+ try {
2063
+ if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
2064
+ } catch {
2065
+ snapshot = null;
2066
+ }
2067
+ const r = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
2068
+ return { preEditFile: snapshot, result: r };
2069
+ });
2005
2070
  const ops = getEditOperations(params);
2006
2071
  if (ops.length === 0) return result;
2007
2072
 
@@ -2076,41 +2141,53 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
2076
2141
 
2077
2142
  const dc: DiffColors = resolveDiffColors(theme);
2078
2143
  const editCount = d.edits.length;
2079
- const diffLineTotal = d.edits.reduce((acc, e) => acc + e.diff.lines.length, 0);
2080
2144
  const summary = summarizeDiff(d.totalAdded, d.totalRemoved);
2081
- const editsLabel =
2082
- editCount === 1 ? `1 edit ${summary}` : `${editCount} edits ${summary}`;
2083
- const lab = `${editsLabel} ${FG_DIM}(${diffLineTotal} diff lines)${RST}`;
2084
-
2085
- const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${diffLineTotal}:${w}:${status}:${ctx.expanded ? 1 : 0}`;
2145
+ // `+A -B` already conveys the change size. We used to append
2146
+ // `(N diff lines)` here, but N counted context rows + hunk
2147
+ // separators on top of A+B, so it never matched the +A -B
2148
+ // summary and looked like a miscount. With always-full
2149
+ // rendering there's nothing useful to disambiguate either.
2150
+ //
2151
+ // `N edits` keeps the same FG_DIM the old parenthetical used:
2152
+ // it's a quiet meta-label so the colored +A -B summary stays
2153
+ // the visual anchor of the bottom border.
2154
+ const editsCountLabel =
2155
+ editCount === 1 ? `${FG_DIM}1 edit${RST}` : `${FG_DIM}${editCount} edits${RST}`;
2156
+ const lab = `${editsCountLabel} ${summary}`;
2157
+
2158
+ // Every edit block is rendered in full — no compact mode,
2159
+ // no `… N more edit blocks` footer, no per-edit line cap —
2160
+ // so `ctx.expanded` doesn't affect the body and stays out of
2161
+ // the key.
2162
+ const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${w}:${status}`;
2086
2163
  if (ctx.state._ek !== key) {
2087
2164
  ctx.state._ek = key;
2088
2165
  ctx.state._et = frameResultWithBottomLabel("", lab, status, t, w);
2089
2166
 
2090
2167
  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
2168
 
2097
2169
  // Pick one layout for every edit in this call ("consistent" mode,
2098
2170
  // the default) so we never end up with `Edit 1 split, Edit 2
2099
2171
  // 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;
2172
+ // `diffLayout` ("split"/"unified"/"per-edit"). We probe layout
2173
+ // with the longest edit so a single tall diff doesn't force
2174
+ // every other edit into unified mode unnecessarily.
2175
+ const probeCap = Math.max(
2176
+ 1,
2177
+ ...d.edits.map((e) => e.diff.lines.length),
2178
+ );
2102
2179
  const layout = decideDiffLayout(
2103
- shownEdits.map((e) => e.diff),
2180
+ d.edits.map((e) => e.diff),
2104
2181
  innerW,
2105
2182
  probeCap,
2106
2183
  );
2107
2184
 
2108
2185
  Promise.all(
2109
- shownEdits.map((edit, idx) =>
2186
+ d.edits.map((edit, idx) =>
2110
2187
  renderSplit(
2111
2188
  edit.diff,
2112
2189
  d.language,
2113
- Number.isFinite(perEditCap) ? perEditCap : edit.diff.lines.length,
2190
+ edit.diff.lines.length,
2114
2191
  dc,
2115
2192
  innerW,
2116
2193
  { frameless: true, layout },
@@ -2129,12 +2206,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
2129
2206
  )
2130
2207
  .then((sections) => {
2131
2208
  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);
2209
+ ctx.state._et = frameResultWithBottomLabel(sections.join("\n\n"), lab, status, t, w);
2138
2210
  ctx.invalidate();
2139
2211
  })
2140
2212
  .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.2",
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
+ });