Files
oh-my-pi/packages/coding-agent/test/tools/edit-renderer.test.ts
T
can1357 860eef3f2f refactor(hashline): switched header syntax to bracketed [path#tag]
- Replaced `¶path#hash` prefix with `[path#hash]` delimiters across parser, tokenizer, and grammar.
- Updated prompts, docs, and recovery paths to the new bracketed form.
2026-06-06 15:38:08 +02:00

278 lines
10 KiB
TypeScript

import { beforeAll, 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 { InMemorySnapshotStore } from "@oh-my-pi/hashline";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { Text, type TUI } from "@oh-my-pi/pi-tui";
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
async function getUiTheme() {
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
return theme!;
}
async function waitForRenderedText(
component: ToolExecutionComponent,
width: number,
expectedText: string,
): Promise<string> {
const deadline = Date.now() + 1_000;
let rendered = "";
while (Date.now() < deadline) {
rendered = Bun.stripANSI(component.render(width).join("\n"));
if (rendered.includes(expectedText)) return rendered;
await Bun.sleep(10);
}
return rendered;
}
describe("editToolRenderer", () => {
it("shows the target path from partial JSON while edit args stream", async () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall(
{
edits: [{}],
__partialJson: '{"edits":[{"path":"packages/coding-agent/src/edit/renderer.ts","old_text":"before',
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } },
uiTheme,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts");
});
it("uses hashline input headers for streaming call path without apply_patch errors", async () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall(
{
input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts");
expect(rendered).not.toContain("The first line of the patch must be");
});
it("shows hashline envelope target path while preview diff is not computable yet", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool;
const component = new ToolExecutionComponent(
"edit",
{
input: [
"*** Begin Patch",
"[crates/pi-natives/src/shell.rs]",
"insert tail:",
"+pub fn streaming_preview() {",
].join("\n"),
},
{},
hashlineTool,
uiStub,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("crates/pi-natives/src/shell.rs");
expect(rendered).not.toContain("insert tail:");
expect(rendered).not.toContain("+pub fn streaming_preview() {");
expect(rendered).not.toContain("*** Begin Patch");
});
it("recognizes compact and quoted hashline input headers", async () => {
const uiTheme = await getUiTheme();
const compactComponent = editToolRenderer.renderCall(
{
input: "[foo bar.ts]\ninsert head:\n+// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
const quotedComponent = editToolRenderer.renderCall(
{
input: "['baz qux.ts']\ninsert head:\n+// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
const compactRendered = Bun.stripANSI(compactComponent.render(160).join("\n"));
const quotedRendered = Bun.stripANSI(quotedComponent.render(160).join("\n"));
expect(compactRendered).toContain("foo bar.ts");
expect(quotedRendered).toContain("baz qux.ts");
});
it("strips bracket delimiters from hashline input headers", async () => {
const uiTheme = await getUiTheme();
// Canonical `[PATH]` form — the parser strips the delimiters and the
// renderer keeps the title clean.
const canonical = editToolRenderer.renderCall(
{
input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\ninsert head:\n+// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
// While streaming, the closing bracket may not have arrived yet.
const partial = editToolRenderer.renderCall(
{ input: "[a/b/c.ts\ninsert head:\n+// preview" },
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
const canonicalRendered = Bun.stripANSI(canonical.render(160).join("\n"));
const partialRendered = Bun.stripANSI(partial.render(160).join("\n"));
expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts");
expect(canonicalRendered).not.toMatch(/\[packages\/coding-agent/);
expect(partialRendered).toContain("a/b/c.ts");
expect(partialRendered).not.toMatch(/\[a\/b\/c\.ts/);
});
it("uses hashline input headers for completed single-file result path", async () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderResult(
{
content: [{ type: "text", text: "Updated packages/coding-agent/src/edit/renderer.ts" }],
details: {
diff: "+1|// preview",
op: "update",
},
},
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
uiTheme,
{
input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview",
},
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts");
expect(rendered).not.toContain(" …");
});
it("computes the hashline preview diff once a single-line edit finishes streaming", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-stream-preview-"));
try {
const content = "export const a = 1;\nexport const b = 2;\nexport const c = 3;\n";
const filePath = path.join(tmpDir, "memory.ts");
await Bun.write(filePath, content);
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(filePath, content);
// The trailing payload line carries no newline — the common shape for a
// single-line edit. The streaming pass trims that in-flight line, so the
// preview only becomes computable once args are marked complete.
const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;`;
const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir);
component.setArgsComplete();
await Bun.sleep(50);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("export const b = 22;");
expect(rendered).not.toContain("No changes would be made");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("renders raw custom hashline input carried only in partialJson", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-custom-stream-preview-"));
try {
const content = "export const a = 1;\nexport const b = 2;\n";
const filePath = path.join(tmpDir, "memory.ts");
await Bun.write(filePath, content);
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(filePath, content);
const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;\n`;
const component = new ToolExecutionComponent(
"edit",
{ __partialJson: input },
{ snapshots },
hashlineTool,
uiStub,
tmpDir,
);
const rendered = await waitForRenderedText(component, 160, "export const b = 22;");
expect(rendered).toContain("memory.ts");
expect(rendered).toContain("export const b = 22;");
expect(rendered).not.toContain(" …");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("renders raw custom apply_patch input carried only in partialJson", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const input = [
"*** Begin Patch",
"*** Update File: src/demo.ts",
"@@",
"-const value = 1;",
"+const value = 2;",
"*** End Patch",
].join("\n");
const component = new ToolExecutionComponent("apply_patch", { __partialJson: input }, {}, undefined, uiStub);
const rendered = await waitForRenderedText(component, 160, "const value = 2;");
expect(rendered).toContain("src/demo.ts");
expect(rendered).toContain("const value = 2;");
expect(rendered).not.toContain(" …");
});
it("normalizes raw streamed text input for any renderer", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const customTextTool = {
name: "custom_text",
label: "Custom Text",
renderCall(args: unknown) {
const input =
typeof (args as { input?: unknown }).input === "string" ? (args as { input: string }).input : "";
return new Text(input, 0, 0);
},
} as unknown as AgentTool;
const component = new ToolExecutionComponent(
"custom_text",
{ __partialJson: "plain streamed text" },
{},
customTextTool,
uiStub,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("plain streamed text");
});
});