@wierdbytes/pi-facelift 0.3.2 → 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/CHANGELOG.md +44 -0
- package/README.md +56 -1
- package/config.test.ts +133 -0
- package/config.ts +143 -0
- package/edit-line-numbers.test.ts +231 -0
- package/index.ts +639 -3
- package/package.json +2 -2
- package/paths.ts +50 -0
- package/scripts/diff-demo.ts +96 -0
- package/write-edit-concurrency.test.ts +300 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wierdbytes/pi-facelift",
|
|
3
|
-
"version": "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": [
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@shikijs/cli": "^4.0.2",
|
|
56
|
-
"@wierdbytes/pi-common": "^0.
|
|
56
|
+
"@wierdbytes/pi-common": "^0.4.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@earendil-works/pi-ai": ">=0.74.0",
|
package/paths.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem paths for @wierdbytes/pi-facelift.
|
|
3
|
+
*
|
|
4
|
+
* Per-package state lives under `~/.pi/agent/wierd-facelift/` (or
|
|
5
|
+
* `${PI_AGENT_DIR}/wierd-facelift/` if the env var is set):
|
|
6
|
+
*
|
|
7
|
+
* - `config.json` — user-editable settings (see config.ts). Today
|
|
8
|
+
* this is just the `diffLayout` preference; more knobs (themes,
|
|
9
|
+
* icon mode, max preview lines, etc.) can be added later without
|
|
10
|
+
* changing the directory layout.
|
|
11
|
+
*
|
|
12
|
+
* The directory is created lazily on first write — `ensureFaceliftDir`
|
|
13
|
+
* is the canonical entry point. We never call `mkdirSync` from module
|
|
14
|
+
* load so test runners and unrelated tooling don't accidentally touch
|
|
15
|
+
* the agent dir.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const PACKAGE_DIRNAME = "wierd-facelift";
|
|
23
|
+
|
|
24
|
+
/** Resolve the pi agent root (`PI_AGENT_DIR` if set, otherwise `~/.pi/agent`). */
|
|
25
|
+
export function piAgentDir(): string {
|
|
26
|
+
const fromEnv = process.env.PI_AGENT_DIR?.trim();
|
|
27
|
+
if (fromEnv) return fromEnv;
|
|
28
|
+
return join(homedir(), ".pi", "agent");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Per-package state directory: `<piAgentDir>/wierd-facelift`. */
|
|
32
|
+
export function faceliftDir(): string {
|
|
33
|
+
return join(piAgentDir(), PACKAGE_DIRNAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Path to the persisted config JSON. */
|
|
37
|
+
export function configPath(): string {
|
|
38
|
+
return join(faceliftDir(), "config.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure the package directory exists. Idempotent. Caller should invoke
|
|
43
|
+
* this once before the first config save.
|
|
44
|
+
*/
|
|
45
|
+
export function ensureFaceliftDir(): void {
|
|
46
|
+
const dir = faceliftDir();
|
|
47
|
+
if (!existsSync(dir)) {
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual smoke test for the new write/edit diff renderer.
|
|
3
|
+
*
|
|
4
|
+
* Renders a representative split-view diff inside an open-right facelift
|
|
5
|
+
* frame so the result can be eyeballed against the original pi-diff
|
|
6
|
+
* screenshot. Not run by CI — invoked manually with:
|
|
7
|
+
*
|
|
8
|
+
* bun run packages/facelift/scripts/diff-demo.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
applyDiffPalette,
|
|
13
|
+
canRenderSplit,
|
|
14
|
+
getDiffLayoutPreference,
|
|
15
|
+
lang,
|
|
16
|
+
parseDiff,
|
|
17
|
+
renderSplit,
|
|
18
|
+
resolveDiffColors,
|
|
19
|
+
summarize,
|
|
20
|
+
type DiffLayout,
|
|
21
|
+
} from "@wierdbytes/pi-common/diff";
|
|
22
|
+
import {
|
|
23
|
+
frameResultWithBottomLabel,
|
|
24
|
+
frameTop,
|
|
25
|
+
} from "@wierdbytes/pi-common/tool-frame";
|
|
26
|
+
|
|
27
|
+
applyDiffPalette();
|
|
28
|
+
|
|
29
|
+
// Minimal theme stub. By default we mimic tokyo-night-ish bg tokens so the
|
|
30
|
+
// auto-derived diff bg tints (BG_DEL/BG_ADD/BG_BASE) blend with each other.
|
|
31
|
+
// Pass FACELIFT_DEMO_PLAIN=1 to fall back to default terminal bg for the
|
|
32
|
+
// uncommon case where you want to inspect raw output.
|
|
33
|
+
const plain = process.env.FACELIFT_DEMO_PLAIN === "1";
|
|
34
|
+
const theme = plain
|
|
35
|
+
? ({
|
|
36
|
+
fg: (_key: string, text: string) => text,
|
|
37
|
+
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
|
|
38
|
+
getFgAnsi: () => "",
|
|
39
|
+
getBgAnsi: () => "",
|
|
40
|
+
} as unknown as Parameters<typeof frameTop>[2])
|
|
41
|
+
: ({
|
|
42
|
+
fg: (_key: string, text: string) => text,
|
|
43
|
+
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
|
|
44
|
+
getFgAnsi: (key: string) => {
|
|
45
|
+
if (key === "toolDiffAdded") return "\x1b[38;2;100;180;120m"; // green
|
|
46
|
+
if (key === "toolDiffRemoved") return "\x1b[38;2;200;100;100m"; // red
|
|
47
|
+
return "";
|
|
48
|
+
},
|
|
49
|
+
getBgAnsi: (key: string) => {
|
|
50
|
+
if (key === "toolSuccessBg") return "\x1b[48;2;26;30;46m"; // tokyo-night-ish slate
|
|
51
|
+
if (key === "toolErrorBg") return "\x1b[48;2;46;26;30m";
|
|
52
|
+
return "";
|
|
53
|
+
},
|
|
54
|
+
} as unknown as Parameters<typeof frameTop>[2]);
|
|
55
|
+
|
|
56
|
+
const oldText = `if len(blockers) == 0 {
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
tx, err := s.db.BeginTx(ctx, nil)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return fmt.Errorf("begin tx: %w", err)
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const newText = `if len(blockers) == 0 {
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
return retryOnBusy(ctx, func() error { return s.blockOnce(ctx, id, blockers) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func (s *Store) blockOnce(ctx context.Context, id string, blockers []string) error {
|
|
72
|
+
tx, err := s.db.BeginTx(ctx, nil)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return fmt.Errorf("begin tx: %w", err)
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const diff = parseDiff(oldText, newText);
|
|
78
|
+
const colors = resolveDiffColors(theme as never);
|
|
79
|
+
const width = Number(process.env.COLUMNS) || 200;
|
|
80
|
+
const innerW = width - 1;
|
|
81
|
+
|
|
82
|
+
// Mirror facelift's `decideDiffLayout` so the demo also respects
|
|
83
|
+
// `DIFF_LAYOUT=...` / `diffLayout` settings when invoked manually.
|
|
84
|
+
const pref = getDiffLayoutPreference();
|
|
85
|
+
let layout: DiffLayout | undefined;
|
|
86
|
+
if (pref === "split") layout = "split";
|
|
87
|
+
else if (pref === "unified") layout = "unified";
|
|
88
|
+
else if (pref === "per-edit") layout = undefined;
|
|
89
|
+
else layout = canRenderSplit(diff, innerW, 60) ? "split" : "unified";
|
|
90
|
+
|
|
91
|
+
const title = `\x1b[1medit\x1b[22m internal/store/doltlite/deps.go`;
|
|
92
|
+
console.log(frameTop(title, "success", theme, width));
|
|
93
|
+
|
|
94
|
+
const body = await renderSplit(diff, lang("foo.go"), 60, colors, innerW, { frameless: true, layout });
|
|
95
|
+
const label = `1 edit ${summarize(diff.added, diff.removed)} (${diff.lines.length} diff lines)`;
|
|
96
|
+
console.log(frameResultWithBottomLabel(body, label, "success", theme, width));
|
|
@@ -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
|
+
});
|