pi-mono/packages/coding-agent/test/tool-execution-component.test.ts
2026-05-13 10:44:56 +02:00

416 lines
13 KiB
TypeScript

import { join, resolve } from "node:path";
import { Text, type TUI } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { beforeAll, describe, expect, test } from "vitest";
import { getReadmePath } from "../src/config.js";
import type { ToolDefinition } from "../src/core/extensions/types.js";
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.js";
import { createWriteToolDefinition } from "../src/core/tools/write.js";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";
import { initTheme } from "../src/modes/interactive/theme/theme.js";
import { stripAnsi } from "../src/utils/ansi.js";
function createBaseToolDefinition(name = "custom_tool"): ToolDefinition {
return {
name,
label: name,
description: "custom tool",
parameters: Type.Any(),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
};
}
function createFakeTui(): TUI {
return {
requestRender: () => {},
} as unknown as TUI;
}
describe("ToolExecutionComponent parity", () => {
beforeAll(() => {
initTheme("dark");
});
test("stacks custom call and result renderers like the old implementation", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => new Text("custom call", 0, 0),
renderResult: () => new Text("custom result", 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-1",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
component.updateResult(
{
content: [{ type: "text", text: "done" }],
details: {},
isError: false,
},
false,
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call");
expect(rendered).toContain("custom result");
});
test("uses built-in rendering for built-in overrides without custom renderers", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("edit"),
};
const component = new ToolExecutionComponent(
"edit",
"tool-2",
{ path: "README.md", oldText: "before", newText: "after" },
{},
overrideDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("edit");
expect(rendered).toContain("README.md");
expect(rendered).not.toContain(":1");
});
test("preserves legacy file_path rendering compatibility for built-in tools", () => {
const component = new ToolExecutionComponent(
"read",
"tool-3",
{ file_path: "README.md" },
{},
undefined,
createFakeTui(),
process.cwd(),
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
expect(rendered).toContain("README.md");
});
test("bash execute emits an initial empty partial update before output arrives", async () => {
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
const operations: BashOperations = {
exec: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return { exitCode: 0 };
},
};
const tool = createBashToolDefinition(process.cwd(), { operations });
const promise = tool.execute(
"tool-bash-1",
{ command: "sleep 10" },
undefined,
(update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }),
{} as never,
);
expect(updates).toEqual([{ content: [], details: undefined }]);
await promise;
});
test("does not duplicate built-in headers when passed the active built-in definition", () => {
const component = new ToolExecutionComponent(
"read",
"tool-4",
{ path: "README.md" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1);
});
test("inherits missing built-in result renderer slot from the built-in tool", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("read"),
renderCall: () => new Text("override call", 0, 0),
};
const component = new ToolExecutionComponent(
"read",
"tool-4b",
{ path: "notes.txt" },
{},
overrideDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("override call");
expect(rendered).toContain("hello");
});
test("inherits missing built-in call renderer slot from the built-in tool", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("read"),
renderResult: () => new Text("override result", 0, 0),
};
const component = new ToolExecutionComponent(
"read",
"tool-4c",
{ path: "README.md" },
{},
overrideDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
expect(rendered).toContain("README.md");
expect(rendered).toContain("override result");
});
test("uses custom renderers for built-in overrides that reuse built-in definition parameters", () => {
const builtInDefinition = createReadToolDefinition(process.cwd());
const component = new ToolExecutionComponent(
"read",
"tool-4d",
{ path: "README.md" },
{},
{
...builtInDefinition,
renderCall: () => new Text("override call", 0, 0),
renderResult: () => new Text("override result", 0, 0),
},
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("override call");
expect(rendered).toContain("override result");
expect(rendered).not.toContain("read README.md");
});
test("uses custom renderers for built-in overrides that reuse wrapped built-in tool parameters", () => {
const builtInTool = createReadTool(process.cwd());
const component = new ToolExecutionComponent(
"read",
"tool-4e",
{ path: "README.md" },
{},
{
...createBaseToolDefinition("read"),
parameters: builtInTool.parameters,
renderCall: () => new Text("wrapped override call", 0, 0),
renderResult: () => new Text("wrapped override result", 0, 0),
},
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("wrapped override call");
expect(rendered).toContain("wrapped override result");
});
test("shares renderer state across custom call and result slots", () => {
type RenderState = { token?: string };
const toolDefinition: ToolDefinition<any, unknown, RenderState> = {
...createBaseToolDefinition(),
renderCall: (_args, _theme, context) => {
context.state.token ??= "shared-token";
return new Text(`custom call ${context.state.token}`, 0, 0);
},
renderResult: (_result, _options, _theme, context) => {
return new Text(`custom result ${context.state.token}`, 0, 0);
},
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-5",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call shared-token");
expect(rendered).toContain("custom result shared-token");
});
test("exposes args in render result context", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => new Text("call", 0, 0),
renderResult: (_result, _options, _theme, context) =>
new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-5b",
{ foo: "bar" },
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("arg:bar");
});
test("falls back when custom renderers are absent", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-6",
{ foo: "bar" },
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom_tool");
expect(rendered).toContain("done");
});
test("trims trailing blank display lines from write previews", () => {
const component = new ToolExecutionComponent(
"write",
"tool-7",
{ path: "README.md", content: "one\ntwo\n" },
{},
createWriteToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one");
expect(rendered).toContain("two");
expect(rendered).not.toContain("two\n\n");
});
test("trims trailing blank display lines from read results", () => {
const component = new ToolExecutionComponent(
"read",
"tool-8",
{ path: "notes.txt" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult(
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
false,
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one");
expect(rendered).toContain("two");
expect(rendered).not.toContain("two\n\n");
});
for (const scenario of [
{
title: "SKILL.md",
path: join(process.cwd(), "attio", "SKILL.md"),
content: "---\nname: attio\ndescription: CRM helper\n---\n\n# Hidden skill instructions",
compact: "[skill] attio",
hidden: "Hidden skill instructions",
absent: "read skill attio",
},
{
title: "AGENTS.md",
path: join(process.cwd(), ".pi", "AGENTS.md"),
content: "Hidden resource instructions",
compact: "read resource .pi/AGENTS.md",
hidden: "Hidden resource instructions",
absent: undefined,
},
{
title: "outside AGENTS.md",
path: resolve(process.cwd(), "..", "AGENTS.md"),
content: "Hidden outside resource instructions",
compact: `read resource ${resolve(process.cwd(), "..", "AGENTS.md").replace(/\\/g, "/")}`,
hidden: "Hidden outside resource instructions",
absent: undefined,
},
{
title: "Pi documentation",
path: getReadmePath(),
content: "Hidden docs content",
compact: "read docs README.md",
hidden: "Hidden docs content",
absent: undefined,
},
] as const) {
test(`renders ${scenario.title} read results compactly until expanded`, () => {
const component = new ToolExecutionComponent(
"read",
`tool-compact-${scenario.title}`,
{ path: scenario.path },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult(
{ content: [{ type: "text", text: scenario.content }], details: undefined, isError: false },
false,
);
const collapsed = stripAnsi(component.render(120).join("\n"));
expect(collapsed).toContain(scenario.compact);
expect(collapsed).not.toContain(scenario.hidden);
if (scenario.absent) {
expect(collapsed).not.toContain(scenario.absent);
}
component.setExpanded(true);
const expanded = stripAnsi(component.render(120).join("\n"));
expect(expanded).toContain(scenario.hidden);
});
}
for (const scenario of [
{ title: "SKILL.md", path: join(process.cwd(), "attio", "SKILL.md"), compact: "[skill] attio:120-329" },
{ title: "Pi documentation", path: getReadmePath(), compact: "read docs README.md:120-329" },
] as const) {
test(`shows the read line range in compact ${scenario.title} reads before the expand hint`, () => {
const component = new ToolExecutionComponent(
"read",
`tool-compact-range-${scenario.title}`,
{ path: scenario.path, offset: 120, limit: 210 },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
const collapsed = stripAnsi(component.render(120).join("\n"));
expect(collapsed).toContain(scenario.compact);
expect(collapsed.indexOf(":120-329")).toBeLessThan(collapsed.indexOf("to expand"));
});
}
});