import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getLanguageFromPath } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { EditTool } from "@oh-my-pi/pi-coding-agent/patch";
import { HASHLINE_NIBBLE_ALPHABET } from "@oh-my-pi/pi-coding-agent/patch/hashline";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { GrepTool } from "@oh-my-pi/pi-coding-agent/tools/grep";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import { resolveFileDisplayMode } from "@oh-my-pi/pi-coding-agent/utils/file-display-mode";
import { ChunkReadStatus, ChunkState } from "@oh-my-pi/pi-natives";
import { applyChunkEdits } from "../../src/tools/chunk-tree";
function getText(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content
.filter(block => block.type === "text")
.map(block => block.text ?? "")
.join("\n");
}
function createSession(cwd: string): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
};
}
function getChunkChecksum(source: string, language: string, chunkPath: string): string {
const chunk = ChunkState.parse(source, language).chunk(chunkPath);
if (!chunk) {
throw new Error(`Chunk not found in fixture: ${chunkPath}`);
}
return chunk.checksum;
}
function buildLargeTypescriptFixture(): string {
const body = Array.from({ length: 60 }, (_, index) => ` total += ${index};`).join("\n");
return `class Server {\n private handleError(err: Error): string {\n let total = 0;\n${body}\n return err.message + total;\n }\n}\n\nfunction main(): void {\n console.log("boot");\n}\n`;
}
describe("chunk mode tools", () => {
let tmpDir: string;
let originalEditVariant: string | undefined;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "chunk-mode-test-"));
_resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tmpDir });
originalEditVariant = Bun.env.PI_EDIT_VARIANT;
Bun.env.PI_EDIT_VARIANT = "chunk";
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
_resetSettingsForTest();
if (originalEditVariant === undefined) {
delete Bun.env.PI_EDIT_VARIANT;
} else {
Bun.env.PI_EDIT_VARIANT = originalEditVariant;
}
});
it("reads a TypeScript file as a chunk listing in chunk mode", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-root", { path: filePath });
const text = getText(result);
expect(text).toContain("server.ts·");
expect(text).toContain("[class_Server#");
expect(text).toContain("[fn_main#");
expect(text).not.toContain("ck:");
});
it("disables hashline display mode across shared tool renderers in chunk mode", () => {
const displayMode = resolveFileDisplayMode(createSession(tmpDir));
expect(displayMode.hashLines).toBe(false);
expect(displayMode.lineNumbers).toBe(false);
});
it("reads a specific chunk path and previews large child leaves inline", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-branch", { path: `${filePath}:class_Server.fn_handleError` });
const text = getText(result);
expect(text).not.toContain("to expand ⋮");
expect(text).toContain("let total = 0;");
expect(text).toContain("29| total += 25;");
expect(text).toContain("return err.message + total;");
});
it("renders line-range reads as range-scoped chunk output", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-lines", { path: filePath, sel: "L2-L4" });
const text = getText(result);
expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]");
expect(text).toContain("server.ts·");
expect(text).toContain("[class_Server.fn_handleError#");
expect(text).toContain("[class_Server.fn_handleError.var_total#");
expect(text).toContain("3|");
expect(text).toContain("4|");
expect(text).not.toContain("⋯");
});
it("supports L selectors in the path fragment in chunk mode", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-lines-in-path", { path: `${filePath}:L2-L4` });
const text = getText(result);
expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]");
expect(text).toContain("server.ts·");
expect(text).toContain("[class_Server.fn_handleError#");
expect(text).toContain("[class_Server.fn_handleError.var_total#");
expect(text).toContain("[class_Server.fn_handleError.var_total#");
expect(text).toContain("3|");
});
it("ignores a chunk selector checksum suffix on read", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const staleChecksum = getChunkChecksum(originalSource, "typescript", "class_Server.fn_handleError");
await Bun.write(filePath, originalSource.replace(" return err.message + total;", " return err.message;"));
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-stale-checksum", {
path: `${filePath}:class_Server.fn_handleError#${staleChecksum}`,
});
const text = getText(result);
expect(text).toContain("server.ts:class_Server.fn_handleError·");
expect(text).not.toContain("[Warning: checksum #");
});
it("routes .tsx files to the TSX grammar in chunk mode", async () => {
const filePath = path.join(tmpDir, "component.tsx");
await Bun.write(filePath, 'export function App() { return
ok
; }\n');
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-tsx", { path: filePath });
const text = getText(result);
expect(text).toContain("component.tsx·");
expect(text).toContain("tsx");
expect(text).toContain("[fn_App#");
});
it("maps Handlebars and TLA+ file extensions for chunk mode", () => {
expect(getLanguageFromPath("/tmp/template.hbs")).toBe("handlebars");
expect(getLanguageFromPath("/tmp/template.hsb")).toBe("handlebars");
expect(getLanguageFromPath("/tmp/spec.tla")).toBe("tlaplus");
expect(getLanguageFromPath("/tmp/spec.tlaplus")).toBe("tlaplus");
});
it("annotates grep hits with chunk paths", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new GrepTool(createSession(tmpDir));
const result = await tool.execute("chunk-grep", {
pattern: "err\\.message",
path: filePath,
});
const text = getText(result);
expect(text).toContain("server.ts:class_Server.fn_handleError");
expect(text).toContain(".return>64|");
expect(text).toContain("err.message");
});
it("replaces a chunk using a copied selector in path", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const session = createSession(tmpDir);
const readTool = new ReadTool(session);
const editTool = new EditTool(session);
const branchRead = await readTool.execute("chunk-read-before-edit", {
path: `${filePath}:class_Server.fn_handleError`,
});
const branchText = getText(branchRead);
const checksum = new RegExp(
`server\\.ts:class_Server\\.fn_handleError[^\n]*#([${HASHLINE_NIBBLE_ALPHABET}]{4})`,
"i",
).exec(branchText)?.[1];
expect(checksum).toBeDefined();
const editResult = await editTool.execute("chunk-edit", {
path: filePath,
edits: [
{
target: `class_Server.fn_handleError#${checksum}`,
content: ` private handleError(err: Error): string {
return \`normalized:\${err.message}\`;
}
`,
},
],
} as never);
const editText = getText(editResult);
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).toContain("normalized:");
expect(updatedSource).not.toContain("total +=");
expect(editText).toContain("server.ts·");
expect(editText).toContain("@@ -3,62 +3,1 @@");
});
it("applies omitted-sel batch splices when the second operation uses the post-first checksum", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const chunkPath = "class_Server.fn_handleError";
const checksum = getChunkChecksum(originalSource, "typescript", chunkPath);
const afterFirst = applyChunkEdits({
source: originalSource,
language: "typescript",
cwd: tmpDir,
filePath,
operations: [
{
op: "replace",
sel: chunkPath,
crc: checksum,
line: 63,
endLine: 63,
content: "return err.message.toUpperCase() + total;",
},
],
}).diffSourceAfter;
const checksum2 = getChunkChecksum(afterFirst, "typescript", chunkPath);
await editTool.execute("chunk-edit-default-selector-batch", {
path: filePath,
edits: [
{
target: `${chunkPath}#${checksum}`,
line: 63,
end_line: 63,
content: "return err.message.toUpperCase() + total;",
},
{ target: `${chunkPath}#${checksum2}`, line: 3, end_line: 3, content: "let total = 1;" },
],
} as never);
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).toContain("let total = 1;");
expect(updatedSource).toContain("return err.message.toUpperCase() + total;");
});
it("rejects L0 selector as invalid", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
await expect(tool.execute("chunk-read-L0", { path: filePath, sel: "L0" })).rejects.toThrow(
/L0 is invalid.*1-indexed/,
);
});
it("rejects reversed line range selector", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
await expect(tool.execute("chunk-read-reversed", { path: filePath, sel: "L30-L20" })).rejects.toThrow(
/end must be >= start/,
);
});
it("accepts string content for chunk insert operations", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const session = createSession(tmpDir);
const editTool = new EditTool(session);
await editTool.execute("chunk-edit-string-content", {
path: filePath,
edits: [{ target: "class_Server", op: "append", content: 'status(): string {\n return "ok";\n}\n' }],
} as never);
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).toContain("status(): string");
expect(updatedSource).toContain('return "ok";');
});
it("supports explicit absolute-line zero-width splices", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const chunkPath = "class_Server.fn_handleError";
const fileText = await Bun.file(filePath).text();
const checksum = getChunkChecksum(fileText, "typescript", chunkPath);
const afterInsertAfter = applyChunkEdits({
source: fileText,
language: "typescript",
cwd: tmpDir,
filePath,
operations: [
{
op: "replace",
sel: chunkPath,
crc: checksum,
line: 4,
endLine: 3,
content: "const end = Date.now();",
},
],
}).diffSourceAfter;
const checksum2 = getChunkChecksum(afterInsertAfter, "typescript", chunkPath);
// Zero-width splices at larger `end` run first (bottom-up) so line numbers stay stable.
await editTool.execute("chunk-edit-insert-lines", {
path: filePath,
edits: [
{ target: `${chunkPath}#${checksum}`, line: 4, end_line: 3, content: "const end = Date.now();" },
{ target: `${chunkPath}#${checksum2}`, line: 3, end_line: 2, content: "const start = Date.now();" },
],
} as never);
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).toContain(" const start = Date.now();");
expect(updatedSource).toContain(" const end = Date.now();");
expect(updatedSource).toContain(" let total = 0;");
});
it("treats empty replace content as delete", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const checksum = getChunkChecksum(originalSource, "typescript", "fn_main");
await editTool.execute("chunk-edit-empty-replace-delete", {
path: filePath,
edits: [{ target: `fn_main#${checksum}`, content: "" }],
});
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).not.toContain("function main(): void");
});
it("returns structured status for missing chunk reads", async () => {
const filePath = path.join(tmpDir, "server.ts");
await Bun.write(filePath, buildLargeTypescriptFixture());
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("chunk-read-missing", {
path: `${filePath}:class_Server.fn_missing`,
});
expect(getText(result)).toContain("Chunk path not found");
expect(result.details?.chunk).toEqual({
status: ChunkReadStatus.NotFound,
selector: "class_Server.fn_missing",
});
});
it("rejects reversed splice ranges without changing the file", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const checksum = getChunkChecksum(originalSource, "typescript", "class_Server.fn_handleError");
await expect(
editTool.execute("chunk-edit-invalid-splice-range", {
path: filePath,
edits: [
{
target: `class_Server.fn_handleError#${checksum}`,
line: 5,
end_line: 2,
content: " let total = 1;",
},
],
}),
).rejects.toThrow(/Invalid line range L5-L2/);
expect(await Bun.file(filePath).text()).toBe(originalSource);
});
it("rolls back mixed-validity batches without changing the file", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
await expect(
editTool.execute("chunk-edit-batch-rollback", {
path: filePath,
edits: [
{ target: "class_Server", op: "append", content: ' status(): string {\n return "ok";\n }' },
{ target: "class_Server.fn_handleError#ZZZZ", op: "delete" },
],
}),
).rejects.toThrow(/No changes were saved/);
expect(await Bun.file(filePath).text()).toBe(originalSource);
});
it("leaves files unchanged when an edit is rejected for introducing parse errors", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const checksum = getChunkChecksum(originalSource, "typescript", "class_Server.fn_handleError");
await expect(
editTool.execute("chunk-edit-parse-reject", {
path: filePath,
edits: [
{
target: `class_Server.fn_handleError#${checksum}`,
content: " private handleError(err: Error): string {\n if (err) {\n",
},
],
}),
).rejects.toThrow(/Parse errors:[\s\S]*L\d+:C\d+/i);
expect(await Bun.file(filePath).text()).toBe(originalSource);
});
it("auto-accepts stale CRC in mixed batches on the same chunk", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const checksum = getChunkChecksum(originalSource, "typescript", "class_Server.fn_handleError");
const _result = await editTool.execute("chunk-edit-stale-mixed-batch", {
path: filePath,
edits: [
{
target: `class_Server.fn_handleError#${checksum}`,
content: " private handleError(err: Error): string {\n return err.message;\n }",
},
{
target: `class_Server.fn_handleError#${checksum}`,
line: 3,
end_line: 3,
content: " return err.message.toUpperCase();",
},
],
});
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).toContain("toUpperCase");
});
it("rejects missing CRC", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
await expect(
editTool.execute("chunk-edit-strong-crc", {
path: filePath,
edits: [
{
target: "class_Server.fn_handleError",
line: 3,
end_line: 3,
content: " let total = 1;",
},
],
}),
).rejects.toThrow(/Checksum required/);
expect(await Bun.file(filePath).text()).toBe(originalSource);
});
it("rejects non-canonical edit selectors", async () => {
const filePath = path.join(tmpDir, "server.ts");
const originalSource = buildLargeTypescriptFixture();
await Bun.write(filePath, originalSource);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const checksum = getChunkChecksum(originalSource, "typescript", "fn_main");
await expect(
editTool.execute("chunk-edit-prefix-resolve", {
path: filePath,
edits: [{ target: `main#${checksum}`, content: 'function main(): void {\n console.log("started");\n}\n' }],
}),
).rejects.toThrow(/Chunk path not found: "main"/);
expect(await Bun.file(filePath).text()).toBe(originalSource);
});
it("resolves root-only checksum targets to the file root", async () => {
const filePath = path.join(tmpDir, "CONTRIBUTING.md");
const source = `# Contributing to uLua
## Building and Testing
Use just.
## Code Style
Use clang-format.
`;
await Bun.write(filePath, source);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const language = getLanguageFromPath(filePath);
if (!language) {
throw new Error("expected markdown language");
}
const state = ChunkState.parse(source, language);
const root = state.root();
if (!root) {
throw new Error("expected root chunk");
}
expect(state.chunks().filter(chunk => chunk.checksum === root.checksum).length).toBeGreaterThan(1);
const _result = await editTool.execute("chunk-edit-root-checksum", {
path: filePath,
edits: [{ target: `#${root.checksum}`, line: 1, end_line: 1, content: "# Updated guide" }],
});
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource.startsWith("# Updated guide\n")).toBe(true);
expect(updatedSource).toContain("## Building and Testing");
});
it("preserves sibling headings when replacing a whole markdown section", async () => {
const filePath = path.join(tmpDir, "CONTRIBUTING.md");
const source = [
"# Contributing to uLua",
"",
"## Building and Testing",
"",
"```bash",
"cmake -S . -B build",
"```",
"",
"## Code Style",
"",
"- Follow .clang-format.",
"",
"## Commit Messages",
"",
"- Use imperative mood.",
"",
].join("\n");
await Bun.write(filePath, source);
const session = createSession(tmpDir);
const editTool = new EditTool(session);
const language = getLanguageFromPath(filePath);
if (!language) {
throw new Error("expected markdown language");
}
const state = ChunkState.parse(source, language);
const building = state
.chunks()
.find(chunk => chunk.path === "section_Contributing_to_uLua.section_Building_and_Testing");
if (!building) {
throw new Error("expected section_Building_and_Testing chunk");
}
await editTool.execute("chunk-edit-section-replace", {
path: filePath,
edits: [
{
target: `${building.path}#${building.checksum}`,
content: "## Building and Testing\n\nUse `just verify` instead. It wraps cmake and ctest.\n",
},
],
});
const updatedSource = await Bun.file(filePath).text();
expect(updatedSource).toContain("## Code Style");
expect(updatedSource).toContain("## Commit Messages");
expect(updatedSource).toContain("Use `just verify`");
expect(updatedSource).not.toContain("cmake -S . -B build");
});
});