Files
oh-my-pi/packages/coding-agent/test/tools/recipe.test.ts
T
can1357 c124c74b5d fix(pi-natives/shell): ensured OK output for successful empty minimization
- Added `with_text` to keep minimized output byte counters consistent when replacing output text.
- Wrapped successful pipeline and overlay minimizer results with a post-step that emits `OK\n` when the transform empties output on success.
- Added tests to confirm successful empty-output minimization becomes `OK\n` and failures keep empty output.
2026-04-30 00:01:46 +02:00

220 lines
7.5 KiB
TypeScript

import { afterEach, 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 { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
buildPromptModel,
commandFromOp,
createTools,
type DetectedRunner,
RecipeTool,
resolveCommand,
type ToolSession,
tasksFromCargoMetadata,
titleFromOp,
} from "@oh-my-pi/pi-coding-agent/tools";
const detectedRunners: DetectedRunner[] = [
{
id: "just",
label: "Just",
commandPrefix: "just",
tasks: [
{ name: "build", parameters: [] },
{ name: "test", doc: "Run just tests", parameters: ["filter"] },
],
},
{
id: "pkg",
label: "Pkg",
commandPrefix: "bun run",
tasks: [
{ name: "test", parameters: [] },
{ name: "test:unit", parameters: [] },
],
},
{
id: "cargo",
label: "Cargo",
commandPrefix: "cargo",
tasks: [
{
name: "server/bin/serve",
parameters: [],
commandPrefix: "cargo run --package 'server' --bin",
commandName: "'serve'",
},
],
},
];
const tempDirs: string[] = [];
function createTestSession(cwd: string, settings = Settings.isolated()): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings,
};
}
describe("recipe", () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
it("resolves bare unique tasks and preserves forwarded args", () => {
expect(resolveCommand("build --release --flag", detectedRunners)).toEqual({
command: "just build --release --flag",
});
});
it("requires runner id when a bare task is ambiguous", () => {
expect(() => resolveCommand("test", detectedRunners)).toThrow(/multiple runners \(just, pkg\)/);
expect(() => resolveCommand("test", detectedRunners)).toThrow(/just:test/);
});
it("allows colon-containing task names when the prefix is not a runner id", () => {
expect(resolveCommand("test:unit --watch", detectedRunners)).toEqual({ command: "bun run test:unit --watch" });
});
it("routes explicit runner-prefixed tasks", () => {
expect(resolveCommand("pkg:test --watch", detectedRunners)).toEqual({ command: "bun run test --watch" });
expect(titleFromOp("pkg:test", detectedRunners)).toBe("Pkg");
});
it("routes namespaced tasks through task-specific command prefixes", () => {
expect(resolveCommand("pkg:test --watch", detectedRunners)).toEqual({ command: "bun run test --watch" });
expect(resolveCommand("cargo:server/bin/serve -- --port 0", detectedRunners)).toEqual({
command: "cargo run --package 'server' --bin 'serve' -- --port 0",
});
});
it("propagates per-task cwd through resolveCommand", () => {
const runners: DetectedRunner[] = [
{
id: "pkg",
label: "Pkg",
commandPrefix: "bun run",
tasks: [{ name: "pkg-a/test", parameters: [], cwd: "packages/pkg-a", commandName: "'test'" }],
},
];
expect(resolveCommand("pkg-a/test --watch", runners)).toEqual({
command: "bun run 'test' --watch",
cwd: "packages/pkg-a",
});
});
it("returns renderer fallbacks for unresolved or streaming ops", () => {
expect(commandFromOp("", detectedRunners)).toBeUndefined();
expect(commandFromOp("missing", detectedRunners)).toBeUndefined();
expect(titleFromOp("test", detectedRunners)).toBe("Run");
expect(titleFromOp("", detectedRunners)).toBe("Run");
});
it("builds prompt model with parameter signatures and ambiguity guidance", () => {
const model = buildPromptModel(detectedRunners);
expect(model.hasMultipleRunners).toBe(true);
expect(model.ambiguityExampleRunner).toBe("just");
expect(model.ambiguityExampleTask).toBe("test");
expect(model.runners[0]?.tasks[1]?.paramSig).toBe("filter");
});
it("maps Cargo workspace bins examples and tests to namespaced tasks", () => {
const tasks = tasksFromCargoMetadata({
workspace_members: ["crate-a-id", "crate-b-id"],
packages: [
{
id: "crate-a-id",
name: "crate-a",
targets: [
{ name: "server", kind: ["bin"] },
{ name: "demo", kind: ["example"] },
{ name: "integration", kind: ["test"] },
{ name: "crate_a", kind: ["lib"] },
],
},
{
id: "crate-b-id",
name: "crate-b",
targets: [{ name: "worker", kind: ["bin"] }],
},
],
});
expect(tasks.map(task => task.name)).toEqual([
"crate-a/bin/server",
"crate-a/example/demo",
"crate-a/test/integration",
"crate-b/bin/worker",
]);
expect(
resolveCommand("cargo:crate-a/example/demo", [{ id: "cargo", label: "Cargo", commandPrefix: "cargo", tasks }]),
).toEqual({ command: "cargo run --package 'crate-a' --example 'demo'" });
});
it("detects package scripts and forwards execution through bash", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-"));
tempDirs.push(dir);
await Bun.write(path.join(dir, "package.json"), JSON.stringify({ scripts: { "say-ok": "echo ok" } }, null, 2));
await Bun.write(path.join(dir, "bun.lock"), "");
const tool = await RecipeTool.createIf(createTestSession(dir));
expect(tool).not.toBeNull();
const result = await tool!.execute("tool-call", { op: "say-ok" });
const text = result.content.find(block => block.type === "text")?.text ?? "";
expect(text).toContain("ok");
});
it("keeps root package scripts bare and exposes workspace package scripts as package-name/script tasks", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-workspace-"));
tempDirs.push(dir);
await Bun.write(
path.join(dir, "package.json"),
JSON.stringify({ name: "root-app", workspaces: ["packages/*"], scripts: { root: "echo root" } }, null, 2),
);
await Bun.write(path.join(dir, "bun.lock"), "");
await fs.mkdir(path.join(dir, "packages", "pkg-a"), { recursive: true });
await Bun.write(
path.join(dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", scripts: { "say-ok": "echo workspace-ok" } }, null, 2),
);
const tool = await RecipeTool.createIf(createTestSession(dir));
expect(tool).not.toBeNull();
expect(tool!.description).toContain("root");
expect(tool!.description).not.toContain("root-app/root");
expect(tool!.description).toContain("pkg-a/say-ok");
const rootResult = await tool!.execute("tool-call", { op: "root" });
const rootText = rootResult.content.find(block => block.type === "text")?.text ?? "";
expect(rootText).toContain("root");
const result = await tool!.execute("tool-call", { op: "pkg-a/say-ok" });
const text = result.content.find(block => block.type === "text")?.text ?? "";
expect(text).toContain("workspace-ok");
});
it("auto-includes recipe when bash is requested and a runner is detected", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-auto-"));
tempDirs.push(dir);
await Bun.write(path.join(dir, "package.json"), JSON.stringify({ scripts: { test: "echo t" } }, null, 2));
await Bun.write(path.join(dir, "bun.lock"), "");
const tools = await createTools(createTestSession(dir), ["bash"]);
const names = tools.map(tool => tool.name);
expect(names).toContain("bash");
expect(names).toContain("recipe");
});
it("is absent when disabled even if a package manifest is present", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-disabled-"));
tempDirs.push(dir);
await Bun.write(path.join(dir, "package.json"), JSON.stringify({ scripts: { test: "echo t" } }, null, 2));
const settings = Settings.isolated({ "recipe.enabled": false });
expect(await RecipeTool.createIf(createTestSession(dir, settings))).toBeNull();
});
});