fix(coding-agent): built-in tools work like extension tools
Export readToolDefinition / createReadToolDefinition and the equivalent built-in ToolDefinition APIs from @mariozechner/pi-coding-agent.
This commit is contained in:
@@ -3,13 +3,16 @@ import { Type } from "@sinclair/typebox";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import type { ToolDefinition } from "../src/core/extensions/types.js";
|
||||
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
|
||||
import { 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";
|
||||
|
||||
function createBaseToolDefinition(): ToolDefinition {
|
||||
function createBaseToolDefinition(name = "custom_tool"): ToolDefinition {
|
||||
return {
|
||||
name: "custom_tool",
|
||||
label: "custom_tool",
|
||||
name,
|
||||
label: name,
|
||||
description: "custom tool",
|
||||
parameters: Type.Any(),
|
||||
execute: async () => ({
|
||||
@@ -25,48 +28,235 @@ function createFakeTui(): TUI {
|
||||
} as unknown as TUI;
|
||||
}
|
||||
|
||||
describe("ToolExecutionComponent custom renderer suppression", () => {
|
||||
describe("ToolExecutionComponent parity", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
test("renders no lines when custom renderers return undefined", () => {
|
||||
test("stacks custom call and result renderers like the old implementation", () => {
|
||||
const toolDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition(),
|
||||
renderCall: () => undefined,
|
||||
renderResult: () => undefined,
|
||||
renderCall: () => new Text("custom call", 0, 0),
|
||||
renderResult: () => new Text("custom result", 0, 0),
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
|
||||
expect(component.render(120)).toEqual([]);
|
||||
const component = new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, createFakeTui());
|
||||
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
|
||||
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: "hidden" }],
|
||||
content: [{ type: "text", text: "done" }],
|
||||
details: {},
|
||||
isError: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(component.render(120)).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps built-in tool rendering visible", () => {
|
||||
const component = new ToolExecutionComponent("read", { path: "README.md" }, {}, undefined, createFakeTui());
|
||||
const rendered = stripAnsi(component.render(120).join("\n"));
|
||||
expect(rendered).toContain("read");
|
||||
});
|
||||
|
||||
test("keeps custom tool rendering visible when renderer returns a component", () => {
|
||||
const toolDefinition: ToolDefinition = {
|
||||
...createBaseToolDefinition(),
|
||||
renderCall: () => new Text("custom call", 0, 0),
|
||||
renderResult: () => undefined,
|
||||
};
|
||||
|
||||
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
|
||||
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(),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
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: "README.md" },
|
||||
{},
|
||||
overrideDefinition,
|
||||
createFakeTui(),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
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("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());
|
||||
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(),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
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: "README.md" },
|
||||
{},
|
||||
createReadToolDefinition(process.cwd()),
|
||||
createFakeTui(),
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user