@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.
- package/CHANGELOG.md +20 -0
- package/README.md +40 -90
- package/extensions/{btw.ts → btw/index.ts} +1 -1
- package/extensions/{tests/btw-history.mjs → btw/tests/history.mjs} +1 -1
- package/package.json +19 -10
- package/packages/cache-export/CHANGELOG.md +5 -0
- package/packages/cache-export/README.md +70 -0
- package/packages/cache-export/TESTING.md +17 -0
- package/packages/cache-export/docs/demo.gif +0 -0
- package/packages/cache-export/docs/screenshot.png +0 -0
- package/{extensions/cache-export/index.ts → packages/cache-export/extensions/cache-export.ts} +1 -1
- package/packages/cache-export/package.json +22 -0
- package/{extensions/cache-export → packages/cache-export/src}/render.ts +1 -1
- package/{extensions → packages}/cache-export/tests/e2e-browser.mjs +2 -2
- package/{extensions → packages}/cache-export/tests/run-tests.mjs +2 -2
- package/packages/codex-edit/CHANGELOG.md +47 -0
- package/packages/codex-edit/README.md +130 -0
- package/packages/codex-edit/TESTING.md +51 -0
- package/packages/codex-edit/benchmark/explicit-edit-100.json +2824 -0
- package/packages/codex-edit/benchmark/export-evidence.mjs +77 -0
- package/packages/codex-edit/benchmark/incident-cases.mjs +37 -0
- package/packages/codex-edit/benchmark/prepare.mjs +181 -0
- package/packages/codex-edit/benchmark/results/explicit-edit-100-sol-low-v3.json +4562 -0
- package/packages/codex-edit/benchmark/run-with-budget.mjs +77 -0
- package/packages/codex-edit/benchmark/schedule.mjs +19 -0
- package/packages/codex-edit/benchmark/select-tasks.mjs +167 -0
- package/{extensions → packages}/codex-edit/codex-edit-explainer.html +43 -22
- package/packages/codex-edit/docs/demo.gif +0 -0
- package/packages/codex-edit/docs/screenshot.png +0 -0
- package/packages/codex-edit/docs/test-report.html +181 -0
- package/packages/codex-edit/docs/test-report.md +242 -0
- package/packages/codex-edit/docs/test-summary.md +9 -0
- package/{extensions/codex-edit/index.ts → packages/codex-edit/extensions/codex-edit.ts} +7 -5
- package/packages/codex-edit/package.json +54 -0
- package/{extensions/codex-edit → packages/codex-edit/src}/parser.ts +27 -5
- package/{extensions → packages}/codex-edit/summary.md +1 -1
- package/packages/codex-edit/tests/benchmark.test.mjs +81 -0
- package/packages/codex-edit/tests/comparison-cases.mjs +396 -0
- package/packages/codex-edit/tests/comparison-harness.mjs +44 -0
- package/packages/codex-edit/tests/generate-test-report.mjs +589 -0
- package/packages/codex-edit/tests/legacy-parser-v0.1.7.mjs +128 -0
- package/packages/codex-edit/tests/native-vs-codex.ts +109 -0
- package/packages/codex-edit/tests/package.test.mjs +51 -0
- package/packages/codex-edit/tests/parser.test.mjs +308 -0
- package/packages/codex-edit/tests/released-vs-local.test.mjs +31 -0
- package/packages/tiny-subagent/CHANGELOG.md +12 -0
- package/packages/tiny-subagent/README.md +128 -0
- package/packages/tiny-subagent/TESTING.md +20 -0
- package/packages/tiny-subagent/agents/worker.md +9 -0
- package/packages/tiny-subagent/docs/demo.gif +0 -0
- package/packages/tiny-subagent/docs/screenshot.png +0 -0
- package/packages/tiny-subagent/extensions/tiny-subagent.ts +228 -0
- package/packages/tiny-subagent/package.json +44 -0
- package/packages/tiny-subagent/src/personas.ts +110 -0
- package/packages/tiny-subagent/src/runner.ts +319 -0
- package/packages/tiny-subagent/tests/run-tests.mjs +111 -0
- package/packages/todo/CHANGELOG.md +5 -0
- package/packages/todo/README.md +63 -0
- package/packages/todo/TESTING.md +20 -0
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/{extensions → packages/todo/extensions}/todo.ts +1 -1
- package/packages/todo/package.json +25 -0
- package/{extensions → packages/todo}/tests/todo-state.mjs +1 -1
- package/packages/worktree/CHANGELOG.md +5 -0
- package/packages/worktree/README.md +60 -0
- package/packages/worktree/TESTING.md +21 -0
- package/packages/worktree/docs/demo.gif +0 -0
- package/packages/worktree/docs/screenshot.png +0 -0
- package/packages/worktree/package.json +20 -0
- package/packages/worktree/scripts/install.mjs +26 -0
- package/packages/worktree/tests/install.test.mjs +25 -0
- package/packages/worktree/tests/run-tests.sh +46 -0
- package/scripts/install-agents.mjs +8 -18
- package/extensions/codex-edit/README.md +0 -53
- package/extensions/codex-edit/tests/package.test.mjs +0 -38
- package/extensions/codex-edit/tests/parser.test.mjs +0 -89
- /package/extensions/{btw-scroll.ts → btw/scroll.ts} +0 -0
- /package/{extensions/cache-export → packages/cache-export/src}/tau-assets.ts +0 -0
- /package/{extensions → packages}/codex-edit/config.json +0 -0
- /package/{extensions/codex-edit → packages/codex-edit/src}/model-routing.ts +0 -0
- /package/{extensions → packages/todo/src}/todo-state.ts +0 -0
- /package/{bin → packages/worktree/bin}/pi-worktree +0 -0
- /package/{bin → packages/worktree/bin}/pi-worktree-completion.bash +0 -0
- /package/{skills → packages/worktree/skills}/pi-worktree/SKILL.md +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Frozen semantic baseline from @maxiaochao/pi-codex-edit v0.1.7.
|
|
2
|
+
// Source blob: codex-edit-v0.1.7:packages/codex-edit/src/parser.ts
|
|
3
|
+
// Git blob ID: e2896b59d40b5f6f01b3a4a7918476f4acc28cd8
|
|
4
|
+
// Keep this file unchanged; add cases to comparison-cases.mjs instead.
|
|
5
|
+
|
|
6
|
+
export function parseLegacyPatch(patch) {
|
|
7
|
+
const lines = patch.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split("\n");
|
|
8
|
+
if (lines[0]?.trim() !== "*** Begin Patch") throw new Error("The first line of the patch must be '*** Begin Patch'");
|
|
9
|
+
if (lines.at(-1)?.trim() !== "*** End Patch") throw new Error("The last line of the patch must be '*** End Patch'");
|
|
10
|
+
const operations = [];
|
|
11
|
+
let index = 1;
|
|
12
|
+
while (index < lines.length - 1) {
|
|
13
|
+
const line = lines[index];
|
|
14
|
+
if (line.startsWith("*** Add File: ")) {
|
|
15
|
+
const path = line.slice("*** Add File: ".length);
|
|
16
|
+
index += 1;
|
|
17
|
+
const content = [];
|
|
18
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
|
19
|
+
if (!lines[index].startsWith("+")) throw new Error(`Invalid add line ${index + 1}`);
|
|
20
|
+
content.push(lines[index].slice(1));
|
|
21
|
+
index += 1;
|
|
22
|
+
}
|
|
23
|
+
if (content.length === 0) throw new Error(`Add file hunk for path '${path}' is empty`);
|
|
24
|
+
operations.push({ action: "add", path, content: `${content.join("\n")}\n` });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (line.startsWith("*** Delete File: ")) {
|
|
28
|
+
const path = line.slice("*** Delete File: ".length);
|
|
29
|
+
if (!path) throw new Error(`Delete file hunk on line ${index + 1} has an empty path`);
|
|
30
|
+
operations.push({ action: "delete", path });
|
|
31
|
+
index += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (line.startsWith("*** Update File: ")) {
|
|
35
|
+
const path = line.slice("*** Update File: ".length);
|
|
36
|
+
index += 1;
|
|
37
|
+
let moveTo;
|
|
38
|
+
if (lines[index]?.startsWith("*** Move to: ")) {
|
|
39
|
+
moveTo = lines[index].slice("*** Move to: ".length);
|
|
40
|
+
index += 1;
|
|
41
|
+
}
|
|
42
|
+
const chunks = [];
|
|
43
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
|
44
|
+
let context;
|
|
45
|
+
if (lines[index] === "@@" || lines[index].startsWith("@@ ")) {
|
|
46
|
+
context = lines[index].slice(2).trim() || undefined;
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
const oldLines = [];
|
|
50
|
+
const newLines = [];
|
|
51
|
+
const contextLineIndices = [];
|
|
52
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ") && !lines[index].startsWith("@@")) {
|
|
53
|
+
const change = lines[index];
|
|
54
|
+
const marker = change[0];
|
|
55
|
+
if (!["+", "-", " "].includes(marker)) throw new Error(`Invalid update line ${index + 1}`);
|
|
56
|
+
const text = change.slice(1);
|
|
57
|
+
if (marker === "+") newLines.push(text);
|
|
58
|
+
else if (marker === "-") oldLines.push(text);
|
|
59
|
+
else {
|
|
60
|
+
contextLineIndices.push([oldLines.length, newLines.length]);
|
|
61
|
+
oldLines.push(text);
|
|
62
|
+
newLines.push(text);
|
|
63
|
+
}
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
const endOfFile = lines[index] === "*** End of File";
|
|
67
|
+
if (endOfFile) index += 1;
|
|
68
|
+
if (oldLines.length === 0 && newLines.length === 0) throw new Error(`Empty update chunk on line ${index + 1}`);
|
|
69
|
+
chunks.push({ context, oldLines, newLines, contextLineIndices, endOfFile });
|
|
70
|
+
}
|
|
71
|
+
if (chunks.length === 0) throw new Error(`Update file hunk for path '${path}' is empty`);
|
|
72
|
+
operations.push({ action: "update", path, moveTo, chunks });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Invalid hunk header on line ${index + 1}: ${line}`);
|
|
76
|
+
}
|
|
77
|
+
if (operations.length === 0) throw new Error("Patch contains no file operations");
|
|
78
|
+
const targets = operations.flatMap((operation) => [operation.path, operation.moveTo].filter(Boolean));
|
|
79
|
+
if (new Set(targets).size !== targets.length) throw new Error("Multiple operations target the same path");
|
|
80
|
+
return operations;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findCandidates(lines, pattern, start, eof, compare) {
|
|
84
|
+
const candidates = [];
|
|
85
|
+
for (let index = start; index + pattern.length <= lines.length; index += 1) {
|
|
86
|
+
if (pattern.every((line, offset) => compare(lines[index + offset], line))) candidates.push(index);
|
|
87
|
+
}
|
|
88
|
+
candidates.sort((left, right) => eof ? right - left : left - right);
|
|
89
|
+
return candidates;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function locate(lines, pattern, start, eof, label) {
|
|
93
|
+
if (pattern.length === 0) return Math.min(start, lines.length);
|
|
94
|
+
const comparisons = [
|
|
95
|
+
(left, right) => left === right,
|
|
96
|
+
(left, right) => left.trimEnd() === right.trimEnd(),
|
|
97
|
+
(left, right) => left.trim() === right.trim(),
|
|
98
|
+
];
|
|
99
|
+
for (const compare of comparisons) {
|
|
100
|
+
const candidates = findCandidates(lines, pattern, start, eof, compare);
|
|
101
|
+
if (candidates.length > 0) return candidates[0];
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`Failed to find expected lines${label ? ` in ${label}` : ""}: ${pattern.join("\\n")}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function applyLegacyUpdate(content, chunks, path) {
|
|
107
|
+
const bom = content.startsWith("\ufeff") ? "\ufeff" : "";
|
|
108
|
+
const withoutBom = bom ? content.slice(1) : content;
|
|
109
|
+
const ending = withoutBom.includes("\r\n") ? "\r\n" : "\n";
|
|
110
|
+
const normalized = withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
111
|
+
const hadFinalNewline = normalized.endsWith("\n");
|
|
112
|
+
const lines = normalized === "" ? [] : normalized.split("\n");
|
|
113
|
+
if (hadFinalNewline) lines.pop();
|
|
114
|
+
const replacements = [];
|
|
115
|
+
let cursor = 0;
|
|
116
|
+
for (const chunk of chunks) {
|
|
117
|
+
if (chunk.context !== undefined) cursor = locate(lines, [chunk.context], cursor, false, path) + 1;
|
|
118
|
+
const start = locate(lines, chunk.oldLines, cursor, chunk.endOfFile, path);
|
|
119
|
+
replacements.push({ start, length: chunk.oldLines.length, next: chunk.newLines });
|
|
120
|
+
cursor = start + chunk.oldLines.length;
|
|
121
|
+
}
|
|
122
|
+
const output = [...lines];
|
|
123
|
+
for (const replacement of [...replacements].reverse()) output.splice(replacement.start, replacement.length, ...replacement.next);
|
|
124
|
+
let next = output.join("\n");
|
|
125
|
+
if (hadFinalNewline || next.length > 0) next += "\n";
|
|
126
|
+
if (ending === "\r\n") next = next.replace(/\n/g, "\r\n");
|
|
127
|
+
return bom + next;
|
|
128
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { createEditToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import registerCodexEdit from "../extensions/codex-edit.ts";
|
|
7
|
+
import { comparisonCases } from "./comparison-cases.mjs";
|
|
8
|
+
|
|
9
|
+
async function writeSnapshot(root, snapshot) {
|
|
10
|
+
for (const [path, content] of Object.entries(snapshot)) {
|
|
11
|
+
const absolute = join(root, path);
|
|
12
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
13
|
+
await writeFile(absolute, content, "utf8");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readSnapshot(root, relative = "") {
|
|
18
|
+
const snapshot = {};
|
|
19
|
+
for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
|
|
20
|
+
const path = relative ? `${relative}/${entry.name}` : entry.name;
|
|
21
|
+
if (entry.isDirectory()) Object.assign(snapshot, await readSnapshot(root, path));
|
|
22
|
+
else snapshot[path] = await readFile(join(root, path), "utf8");
|
|
23
|
+
}
|
|
24
|
+
return snapshot;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runNativeCase(testCase) {
|
|
28
|
+
if (testCase.outcomes.native === "unsupported") {
|
|
29
|
+
assert.equal(testCase.nativeCalls, null, `${testCase.id}: unsupported native case must not invent edit calls`);
|
|
30
|
+
return "unsupported";
|
|
31
|
+
}
|
|
32
|
+
const workspace = await mkdtemp(join(tmpdir(), "codex-edit-native-"));
|
|
33
|
+
try {
|
|
34
|
+
await writeSnapshot(workspace, testCase.input);
|
|
35
|
+
const edit = createEditToolDefinition(workspace);
|
|
36
|
+
try {
|
|
37
|
+
for (const [index, call] of testCase.nativeCalls.entries()) {
|
|
38
|
+
await edit.execute(`native-${index}`, call, undefined, undefined, { cwd: workspace });
|
|
39
|
+
}
|
|
40
|
+
if (testCase.outcomes.native !== "pass") {
|
|
41
|
+
assert.fail(`${testCase.id}: native edit unexpectedly succeeded`);
|
|
42
|
+
}
|
|
43
|
+
assert.deepEqual(await readSnapshot(workspace), testCase.expected);
|
|
44
|
+
return "pass";
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (testCase.outcomes.native === "pass") throw error;
|
|
47
|
+
return "fail";
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(workspace, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getCodexTool() {
|
|
55
|
+
let tool;
|
|
56
|
+
await registerCodexEdit({
|
|
57
|
+
registerTool(definition) { tool = definition; },
|
|
58
|
+
on() {},
|
|
59
|
+
getActiveTools() { return []; },
|
|
60
|
+
});
|
|
61
|
+
assert.ok(tool, "Codex Edit extension did not register apply_patch");
|
|
62
|
+
return tool;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runCodexCase(tool, testCase) {
|
|
66
|
+
const workspace = await mkdtemp(join(tmpdir(), "codex-edit-local-"));
|
|
67
|
+
try {
|
|
68
|
+
await writeSnapshot(workspace, testCase.input);
|
|
69
|
+
try {
|
|
70
|
+
await tool.execute("codex-local", { patch: testCase.patch }, undefined, undefined, {
|
|
71
|
+
cwd: workspace,
|
|
72
|
+
hasUI: false,
|
|
73
|
+
});
|
|
74
|
+
if (testCase.outcomes.local !== "pass") {
|
|
75
|
+
assert.fail(`${testCase.id}: local Codex Edit unexpectedly succeeded`);
|
|
76
|
+
}
|
|
77
|
+
assert.deepEqual(await readSnapshot(workspace), testCase.expected);
|
|
78
|
+
return "pass";
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (testCase.outcomes.local === "pass") throw error;
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
await readSnapshot(workspace),
|
|
83
|
+
testCase.input,
|
|
84
|
+
`${testCase.id}: failed Codex patch must not partially mutate the workspace`,
|
|
85
|
+
);
|
|
86
|
+
return "fail";
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
await rm(workspace, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default async function () {
|
|
94
|
+
const codexTool = await getCodexTool();
|
|
95
|
+
const results = [];
|
|
96
|
+
for (const testCase of comparisonCases) {
|
|
97
|
+
const native = await runNativeCase(testCase);
|
|
98
|
+
const codex = await runCodexCase(codexTool, testCase);
|
|
99
|
+
results.push({ id: testCase.id, native, codex });
|
|
100
|
+
}
|
|
101
|
+
assert.equal(results.length, comparisonCases.length);
|
|
102
|
+
assert.equal(results.filter(({ native }) => native === "pass").length, 5);
|
|
103
|
+
assert.equal(results.filter(({ native }) => native === "fail").length, 2);
|
|
104
|
+
assert.equal(results.filter(({ native }) => native === "unsupported").length, 5);
|
|
105
|
+
assert.equal(results.filter(({ codex }) => codex === "pass").length, 7);
|
|
106
|
+
assert.equal(results.filter(({ codex }) => codex === "fail").length, 5);
|
|
107
|
+
for (const result of results) console.log(`${result.id}: native=${result.native}, codex=${result.codex}`);
|
|
108
|
+
console.log(`Native edit vs local Codex Edit: ${results.length} comparison cases passed`);
|
|
109
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { modelIsAllowed } from "../src/model-routing.ts";
|
|
6
|
+
|
|
7
|
+
const packageDir = dirname(dirname(new URL(import.meta.url).pathname));
|
|
8
|
+
|
|
9
|
+
test("package points Pi at the extension and config is reachable from it", async () => {
|
|
10
|
+
const manifest = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
|
|
11
|
+
assert.deepEqual(manifest.pi.extensions, ["./extensions/codex-edit.ts"]);
|
|
12
|
+
const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
|
|
13
|
+
assert.match(source, /new URL\("\.\.\/config\.json", import\.meta\.url\)/);
|
|
14
|
+
assert.match(source, /model-routing\.ts/);
|
|
15
|
+
assert.match(source, /parameters: Type\.Object\(\{\s*patch: Type\.String/);
|
|
16
|
+
assert.match(source, /applyUpdateGroups\(before, operation\.chunkGroups, operation\.path\)/);
|
|
17
|
+
const config = JSON.parse(await readFile(join(packageDir, "config.json"), "utf8"));
|
|
18
|
+
assert.equal(config.enabled, true);
|
|
19
|
+
assert.deepEqual(config.models, ["gpt-5.6-*"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("matches allowed model IDs with globs independently of provider and grammar support", () => {
|
|
23
|
+
const settings = { enabled: true, models: ["gpt-5.6-*", "my-codex-model"] };
|
|
24
|
+
const baseModel = {
|
|
25
|
+
id: "gpt-5.6-terra",
|
|
26
|
+
api: "openai-codex-responses",
|
|
27
|
+
compat: { supportsOpenAIGrammarTools: true },
|
|
28
|
+
};
|
|
29
|
+
assert.equal(modelIsAllowed({ ...baseModel, provider: "openai-codex" }, settings), true);
|
|
30
|
+
assert.equal(modelIsAllowed({ ...baseModel, provider: "another-provider" }, settings), true);
|
|
31
|
+
assert.equal(modelIsAllowed({ ...baseModel, compat: { supportsOpenAIGrammarTools: false } }, settings), true);
|
|
32
|
+
assert.equal(
|
|
33
|
+
modelIsAllowed(
|
|
34
|
+
{ ...baseModel, api: "openai-responses", compat: { sessionAffinityFormat: "openai-nosession" } },
|
|
35
|
+
settings,
|
|
36
|
+
),
|
|
37
|
+
true,
|
|
38
|
+
);
|
|
39
|
+
assert.equal(modelIsAllowed({ ...baseModel, api: "openai-completions" }, settings), false);
|
|
40
|
+
assert.equal(modelIsAllowed({ ...baseModel, id: "gpt-5.5" }, settings), false);
|
|
41
|
+
assert.equal(modelIsAllowed({ ...baseModel, id: "my-codex-model" }, settings), true);
|
|
42
|
+
assert.equal(modelIsAllowed({ ...baseModel, id: "my-codex-model-v2" }, settings), false);
|
|
43
|
+
assert.equal(modelIsAllowed({ ...baseModel, id: "GPT-5.6-terra" }, settings), false);
|
|
44
|
+
assert.equal(modelIsAllowed(baseModel, { enabled: false, models: ["gpt-5.6-*"] }), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("execution keeps a resolved-path conflict guard after parser coalescing", async () => {
|
|
48
|
+
const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
|
|
49
|
+
assert.match(source, /new Set\(targets\)\.size !== targets\.length/);
|
|
50
|
+
assert.match(source, /Multiple patch operations target the same resolved path/);
|
|
51
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { applyUpdate, applyUpdateGroups, parsePatch, resolveWorkspacePath } from "../src/parser.ts";
|
|
7
|
+
|
|
8
|
+
test("parses add, delete, update, and move operations", () => {
|
|
9
|
+
const operations = parsePatch(`*** Begin Patch
|
|
10
|
+
*** Add File: added.txt
|
|
11
|
+
+hello
|
|
12
|
+
*** Delete File: removed.txt
|
|
13
|
+
*** Update File: old.txt
|
|
14
|
+
*** Move to: new.txt
|
|
15
|
+
@@
|
|
16
|
+
-old
|
|
17
|
+
+new
|
|
18
|
+
*** End Patch`);
|
|
19
|
+
assert.deepEqual(operations.map((operation) => operation.action), ["add", "delete", "update"]);
|
|
20
|
+
assert.equal(operations[0].content, "hello\n");
|
|
21
|
+
assert.equal(operations[2].moveTo, "new.txt");
|
|
22
|
+
assert.deepEqual(operations[2].chunks[0].oldLines, ["old"]);
|
|
23
|
+
assert.deepEqual(operations[2].chunks[0].newLines, ["new"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects malformed patches and duplicate targets", () => {
|
|
27
|
+
assert.throws(() => parsePatch("bad"), /first line/);
|
|
28
|
+
assert.throws(() => parsePatch(`*** Begin Patch
|
|
29
|
+
*** Add File: same.txt
|
|
30
|
+
+x
|
|
31
|
+
*** Add File: same.txt
|
|
32
|
+
+y
|
|
33
|
+
*** End Patch`), /same path/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("applies multiple chunks using context and preserves BOM and CRLF", () => {
|
|
37
|
+
const before = "\ufefffunction run() {\r\n const value = 1;\r\n return value;\r\n}\r\n";
|
|
38
|
+
const operations = parsePatch(`*** Begin Patch
|
|
39
|
+
*** Update File: src/run.js
|
|
40
|
+
@@ function run() {
|
|
41
|
+
- const value = 1;
|
|
42
|
+
+ const value = 2;
|
|
43
|
+
@@
|
|
44
|
+
- return value;
|
|
45
|
+
+ return value + 1;
|
|
46
|
+
*** End Patch`);
|
|
47
|
+
const after = applyUpdate(before, operations[0].chunks, "src/run.js");
|
|
48
|
+
assert.equal(after, "\ufefffunction run() {\r\n const value = 2;\r\n return value + 1;\r\n}\r\n");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("supports insertion chunks and EOF matching", () => {
|
|
52
|
+
const operations = parsePatch(`*** Begin Patch
|
|
53
|
+
*** Update File: file.txt
|
|
54
|
+
@@
|
|
55
|
+
+first
|
|
56
|
+
*** End of File
|
|
57
|
+
*** End Patch`);
|
|
58
|
+
assert.equal(applyUpdate("", operations[0].chunks, "file.txt"), "first\n");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("uses the first matching region at or after the cursor", () => {
|
|
62
|
+
const operations = parsePatch(`*** Begin Patch
|
|
63
|
+
*** Update File: file.txt
|
|
64
|
+
@@
|
|
65
|
+
-value
|
|
66
|
+
+changed
|
|
67
|
+
*** End Patch`);
|
|
68
|
+
assert.equal(applyUpdate("value\nvalue\n", operations[0].chunks, "file.txt"), "changed\nvalue\n");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("uses earlier hunks to advance the cursor through repeated HTML regions", () => {
|
|
72
|
+
const operations = parsePatch(`*** Begin Patch
|
|
73
|
+
*** Update File: page.html
|
|
74
|
+
@@
|
|
75
|
+
-old
|
|
76
|
+
+new
|
|
77
|
+
@@
|
|
78
|
+
</section>
|
|
79
|
+
+<section id="inserted"></section>
|
|
80
|
+
<section>
|
|
81
|
+
*** End Patch`);
|
|
82
|
+
const before = `<header>
|
|
83
|
+
old
|
|
84
|
+
</header>
|
|
85
|
+
<section>first</section>
|
|
86
|
+
</section>
|
|
87
|
+
<section>
|
|
88
|
+
one
|
|
89
|
+
</section>
|
|
90
|
+
<section>
|
|
91
|
+
two
|
|
92
|
+
</section>
|
|
93
|
+
`;
|
|
94
|
+
const after = `<header>
|
|
95
|
+
new
|
|
96
|
+
</header>
|
|
97
|
+
<section>first</section>
|
|
98
|
+
</section>
|
|
99
|
+
<section id="inserted"></section>
|
|
100
|
+
<section>
|
|
101
|
+
one
|
|
102
|
+
</section>
|
|
103
|
+
<section>
|
|
104
|
+
two
|
|
105
|
+
</section>
|
|
106
|
+
`;
|
|
107
|
+
assert.equal(applyUpdate(before, operations[0].chunks, "page.html"), after);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("applies a hunk at the first repeated Markdown separator after its cursor", () => {
|
|
111
|
+
const operations = parsePatch(`*** Begin Patch
|
|
112
|
+
*** Update File: SKILL.md
|
|
113
|
+
@@
|
|
114
|
+
-description: old
|
|
115
|
+
+description: new
|
|
116
|
+
@@
|
|
117
|
+
---
|
|
118
|
+
+Inserted after frontmatter.
|
|
119
|
+
*** End Patch`);
|
|
120
|
+
const before = `---
|
|
121
|
+
name: demo
|
|
122
|
+
description: old
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
# Demo
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
Footer
|
|
129
|
+
`;
|
|
130
|
+
const after = `---
|
|
131
|
+
name: demo
|
|
132
|
+
description: new
|
|
133
|
+
---
|
|
134
|
+
Inserted after frontmatter.
|
|
135
|
+
|
|
136
|
+
# Demo
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
Footer
|
|
140
|
+
`;
|
|
141
|
+
assert.equal(applyUpdate(before, operations[0].chunks, "SKILL.md"), after);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("parses multiple file operations used by structural refactors", () => {
|
|
145
|
+
const operations = parsePatch(`*** Begin Patch
|
|
146
|
+
*** Update File: extensions/tool.ts
|
|
147
|
+
*** Move to: packages/tool/extensions/tool.ts
|
|
148
|
+
@@
|
|
149
|
+
-export const value = 1;
|
|
150
|
+
+export const value = 2;
|
|
151
|
+
*** Add File: extensions/tool-wrapper.ts
|
|
152
|
+
+export { value } from "../packages/tool/extensions/tool.ts";
|
|
153
|
+
*** Delete File: extensions/tool.test.ts
|
|
154
|
+
*** End Patch`);
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
operations.map(({ action, path, moveTo }) => ({ action, path, moveTo })),
|
|
157
|
+
[
|
|
158
|
+
{ action: "update", path: "extensions/tool.ts", moveTo: "packages/tool/extensions/tool.ts" },
|
|
159
|
+
{ action: "add", path: "extensions/tool-wrapper.ts", moveTo: undefined },
|
|
160
|
+
{ action: "delete", path: "extensions/tool.test.ts", moveTo: undefined },
|
|
161
|
+
],
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("coalesces repeated updates to one path and preserves operation order", () => {
|
|
166
|
+
const operations = parsePatch(`*** Begin Patch
|
|
167
|
+
*** Update File: src/tool.ts
|
|
168
|
+
@@
|
|
169
|
+
-const value = 1;
|
|
170
|
+
+const value = 2;
|
|
171
|
+
*** Update File: src/tool.ts
|
|
172
|
+
@@
|
|
173
|
+
-const value = 2;
|
|
174
|
+
+const value = 3;
|
|
175
|
+
*** End Patch`);
|
|
176
|
+
assert.equal(operations.length, 1);
|
|
177
|
+
assert.equal(operations[0].path, "src/tool.ts");
|
|
178
|
+
assert.equal(operations[0].chunkGroups.length, 2);
|
|
179
|
+
const after = applyUpdateGroups("const value = 1;\n", operations[0].chunkGroups, "src/tool.ts");
|
|
180
|
+
assert.equal(after, "const value = 3;\n");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("still rejects conflicting repeated targets", () => {
|
|
184
|
+
assert.throws(() => parsePatch(`*** Begin Patch
|
|
185
|
+
*** Update File: src/tool.ts
|
|
186
|
+
@@
|
|
187
|
+
-old
|
|
188
|
+
+new
|
|
189
|
+
*** Delete File: src/tool.ts
|
|
190
|
+
*** End Patch`), /same path/);
|
|
191
|
+
assert.throws(() => parsePatch(`*** Begin Patch
|
|
192
|
+
*** Update File: src/old.ts
|
|
193
|
+
*** Move to: src/new.ts
|
|
194
|
+
@@
|
|
195
|
+
-old
|
|
196
|
+
+new
|
|
197
|
+
*** Add File: src/new.ts
|
|
198
|
+
+replacement
|
|
199
|
+
*** End Patch`), /same path/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("rejects empty chunks and stale expected lines instead of guessing", () => {
|
|
203
|
+
assert.throws(() => parsePatch(`*** Begin Patch
|
|
204
|
+
*** Update File: src/tool.ts
|
|
205
|
+
@@
|
|
206
|
+
*** End Patch`), /Empty update chunk/);
|
|
207
|
+
const [operation] = parsePatch(`*** Begin Patch
|
|
208
|
+
*** Update File: src/tool.ts
|
|
209
|
+
@@
|
|
210
|
+
-missing line
|
|
211
|
+
+replacement
|
|
212
|
+
*** End Patch`);
|
|
213
|
+
assert.throws(
|
|
214
|
+
() => applyUpdate("actual line\n", operation.chunks, operation.path),
|
|
215
|
+
/Failed to find expected lines in src\/tool\.ts/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("handles the reported project-scale repeated-file and Markdown shapes", () => {
|
|
220
|
+
const operations = parsePatch(`*** Begin Patch
|
|
221
|
+
*** Update File: skills/pi-worktree/SKILL.md
|
|
222
|
+
@@
|
|
223
|
+
-description: old
|
|
224
|
+
+description: updated
|
|
225
|
+
@@
|
|
226
|
+
---
|
|
227
|
+
+Release notes follow.
|
|
228
|
+
*** Update File: packages/tiny-subagent/src/tool.ts
|
|
229
|
+
@@
|
|
230
|
+
-const state = "old";
|
|
231
|
+
+const state = "middle";
|
|
232
|
+
*** Update File: packages/tiny-subagent/src/tool.ts
|
|
233
|
+
@@
|
|
234
|
+
-const state = "middle";
|
|
235
|
+
+const state = "new";
|
|
236
|
+
*** Update File: scripts/release-package.sh
|
|
237
|
+
@@
|
|
238
|
+
-ROOT_LEVEL=patch
|
|
239
|
+
+ROOT_LEVEL=minor
|
|
240
|
+
*** Update File: scripts/release-package.sh
|
|
241
|
+
@@
|
|
242
|
+
-ROOT_LEVEL=minor
|
|
243
|
+
+ROOT_LEVEL=patch
|
|
244
|
+
*** End Patch`);
|
|
245
|
+
assert.deepEqual(operations.map((operation) => operation.path), [
|
|
246
|
+
"skills/pi-worktree/SKILL.md",
|
|
247
|
+
"packages/tiny-subagent/src/tool.ts",
|
|
248
|
+
"scripts/release-package.sh",
|
|
249
|
+
]);
|
|
250
|
+
assert.equal(operations[1].chunkGroups.length, 2);
|
|
251
|
+
assert.equal(operations[2].chunkGroups.length, 2);
|
|
252
|
+
|
|
253
|
+
const skill = `---
|
|
254
|
+
name: pi-worktree
|
|
255
|
+
description: old
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
# pi-worktree
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
Footer
|
|
262
|
+
`;
|
|
263
|
+
assert.equal(
|
|
264
|
+
applyUpdateGroups(skill, operations[0].chunkGroups, operations[0].path),
|
|
265
|
+
`---
|
|
266
|
+
name: pi-worktree
|
|
267
|
+
description: updated
|
|
268
|
+
---
|
|
269
|
+
Release notes follow.
|
|
270
|
+
|
|
271
|
+
# pi-worktree
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
Footer
|
|
275
|
+
`,
|
|
276
|
+
);
|
|
277
|
+
assert.equal(
|
|
278
|
+
applyUpdateGroups('const state = "old";\n', operations[1].chunkGroups, operations[1].path),
|
|
279
|
+
'const state = "new";\n',
|
|
280
|
+
);
|
|
281
|
+
assert.equal(
|
|
282
|
+
applyUpdateGroups("ROOT_LEVEL=patch\n", operations[2].chunkGroups, operations[2].path),
|
|
283
|
+
"ROOT_LEVEL=patch\n",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("rejects workspace escapes", () => {
|
|
288
|
+
assert.equal(resolveWorkspacePath("/tmp/project", "/tmp/project/src/a.js"), "/tmp/project/src/a.js");
|
|
289
|
+
assert.throws(() => resolveWorkspacePath("/tmp/project", "/tmp/other/a.js"), /escapes/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("round-trips a temporary file operation fixture", async () => {
|
|
293
|
+
const workspace = await mkdtemp(join(tmpdir(), "codex-edit-test-"));
|
|
294
|
+
try {
|
|
295
|
+
const path = join(workspace, "file.txt");
|
|
296
|
+
await writeFile(path, "before\n", "utf8");
|
|
297
|
+
const operations = parsePatch(`*** Begin Patch
|
|
298
|
+
*** Update File: file.txt
|
|
299
|
+
@@
|
|
300
|
+
-before
|
|
301
|
+
+after
|
|
302
|
+
*** End Patch`);
|
|
303
|
+
await writeFile(path, applyUpdate(await readFile(path, "utf8"), operations[0].chunks, "file.txt"), "utf8");
|
|
304
|
+
assert.equal(await readFile(path, "utf8"), "after\n");
|
|
305
|
+
} finally {
|
|
306
|
+
await rm(workspace, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { applyUpdate, parsePatch } from "../src/parser.ts";
|
|
4
|
+
import { comparisonCases } from "./comparison-cases.mjs";
|
|
5
|
+
import { runPatchCase } from "./comparison-harness.mjs";
|
|
6
|
+
import { applyLegacyUpdate, parseLegacyPatch } from "./legacy-parser-v0.1.7.mjs";
|
|
7
|
+
|
|
8
|
+
test("released v0.1.7 versus local parser over the growing comparison corpus", () => {
|
|
9
|
+
const results = [];
|
|
10
|
+
for (const testCase of comparisonCases) {
|
|
11
|
+
const released = runPatchCase(
|
|
12
|
+
testCase,
|
|
13
|
+
parseLegacyPatch,
|
|
14
|
+
applyLegacyUpdate,
|
|
15
|
+
testCase.outcomes.released,
|
|
16
|
+
);
|
|
17
|
+
const local = runPatchCase(testCase, parsePatch, applyUpdate, testCase.outcomes.local);
|
|
18
|
+
results.push({ id: testCase.id, released, local });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
assert.equal(results.length, comparisonCases.length);
|
|
22
|
+
assert.deepEqual(
|
|
23
|
+
results.find(({ id }) => id === "repeated-update-same-path"),
|
|
24
|
+
{ id: "repeated-update-same-path", released: "fail", local: "pass" },
|
|
25
|
+
);
|
|
26
|
+
assert.equal(results.filter(({ released }) => released === "pass").length, 6);
|
|
27
|
+
assert.equal(results.filter(({ released }) => released === "fail").length, 6);
|
|
28
|
+
assert.equal(results.filter(({ local }) => local === "pass").length, 7);
|
|
29
|
+
assert.equal(results.filter(({ local }) => local === "fail").length, 5);
|
|
30
|
+
for (const result of results) console.log(`${result.id}: released=${result.released}, local=${result.local}`);
|
|
31
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## 2026-09-30 - v0.1.1
|
|
2
|
+
|
|
3
|
+
- Add standalone testing documentation and real production-runner screenshot/demo media.
|
|
4
|
+
- Align the package manifest and root bundle with the shared workspace source.
|
|
5
|
+
|
|
6
|
+
## 2026-09-01 - v0.1.0
|
|
7
|
+
|
|
8
|
+
- Add the `tiny_subagents` Pi tool for one focused prompt in a fresh child Pi context.
|
|
9
|
+
- Add package-local `worker.md` personas with model and built-in tool selection.
|
|
10
|
+
- Return structured process, Pi protocol, stderr, stop-reason, exit, signal, and cancellation diagnostics.
|
|
11
|
+
- Preserve oversized stdout, stderr, and final assistant output in private temporary artifacts.
|
|
12
|
+
- Stream child event activity to the TUI and expose persona, prompt, and transcript details when expanded.
|