2d49cfbfa5
- Updated the edit preview coalescing key to include streaming state plus a hash so final non-streaming diffs are not skipped when payload bytes match. - Used a hashed partial-stream key as fallback when tool args are not JSON-serializable, keeping deterministic dedup cache keys. - Added a regression test that verifies single-line hashline streaming edits render the completed preview diff instead of "No changes would be made".
184 lines
7.1 KiB
TypeScript
184 lines
7.1 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 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!;
|
|
}
|
|
|
|
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\nEOF:\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", "EOF:", "|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("EOF:");
|
|
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\nBOF:\n|// preview",
|
|
},
|
|
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
|
uiTheme,
|
|
);
|
|
|
|
const quotedComponent = editToolRenderer.renderCall(
|
|
{
|
|
input: "¶'baz qux.ts'\nBOF:\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 canonical `¶` and longer `¶` runs from hashline input headers", async () => {
|
|
const uiTheme = await getUiTheme();
|
|
|
|
// Canonical `¶PATH` form — the parser strips the marker and the
|
|
// renderer keeps the title clean.
|
|
const canonical = editToolRenderer.renderCall(
|
|
{
|
|
input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview",
|
|
},
|
|
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
|
uiTheme,
|
|
);
|
|
|
|
// Even longer runs should still produce the clean path.
|
|
const triple = editToolRenderer.renderCall(
|
|
{ input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" },
|
|
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
|
uiTheme,
|
|
);
|
|
|
|
const canonicalRendered = Bun.stripANSI(canonical.render(160).join("\n"));
|
|
const tripleRendered = Bun.stripANSI(triple.render(160).join("\n"));
|
|
|
|
expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts");
|
|
expect(canonicalRendered).not.toMatch(/¶packages\/coding-agent/);
|
|
expect(tripleRendered).toContain("a/b/c.ts");
|
|
expect(tripleRendered).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\nEOF:\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 });
|
|
}
|
|
});
|
|
});
|