Files
oh-my-pi/packages/coding-agent/test/tools/tool-output-hyperlinks.test.ts
T
can1357 9bd9e3127e feat(coding-agent): added /tan background forking with prompt cache inheritance
- Added `/tan` slash command registration and interactive handling.
- Added TanCommandController validation and async task scheduling for `/tan` dispatch.
- Added session cloning that suppresses breadcrumbs, copies artifacts, and handles abort cleanup.
- Added `promptCacheKey` support in Agent and inherited `providerPromptCacheKey` in session creation.
2026-06-07 06:52:15 +02:00

183 lines
6.6 KiB
TypeScript

import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { astGrepToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ast-grep";
import { ReadTool, readToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/read";
import { searchToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/search";
import { WriteTool, writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write";
import { getThemeByName, initTheme } from "../../src/modes/theme/theme";
// 1x1 PNG so the read tool takes its image branch.
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
function extractLinkUris(text: string): string[] {
return [...text.matchAll(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/g)].map(match => match[1]!);
}
function createTestToolSession(cwd: string): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
};
}
beforeAll(async () => {
await initTheme(false);
resetSettingsForTest();
await Settings.init({ inMemory: true });
});
afterEach(() => {
settings.clearOverride("tui.hyperlinks");
});
afterAll(() => {
resetSettingsForTest();
});
describe("tool output OSC 8 file:// hyperlinks", () => {
it("links plain text and image read titles to the resolved filesystem path", async () => {
settings.override("tui.hyperlinks", "always");
const theme = (await getThemeByName("dark"))!;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-read-"));
try {
const textPath = path.join(dir, "task.txt");
fs.writeFileSync(textPath, "hello\nworld\n");
const imgPath = path.join(dir, "task.png");
fs.writeFileSync(imgPath, Buffer.from(TINY_PNG_BASE64, "base64"));
const tool = new ReadTool(createTestToolSession(dir));
const textRes = await tool.execute("t", { path: textPath });
const imgRes = await tool.execute("i", { path: imgPath });
const textRender = readToolRenderer
.renderResult(
{ content: textRes.content, details: textRes.details, isError: textRes.isError },
{ expanded: false, isPartial: false },
theme,
{ path: textPath },
)
.render(200)
.join("\n");
const imgRender = readToolRenderer
.renderResult(
{ content: imgRes.content, details: imgRes.details, isError: imgRes.isError },
{ expanded: false, isPartial: false },
theme,
{ path: imgPath },
)
.render(200)
.join("\n");
expect(extractLinkUris(textRender)).toContain(`file://${textPath}`);
expect(extractLinkUris(imgRender)).toContain(`file://${imgPath}`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("links the write header to the absolute path it wrote", async () => {
settings.override("tui.hyperlinks", "always");
const theme = (await getThemeByName("dark"))!;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-write-"));
try {
const filePath = path.join(dir, "out.ts");
const tool = new WriteTool(createTestToolSession(dir));
const res = await tool.execute("w", { path: filePath, content: "export const x = 1;\n" });
const rendered = writeToolRenderer
.renderResult(
{ content: res.content, details: res.details, isError: res.isError },
{ expanded: false, isPartial: false },
theme,
{ path: filePath },
)
.render(200)
.join("\n");
expect(extractLinkUris(rendered)).toContain(`file://${filePath}`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("resolves scoped search links against cwd, not the (sub)scope path", async () => {
settings.override("tui.hyperlinks", "always");
const theme = (await getThemeByName("dark"))!;
// Scoped search: scope dir (`searchPath`) is below cwd, and the grouped
// display paths are cwd-relative. Resolving against searchPath would double
// the `src` prefix (`/proj/src/src/...`).
const result = {
content: [{ type: "text", text: "" }],
details: {
matchCount: 1,
fileCount: 1,
cwd: "/tmp/omp-project",
searchPath: "/tmp/omp-project/src",
scopePath: "src",
displayContent: ["# src/", "## interactive-mode.ts#abcd", "*12│const needle = true;"].join("\n"),
},
};
const rendered = searchToolRenderer
.renderResult(result as never, { expanded: true, isPartial: false }, theme, { pattern: "needle" })
.render(240)
.join("\n");
const uris = extractLinkUris(rendered);
expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts");
expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts?line=12");
expect(uris.some(uri => uri.includes("/src/src/"))).toBe(false);
});
it("resolves scoped ast-grep links against cwd, not the (sub)scope path", async () => {
settings.override("tui.hyperlinks", "always");
const theme = (await getThemeByName("dark"))!;
const result = {
content: [{ type: "text", text: "" }],
details: {
matchCount: 1,
fileCount: 1,
filesSearched: 1,
limitReached: false,
cwd: "/tmp/omp-project",
searchPath: "/tmp/omp-project/src",
scopePath: "src",
displayContent: ["# src/", "## interactive-mode.ts", " *12│const needle = true;"].join("\n"),
},
};
const rendered = astGrepToolRenderer
.renderResult(result as never, { expanded: true, isPartial: false }, theme, { pat: "needle" })
.render(240)
.join("\n");
const uris = extractLinkUris(rendered);
expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts");
expect(uris.some(uri => uri.includes("/src/src/"))).toBe(false);
});
it("links the edit header to the absolute details.path even when the arg path is relative", async () => {
settings.override("tui.hyperlinks", "always");
const theme = (await getThemeByName("dark"))!;
const rendered = editToolRenderer
.renderResult(
{
content: [{ type: "text", text: "Updated src/a.ts" }],
details: { diff: "+1|// x", op: "update", path: "/tmp/omp-project/src/a.ts" },
},
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
theme,
{ path: "src/a.ts" },
)
.render(200)
.join("\n");
const uris = extractLinkUris(rendered);
expect(uris).toContain("file:///tmp/omp-project/src/a.ts");
// A relative arg path must not leak into a root-anchored `file:///src/a.ts`.
expect(uris).not.toContain("file:///src/a.ts");
});
});