@maxiaochao/pi-toolkit 0.9.3 → 0.9.6

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +40 -90
  3. package/extensions/{btw.ts → btw/index.ts} +1 -1
  4. package/extensions/{tests/btw-history.mjs → btw/tests/history.mjs} +1 -1
  5. package/package.json +19 -10
  6. package/packages/cache-export/CHANGELOG.md +5 -0
  7. package/packages/cache-export/README.md +70 -0
  8. package/packages/cache-export/TESTING.md +17 -0
  9. package/packages/cache-export/docs/demo.gif +0 -0
  10. package/packages/cache-export/docs/screenshot.png +0 -0
  11. package/{extensions/cache-export/index.ts → packages/cache-export/extensions/cache-export.ts} +1 -1
  12. package/packages/cache-export/package.json +22 -0
  13. package/{extensions/cache-export → packages/cache-export/src}/render.ts +1 -1
  14. package/{extensions → packages}/cache-export/tests/e2e-browser.mjs +2 -2
  15. package/{extensions → packages}/cache-export/tests/run-tests.mjs +2 -2
  16. package/packages/codex-edit/CHANGELOG.md +47 -0
  17. package/packages/codex-edit/README.md +130 -0
  18. package/packages/codex-edit/TESTING.md +51 -0
  19. package/packages/codex-edit/benchmark/explicit-edit-100.json +2824 -0
  20. package/packages/codex-edit/benchmark/export-evidence.mjs +77 -0
  21. package/packages/codex-edit/benchmark/incident-cases.mjs +37 -0
  22. package/packages/codex-edit/benchmark/prepare.mjs +181 -0
  23. package/packages/codex-edit/benchmark/results/explicit-edit-100-sol-low-v3.json +4562 -0
  24. package/packages/codex-edit/benchmark/run-with-budget.mjs +77 -0
  25. package/packages/codex-edit/benchmark/schedule.mjs +19 -0
  26. package/packages/codex-edit/benchmark/select-tasks.mjs +167 -0
  27. package/{extensions → packages}/codex-edit/codex-edit-explainer.html +43 -22
  28. package/packages/codex-edit/docs/demo.gif +0 -0
  29. package/packages/codex-edit/docs/screenshot.png +0 -0
  30. package/packages/codex-edit/docs/test-report.html +181 -0
  31. package/packages/codex-edit/docs/test-report.md +242 -0
  32. package/packages/codex-edit/docs/test-summary.md +9 -0
  33. package/{extensions/codex-edit/index.ts → packages/codex-edit/extensions/codex-edit.ts} +7 -5
  34. package/packages/codex-edit/package.json +54 -0
  35. package/{extensions/codex-edit → packages/codex-edit/src}/parser.ts +27 -5
  36. package/{extensions → packages}/codex-edit/summary.md +1 -1
  37. package/packages/codex-edit/tests/benchmark.test.mjs +81 -0
  38. package/packages/codex-edit/tests/comparison-cases.mjs +396 -0
  39. package/packages/codex-edit/tests/comparison-harness.mjs +44 -0
  40. package/packages/codex-edit/tests/generate-test-report.mjs +589 -0
  41. package/packages/codex-edit/tests/legacy-parser-v0.1.7.mjs +128 -0
  42. package/packages/codex-edit/tests/native-vs-codex.ts +109 -0
  43. package/packages/codex-edit/tests/package.test.mjs +51 -0
  44. package/packages/codex-edit/tests/parser.test.mjs +308 -0
  45. package/packages/codex-edit/tests/released-vs-local.test.mjs +31 -0
  46. package/packages/tiny-subagent/CHANGELOG.md +12 -0
  47. package/packages/tiny-subagent/README.md +128 -0
  48. package/packages/tiny-subagent/TESTING.md +20 -0
  49. package/packages/tiny-subagent/agents/worker.md +9 -0
  50. package/packages/tiny-subagent/docs/demo.gif +0 -0
  51. package/packages/tiny-subagent/docs/screenshot.png +0 -0
  52. package/packages/tiny-subagent/extensions/tiny-subagent.ts +228 -0
  53. package/packages/tiny-subagent/package.json +44 -0
  54. package/packages/tiny-subagent/src/personas.ts +110 -0
  55. package/packages/tiny-subagent/src/runner.ts +319 -0
  56. package/packages/tiny-subagent/tests/run-tests.mjs +111 -0
  57. package/packages/todo/CHANGELOG.md +5 -0
  58. package/packages/todo/README.md +63 -0
  59. package/packages/todo/TESTING.md +20 -0
  60. package/packages/todo/docs/demo.gif +0 -0
  61. package/packages/todo/docs/screenshot.png +0 -0
  62. package/{extensions → packages/todo/extensions}/todo.ts +1 -1
  63. package/packages/todo/package.json +25 -0
  64. package/{extensions → packages/todo}/tests/todo-state.mjs +1 -1
  65. package/packages/worktree/CHANGELOG.md +5 -0
  66. package/packages/worktree/README.md +60 -0
  67. package/packages/worktree/TESTING.md +21 -0
  68. package/packages/worktree/docs/demo.gif +0 -0
  69. package/packages/worktree/docs/screenshot.png +0 -0
  70. package/packages/worktree/package.json +20 -0
  71. package/packages/worktree/scripts/install.mjs +26 -0
  72. package/packages/worktree/tests/install.test.mjs +25 -0
  73. package/packages/worktree/tests/run-tests.sh +46 -0
  74. package/scripts/install-agents.mjs +8 -18
  75. package/extensions/codex-edit/README.md +0 -53
  76. package/extensions/codex-edit/tests/package.test.mjs +0 -38
  77. package/extensions/codex-edit/tests/parser.test.mjs +0 -89
  78. /package/extensions/{btw-scroll.ts → btw/scroll.ts} +0 -0
  79. /package/{extensions/cache-export → packages/cache-export/src}/tau-assets.ts +0 -0
  80. /package/{extensions → packages}/codex-edit/config.json +0 -0
  81. /package/{extensions/codex-edit → packages/codex-edit/src}/model-routing.ts +0 -0
  82. /package/{extensions → packages/todo/src}/todo-state.ts +0 -0
  83. /package/{bin → packages/worktree/bin}/pi-worktree +0 -0
  84. /package/{bin → packages/worktree/bin}/pi-worktree-completion.bash +0 -0
  85. /package/{skills → packages/worktree/skills}/pi-worktree/SKILL.md +0 -0
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import test from "node:test";
8
+ import { incidentCases } from "../benchmark/incident-cases.mjs";
9
+ import { interleaveNeutralAndChallenge } from "../benchmark/schedule.mjs";
10
+ import { applyUpdate, parsePatch } from "../src/parser.ts";
11
+ import { comparisonCases } from "./comparison-cases.mjs";
12
+ import { runPatchCase } from "./comparison-harness.mjs";
13
+
14
+ const manifest = JSON.parse(readFileSync(new URL("../benchmark/explicit-edit-100.json", import.meta.url), "utf8"));
15
+ const evidence = JSON.parse(readFileSync(new URL("../benchmark/results/explicit-edit-100-sol-low-v3.json", import.meta.url), "utf8"));
16
+
17
+ test("frozen 100-task selection keeps the same task IDs and fixture hashes", () => {
18
+ assert.equal(manifest.included.length, 100);
19
+ assert.equal(new Set(manifest.included.map(({ id }) => id)).size, 100);
20
+ const digest = createHash("sha256")
21
+ .update(manifest.included.map(({ id, fixtureSha256 }) => `${id}\0${fixtureSha256}`).sort().join("\n"))
22
+ .digest("hex");
23
+ assert.equal(digest, "3a5da55315806cfd7984264e6afb5f5b69739d2101b7128cb25ff35f834afc3b");
24
+ assert.equal(evidence.completed, 300);
25
+ assert.equal(evidence.results.length, 300);
26
+ assert.equal(evidence.identities.focusedSelectionSha256, evidence.identities.taskManifestSha256);
27
+
28
+ const outputDirectory = mkdtempSync(join(tmpdir(), "codex-edit-report-"));
29
+ try {
30
+ execFileSync(process.execPath, [new URL("./generate-test-report.mjs", import.meta.url).pathname], {
31
+ env: { ...process.env, CODEX_EDIT_REPORT_DIR: outputDirectory, CODEX_EDIT_REPORT_SKIP_CHECKS: "1" },
32
+ stdio: "pipe",
33
+ });
34
+ for (const filename of ["test-summary.md", "test-report.md", "test-report.html"]) {
35
+ assert.equal(
36
+ readFileSync(join(outputDirectory, filename), "utf8"),
37
+ readFileSync(new URL(`../docs/${filename}`, import.meta.url), "utf8"),
38
+ `${filename} must regenerate byte-for-byte from committed evidence`,
39
+ );
40
+ }
41
+ } finally {
42
+ rmSync(outputDirectory, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test("every five scheduled tasks contain four neutral and one challenge task", () => {
47
+ assert.equal(manifest.selection.scheduling, "four-neutral-then-one-challenge-v1");
48
+ for (let index = 0; index < manifest.included.length; index += 5) {
49
+ const cohorts = manifest.included.slice(index, index + 5).map(({ cohort }) => cohort);
50
+ assert.deepEqual(cohorts, ["neutral", "neutral", "neutral", "neutral", "challenge"]);
51
+ }
52
+ const budgetPrefix = manifest.included.slice(0, 47);
53
+ assert.equal(budgetPrefix.filter(({ cohort }) => cohort === "neutral").length, 38);
54
+ assert.equal(budgetPrefix.filter(({ cohort }) => cohort === "challenge").length, 9);
55
+ });
56
+
57
+ test("scheduler rejects malformed ratios and unknown cohorts", () => {
58
+ assert.throws(() => interleaveNeutralAndChallenge([{ cohort: "neutral" }]), /Expected a 4:1/);
59
+ assert.throws(() => interleaveNeutralAndChallenge([{ cohort: "incident" }]), /Unknown cohort/);
60
+ assert.throws(() => interleaveNeutralAndChallenge([], 0), /positive integer/);
61
+ });
62
+
63
+ test("reported production incidents stay linked to executable protocol regressions", () => {
64
+ assert.equal(incidentCases.length, 5);
65
+ assert.equal(new Set(incidentCases.map(({ id }) => id)).size, incidentCases.length);
66
+ assert.equal(incidentCases.filter(({ tier }) => tier === "agent-and-protocol").length, 4);
67
+ assert.ok(incidentCases.some(({ tier }) => tier === "protocol-only"));
68
+
69
+ const comparisonById = new Map(comparisonCases.map((testCase) => [testCase.id, testCase]));
70
+ for (const incident of incidentCases) {
71
+ const protocolCase = comparisonById.get(incident.protocolCaseId);
72
+ assert.ok(protocolCase, `${incident.id} has no protocol regression`);
73
+ assert.equal(protocolCase.kind, "valid-edit");
74
+ assert.equal(runPatchCase(protocolCase, parsePatch, applyUpdate, "pass"), "pass");
75
+ if (incident.tier === "agent-and-protocol") {
76
+ assert.doesNotMatch(incident.prompt, /apply_patch|Update File|@@/i);
77
+ }
78
+ }
79
+ assert.ok(incidentCases.some(({ protocolCaseId }) => comparisonById.get(protocolCaseId).outcomes.released === "fail"));
80
+ assert.ok(incidentCases.some(({ protocolCaseId }) => comparisonById.get(protocolCaseId).outcomes.native !== "pass"));
81
+ });
@@ -0,0 +1,396 @@
1
+ export const comparisonCases = [
2
+ {
3
+ id: "unique-replacement",
4
+ kind: "valid-edit",
5
+ input: { "app.ts": "const enabled = false;\n" },
6
+ expected: { "app.ts": "const enabled = true;\n" },
7
+ patch: `*** Begin Patch
8
+ *** Update File: app.ts
9
+ @@
10
+ -const enabled = false;
11
+ +const enabled = true;
12
+ *** End Patch`,
13
+ nativeCalls: [{ path: "app.ts", edits: [{ oldText: "const enabled = false;", newText: "const enabled = true;" }] }],
14
+ outcomes: { native: "pass", released: "pass", local: "pass" },
15
+ },
16
+ {
17
+ id: "disjoint-edits-one-file",
18
+ kind: "valid-edit",
19
+ input: { "app.ts": "const first = 1;\nconst middle = 2;\nconst last = 3;\n" },
20
+ expected: { "app.ts": "const first = 10;\nconst middle = 2;\nconst last = 30;\n" },
21
+ patch: `*** Begin Patch
22
+ *** Update File: app.ts
23
+ @@
24
+ -const first = 1;
25
+ +const first = 10;
26
+ @@
27
+ -const last = 3;
28
+ +const last = 30;
29
+ *** End Patch`,
30
+ nativeCalls: [{
31
+ path: "app.ts",
32
+ edits: [
33
+ { oldText: "const first = 1;", newText: "const first = 10;" },
34
+ { oldText: "const last = 3;", newText: "const last = 30;" },
35
+ ],
36
+ }],
37
+ outcomes: { native: "pass", released: "pass", local: "pass" },
38
+ },
39
+ {
40
+ id: "repeated-markdown-separator",
41
+ kind: "valid-edit",
42
+ input: { "SKILL.md": "---\nname: demo\ndescription: old\n---\n\n# Demo\n\n---\nFooter\n" },
43
+ expected: { "SKILL.md": "---\nname: demo\ndescription: new\n---\nInserted after frontmatter.\n\n# Demo\n\n---\nFooter\n" },
44
+ patch: `*** Begin Patch
45
+ *** Update File: SKILL.md
46
+ @@
47
+ -description: old
48
+ +description: new
49
+ @@
50
+ ---
51
+ +Inserted after frontmatter.
52
+ *** End Patch`,
53
+ nativeCalls: [{ path: "SKILL.md", edits: [{ oldText: "---", newText: "---\nInserted after frontmatter." }] }],
54
+ outcomes: { native: "fail", released: "pass", local: "pass" },
55
+ },
56
+ {
57
+ id: "repeated-update-same-path",
58
+ kind: "valid-edit",
59
+ input: { "tool.ts": "const state = \"old\";\n" },
60
+ expected: { "tool.ts": "const state = \"new\";\n" },
61
+ patch: `*** Begin Patch
62
+ *** Update File: tool.ts
63
+ @@
64
+ -const state = "old";
65
+ +const state = "middle";
66
+ *** Update File: tool.ts
67
+ @@
68
+ -const state = "middle";
69
+ +const state = "new";
70
+ *** End Patch`,
71
+ nativeCalls: [
72
+ { path: "tool.ts", edits: [{ oldText: "const state = \"old\";", newText: "const state = \"middle\";" }] },
73
+ { path: "tool.ts", edits: [{ oldText: "const state = \"middle\";", newText: "const state = \"new\";" }] },
74
+ ],
75
+ outcomes: { native: "pass", released: "fail", local: "pass" },
76
+ },
77
+ {
78
+ id: "ordered-runner-type-renames",
79
+ kind: "valid-edit",
80
+ input: {
81
+ "packages/herdr-subagents/src/runner.ts": `export interface TinySubagentDocument {
82
+ \tpath: string;
83
+ }
84
+
85
+ export interface TinySubagentReport {
86
+ \tdocuments: TinySubagentDocument[];
87
+ }
88
+
89
+ export interface TinySubagentOptions {
90
+ \tonUpdate?: (result: TinySubagentResult) => void;
91
+ }
92
+
93
+ export interface TinySubagentResult {
94
+ \tdocuments: TinySubagentDocument[];
95
+ \tstatus: "running" | "failed";
96
+ }
97
+
98
+ function compactText(report: TinySubagentReport): string {
99
+ \treturn report.documents.map((document) => document.path).join("\\n");
100
+ }
101
+
102
+ function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<TinySubagentResult> = {}): TinySubagentResult {
103
+ \treturn { documents: [], status: "failed", ...fields };
104
+ }
105
+
106
+ function validReport(value: unknown): value is TinySubagentReport {
107
+ \treturn Boolean(value);
108
+ }
109
+
110
+ async function readReport(path: string): Promise<TinySubagentReport | undefined> {
111
+ \treturn undefined;
112
+ }
113
+
114
+ export async function runTinySubagent(prompt: string, options: TinySubagentOptions = {}): Promise<TinySubagentResult> {
115
+ \tlet result: TinySubagentResult | undefined;
116
+ \tconst common = (): Partial<TinySubagentResult> => ({});
117
+ \tconst update = (status: TinySubagentResult["status"], summary: string) => {};
118
+ \treturn result ?? failure(".", 0, prompt, common());
119
+ }
120
+ `,
121
+ },
122
+ expected: {
123
+ "packages/herdr-subagents/src/runner.ts": `export interface HerdrSubagentsDocument {
124
+ \tpath: string;
125
+ }
126
+
127
+ export interface HerdrSubagentsReport {
128
+ \tdocuments: HerdrSubagentsDocument[];
129
+ }
130
+
131
+ export interface HerdrSubagentsOptions {
132
+ \tonUpdate?: (result: HerdrSubagentsResult) => void;
133
+ }
134
+
135
+ export interface HerdrSubagentsResult {
136
+ \tdocuments: HerdrSubagentsDocument[];
137
+ \tstatus: "running" | "failed";
138
+ }
139
+
140
+ function compactText(report: HerdrSubagentsReport): string {
141
+ \treturn report.documents.map((document) => document.path).join("\\n");
142
+ }
143
+
144
+ function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<HerdrSubagentsResult> = {}): HerdrSubagentsResult {
145
+ \treturn { documents: [], status: "failed", ...fields };
146
+ }
147
+
148
+ function validReport(value: unknown): value is HerdrSubagentsReport {
149
+ \treturn Boolean(value);
150
+ }
151
+
152
+ async function readReport(path: string): Promise<HerdrSubagentsReport | undefined> {
153
+ \treturn undefined;
154
+ }
155
+
156
+ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsOptions = {}): Promise<HerdrSubagentsResult> {
157
+ \tlet result: HerdrSubagentsResult | undefined;
158
+ \tconst common = (): Partial<HerdrSubagentsResult> => ({});
159
+ \tconst update = (status: HerdrSubagentsResult["status"], summary: string) => {};
160
+ \treturn result ?? failure(".", 0, prompt, common());
161
+ }
162
+ `,
163
+ },
164
+ patch: `*** Begin Patch
165
+ *** Update File: packages/herdr-subagents/src/runner.ts
166
+ @@
167
+ -export interface TinySubagentDocument {
168
+ +export interface HerdrSubagentsDocument {
169
+ @@
170
+ -export interface TinySubagentReport {
171
+ +export interface HerdrSubagentsReport {
172
+ @@
173
+ -\tdocuments: TinySubagentDocument[];
174
+ +\tdocuments: HerdrSubagentsDocument[];
175
+ @@
176
+ -export interface TinySubagentOptions {
177
+ +export interface HerdrSubagentsOptions {
178
+ @@
179
+ -\tonUpdate?: (result: TinySubagentResult) => void;
180
+ +\tonUpdate?: (result: HerdrSubagentsResult) => void;
181
+ @@
182
+ -export interface TinySubagentResult {
183
+ +export interface HerdrSubagentsResult {
184
+ @@
185
+ -\tdocuments: TinySubagentDocument[];
186
+ +\tdocuments: HerdrSubagentsDocument[];
187
+ @@
188
+ -function compactText(report: TinySubagentReport): string {
189
+ +function compactText(report: HerdrSubagentsReport): string {
190
+ @@
191
+ -function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<TinySubagentResult> = {}): TinySubagentResult {
192
+ +function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<HerdrSubagentsResult> = {}): HerdrSubagentsResult {
193
+ @@
194
+ -function validReport(value: unknown): value is TinySubagentReport {
195
+ +function validReport(value: unknown): value is HerdrSubagentsReport {
196
+ @@
197
+ -async function readReport(path: string): Promise<TinySubagentReport | undefined> {
198
+ +async function readReport(path: string): Promise<HerdrSubagentsReport | undefined> {
199
+ @@
200
+ -export async function runTinySubagent(prompt: string, options: TinySubagentOptions = {}): Promise<TinySubagentResult> {
201
+ +export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsOptions = {}): Promise<HerdrSubagentsResult> {
202
+ @@
203
+ -\tlet result: TinySubagentResult | undefined;
204
+ -\tconst common = (): Partial<TinySubagentResult> => ({});
205
+ +\tlet result: HerdrSubagentsResult | undefined;
206
+ +\tconst common = (): Partial<HerdrSubagentsResult> => ({});
207
+ @@
208
+ -\tconst update = (status: TinySubagentResult["status"], summary: string) => {};
209
+ +\tconst update = (status: HerdrSubagentsResult["status"], summary: string) => {};
210
+ *** End Patch`,
211
+ nativeCalls: [{
212
+ path: "packages/herdr-subagents/src/runner.ts",
213
+ edits: [{
214
+ oldText: `export interface TinySubagentDocument {
215
+ \tpath: string;
216
+ }
217
+
218
+ export interface TinySubagentReport {
219
+ \tdocuments: TinySubagentDocument[];
220
+ }
221
+
222
+ export interface TinySubagentOptions {
223
+ \tonUpdate?: (result: TinySubagentResult) => void;
224
+ }
225
+
226
+ export interface TinySubagentResult {
227
+ \tdocuments: TinySubagentDocument[];
228
+ \tstatus: "running" | "failed";
229
+ }`,
230
+ newText: `export interface HerdrSubagentsDocument {
231
+ \tpath: string;
232
+ }
233
+
234
+ export interface HerdrSubagentsReport {
235
+ \tdocuments: HerdrSubagentsDocument[];
236
+ }
237
+
238
+ export interface HerdrSubagentsOptions {
239
+ \tonUpdate?: (result: HerdrSubagentsResult) => void;
240
+ }
241
+
242
+ export interface HerdrSubagentsResult {
243
+ \tdocuments: HerdrSubagentsDocument[];
244
+ \tstatus: "running" | "failed";
245
+ }`,
246
+ }, {
247
+ oldText: `function compactText(report: TinySubagentReport): string {
248
+ \treturn report.documents.map((document) => document.path).join("\\n");
249
+ }
250
+
251
+ function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<TinySubagentResult> = {}): TinySubagentResult {
252
+ \treturn { documents: [], status: "failed", ...fields };
253
+ }
254
+
255
+ function validReport(value: unknown): value is TinySubagentReport {
256
+ \treturn Boolean(value);
257
+ }
258
+
259
+ async function readReport(path: string): Promise<TinySubagentReport | undefined> {
260
+ \treturn undefined;
261
+ }
262
+
263
+ export async function runTinySubagent(prompt: string, options: TinySubagentOptions = {}): Promise<TinySubagentResult> {
264
+ \tlet result: TinySubagentResult | undefined;
265
+ \tconst common = (): Partial<TinySubagentResult> => ({});
266
+ \tconst update = (status: TinySubagentResult["status"], summary: string) => {};`,
267
+ newText: `function compactText(report: HerdrSubagentsReport): string {
268
+ \treturn report.documents.map((document) => document.path).join("\\n");
269
+ }
270
+
271
+ function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<HerdrSubagentsResult> = {}): HerdrSubagentsResult {
272
+ \treturn { documents: [], status: "failed", ...fields };
273
+ }
274
+
275
+ function validReport(value: unknown): value is HerdrSubagentsReport {
276
+ \treturn Boolean(value);
277
+ }
278
+
279
+ async function readReport(path: string): Promise<HerdrSubagentsReport | undefined> {
280
+ \treturn undefined;
281
+ }
282
+
283
+ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsOptions = {}): Promise<HerdrSubagentsResult> {
284
+ \tlet result: HerdrSubagentsResult | undefined;
285
+ \tconst common = (): Partial<HerdrSubagentsResult> => ({});
286
+ \tconst update = (status: HerdrSubagentsResult["status"], summary: string) => {};`,
287
+ }],
288
+ }],
289
+ outcomes: { native: "pass", released: "pass", local: "pass" },
290
+ },
291
+ {
292
+ id: "multi-file-add-delete-move",
293
+ kind: "valid-edit",
294
+ input: { "old.ts": "export const value = 1;\n", "remove.txt": "obsolete\n" },
295
+ expected: { "new.ts": "export const value = 2;\n", "added.txt": "created\n" },
296
+ patch: `*** Begin Patch
297
+ *** Update File: old.ts
298
+ *** Move to: new.ts
299
+ @@
300
+ -export const value = 1;
301
+ +export const value = 2;
302
+ *** Delete File: remove.txt
303
+ *** Add File: added.txt
304
+ +created
305
+ *** End Patch`,
306
+ nativeCalls: null,
307
+ outcomes: { native: "unsupported", released: "pass", local: "pass" },
308
+ },
309
+ {
310
+ id: "stale-expected-lines",
311
+ kind: "safety",
312
+ input: { "app.ts": "const actual = true;\n" },
313
+ expected: null,
314
+ patch: `*** Begin Patch
315
+ *** Update File: app.ts
316
+ @@
317
+ -const stale = false;
318
+ +const stale = true;
319
+ *** End Patch`,
320
+ nativeCalls: [{ path: "app.ts", edits: [{ oldText: "const stale = false;", newText: "const stale = true;" }] }],
321
+ outcomes: { native: "fail", released: "fail", local: "fail" },
322
+ },
323
+ {
324
+ id: "conflicting-targets",
325
+ kind: "safety",
326
+ input: { "app.ts": "old\n" },
327
+ expected: null,
328
+ patch: `*** Begin Patch
329
+ *** Update File: app.ts
330
+ @@
331
+ -old
332
+ +new
333
+ *** Delete File: app.ts
334
+ *** End Patch`,
335
+ nativeCalls: null,
336
+ outcomes: { native: "unsupported", released: "fail", local: "fail" },
337
+ },
338
+ {
339
+ id: "empty-update-chunk",
340
+ kind: "safety",
341
+ input: { "app.ts": "unchanged\n" },
342
+ expected: null,
343
+ patch: `*** Begin Patch
344
+ *** Update File: app.ts
345
+ @@
346
+ *** End Patch`,
347
+ nativeCalls: null,
348
+ outcomes: { native: "unsupported", released: "fail", local: "fail" },
349
+ },
350
+ {
351
+ id: "resolved-path-alias-conflict",
352
+ kind: "safety",
353
+ input: { "app.ts": "const value = 1;\n" },
354
+ expected: null,
355
+ patch: `*** Begin Patch
356
+ *** Update File: app.ts
357
+ @@
358
+ -const value = 1;
359
+ +const value = 2;
360
+ *** Update File: ./app.ts
361
+ @@
362
+ -const value = 2;
363
+ +const value = 3;
364
+ *** End Patch`,
365
+ nativeCalls: null,
366
+ outcomes: { native: "unsupported", released: "fail", local: "fail" },
367
+ },
368
+ {
369
+ id: "outside-workspace-absolute-path",
370
+ kind: "safety",
371
+ input: { "app.ts": "const value = 1;\n" },
372
+ expected: null,
373
+ patch: `*** Begin Patch
374
+ *** Update File: /tmp/outside-workspace.ts
375
+ @@
376
+ -const value = 1;
377
+ +const value = 2;
378
+ *** End Patch`,
379
+ nativeCalls: null,
380
+ outcomes: { native: "unsupported", released: "fail", local: "fail" },
381
+ },
382
+ {
383
+ id: "bom-and-crlf",
384
+ kind: "valid-edit",
385
+ input: { "app.ts": "\ufeffconst old = 1;\r\nconst keep = 2;\r\n" },
386
+ expected: { "app.ts": "\ufeffconst next = 1;\r\nconst keep = 2;\r\n" },
387
+ patch: `*** Begin Patch
388
+ *** Update File: app.ts
389
+ @@
390
+ -const old = 1;
391
+ +const next = 1;
392
+ *** End Patch`,
393
+ nativeCalls: [{ path: "app.ts", edits: [{ oldText: "const old = 1;", newText: "const next = 1;" }] }],
394
+ outcomes: { native: "pass", released: "pass", local: "pass" },
395
+ },
396
+ ];
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ export function applyOperations(input, operations, applyUpdate) {
4
+ const files = new Map(Object.entries(input));
5
+ for (const operation of operations) {
6
+ if (operation.action === "add") {
7
+ if (files.has(operation.path)) throw new Error(`Cannot add existing file ${operation.path}`);
8
+ files.set(operation.path, operation.content);
9
+ continue;
10
+ }
11
+ if (!files.has(operation.path)) throw new Error(`Missing source file ${operation.path}`);
12
+ if (operation.action === "delete") {
13
+ files.delete(operation.path);
14
+ continue;
15
+ }
16
+ const groups = operation.chunkGroups ?? [operation.chunks];
17
+ const after = groups.reduce(
18
+ (content, chunks) => applyUpdate(content, chunks, operation.path),
19
+ files.get(operation.path),
20
+ );
21
+ if (operation.moveTo) {
22
+ if (files.has(operation.moveTo)) throw new Error(`Cannot move over existing file ${operation.moveTo}`);
23
+ files.delete(operation.path);
24
+ files.set(operation.moveTo, after);
25
+ } else {
26
+ files.set(operation.path, after);
27
+ }
28
+ }
29
+ return Object.fromEntries([...files].sort(([left], [right]) => left.localeCompare(right)));
30
+ }
31
+
32
+ export function runPatchCase(testCase, parse, applyUpdate, expectedOutcome) {
33
+ try {
34
+ const actual = applyOperations(testCase.input, parse(testCase.patch), applyUpdate);
35
+ if (expectedOutcome !== "pass") {
36
+ assert.fail(`${testCase.id}: expected ${expectedOutcome}, but patch succeeded`);
37
+ }
38
+ assert.deepEqual(actual, testCase.expected, `${testCase.id}: final filesystem differs`);
39
+ return "pass";
40
+ } catch (error) {
41
+ if (expectedOutcome === "pass") throw error;
42
+ return "fail";
43
+ }
44
+ }