Files
oh-my-pi/packages/coding-agent/test/tools/ast-edit.test.ts
T
can1357 ea9c46262f feat(coding-agent): introduced /force command and ToolChoiceQueue for directive management
- Added `/force` slash command and `ToolChoiceQueue` system for managing tool-choice directives with lifecycle callbacks and requeue semantics.
- Added `setForcedToolChoice()`, `peekQueueInvoker()`, `buildToolChoice()`, `steer()`, and `getToolChoiceQueue()` methods to AgentSession and ToolSession APIs.
- Refactored tool-choice override mechanism from simple override to queue-based system with generator directives, lifecycle callbacks, and requeue preservation.
- Removed `PendingActionStore` class and replaced with `ToolChoiceQueue`; updated `ResolveTool` and custom tool loader to use queue invokers.
- Fixed tool-choice queue cleanup on agent loop abort and requeue semantics to preserve callbacks across abort cycles.
2026-04-08 22:10:37 +02:00

224 lines
8.7 KiB
TypeScript

import { 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 { adaptSchemaForStrict } from "@oh-my-pi/pi-ai/utils/schema";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
function createTestSession(cwd = "/tmp/test", overrides: Partial<ToolSession> = {}): ToolSession {
return {
cwd,
hasUI: true,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
...overrides,
};
}
function asSchemaObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object schema");
}
return value as Record<string, unknown>;
}
describe("ast_edit tool schema", () => {
it("uses op entries as [{ pat, out }]", async () => {
const tools = await createTools(createTestSession());
const tool = tools.find(entry => entry.name === "ast_edit");
expect(tool).toBeDefined();
const schema = asSchemaObject(tool?.parameters);
const properties = asSchemaObject(schema.properties);
const ops = asSchemaObject(properties.ops);
expect(ops.type).toBe("array");
const items = asSchemaObject(ops.items);
expect(items.type).toBe("object");
expect(items.required).toEqual(["pat", "out"]);
const itemProperties = asSchemaObject(items.properties);
expect(asSchemaObject(itemProperties.pat).type).toBe("string");
expect(asSchemaObject(itemProperties.out).type).toBe("string");
expect(properties.preview).toBeUndefined();
});
it("remains strict-representable after strict adaptation", async () => {
const tools = await createTools(createTestSession());
const tool = tools.find(entry => entry.name === "ast_edit");
expect(tool).toBeDefined();
const schema = asSchemaObject(tool?.parameters);
const strict = adaptSchemaForStrict(schema, true);
expect(strict.strict).toBe(true);
});
it("renders +/- lines with aligned hashline prefixes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-edit-render-"));
try {
const filePath = path.join(tempDir, "legacy.ts");
await Bun.write(filePath, "legacyWrap(x, value)\n");
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "ast_edit");
expect(tool).toBeDefined();
const result = await tool!.execute("ast-edit-test", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: filePath,
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
const lines = text.split("\n");
const removedLine = lines.find(line => line.startsWith("-"));
const addedLine = lines.find(line => line.startsWith("+"));
expect(removedLine).toBeDefined();
expect(addedLine).toBeDefined();
expect(removedLine).toMatch(/^-\d+#\w+:/);
expect(addedLine).toMatch(/^\+\d+#\w+:/);
expect(removedLine?.split(":", 1)[0].length).toBe(addedLine?.split(":", 1)[0].length);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("registers a pending action that apply writes changes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-edit-pending-"));
try {
const filePath = path.join(tempDir, "legacy.ts");
await Bun.write(filePath, "legacyWrap(x, value)\n");
const queue = new ToolChoiceQueue();
const tools = await createTools(
createTestSession(tempDir, {
getToolChoiceQueue: () => queue,
buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }),
steer: () => {},
}),
);
const tool = tools.find(entry => entry.name === "ast_edit");
expect(tool).toBeDefined();
const previewResult = await tool!.execute("ast-edit-preview", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: filePath,
});
expect(previewResult.details).toBeDefined();
expect((previewResult.details as { applied?: boolean }).applied).toBe(false);
expect(queue.inspect().some(l => l.startsWith("pending-action:ast_edit"))).toBe(true);
queue.nextToolChoice();
const invoker = queue.peekInFlightInvoker()!;
await invoker({ action: "apply", reason: "apply previewed AST edit" });
const updated = await Bun.file(filePath).text();
expect(updated).toContain("modernWrap(x, value)");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("combines globbing from path and glob parameters", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-edit-glob-"));
try {
const packagesDir = path.join(tempDir, "packages");
const sourceDir = path.join(packagesDir, "pkg-123", "src");
const nestedDir = path.join(sourceDir, "nested");
await fs.mkdir(nestedDir, { recursive: true });
await Bun.write(path.join(sourceDir, "root.ts"), "legacyWrap(rootValue, rootArg)\n");
await Bun.write(path.join(nestedDir, "child.ts"), "legacyWrap(childValue, childArg)\n");
await Bun.write(path.join(sourceDir, "ignore.js"), "legacyWrap(ignoreValue, ignoreArg)\n");
await Bun.write(path.join(tempDir, "outside.ts"), "legacyWrap(outsideValue, outsideArg)\n");
const queue = new ToolChoiceQueue();
const tools = await createTools(
createTestSession(tempDir, {
getToolChoiceQueue: () => queue,
buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }),
steer: () => {},
}),
);
const tool = tools.find(entry => entry.name === "ast_edit");
expect(tool).toBeDefined();
const previewResult = await tool!.execute("ast-edit-glob", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
lang: "typescript",
path: `${packagesDir}/pkg-*/src`,
glob: "**/*.ts",
});
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
const details = previewResult.details as
| { totalReplacements?: number; fileReplacements?: Array<{ path: string; count: number }> }
| undefined;
expect(text).toContain("## └─ root.ts (1 replacement)");
expect(text).toContain("## └─ child.ts (1 replacement)");
expect(text).not.toContain("ignore.js");
expect(text).not.toContain("outside.ts");
expect(details?.totalReplacements).toBe(2);
expect(details?.fileReplacements).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "pkg-123/src/root.ts", count: 1 }),
expect.objectContaining({ path: "pkg-123/src/nested/child.ts", count: 1 }),
]),
);
queue.nextToolChoice();
const invoker = queue.peekInFlightInvoker()!;
await invoker({ action: "apply", reason: "apply previewed AST edit with combined globs" });
expect(await Bun.file(path.join(sourceDir, "root.ts")).text()).toContain("modernWrap(rootValue, rootArg)");
expect(await Bun.file(path.join(nestedDir, "child.ts")).text()).toContain("modernWrap(childValue, childArg)");
expect(await Bun.file(path.join(sourceDir, "ignore.js")).text()).toContain(
"legacyWrap(ignoreValue, ignoreArg)",
);
expect(await Bun.file(path.join(tempDir, "outside.ts")).text()).toContain(
"legacyWrap(outsideValue, outsideArg)",
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("infers tlaplus from .tla files for AST edits", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-edit-tlaplus-"));
try {
const filePath = path.join(tempDir, "Spec.tla");
await Bun.write(filePath, `---- MODULE Spec ----\nVARIABLE x\n\nInit == x = 0\n\nNext == x' = x + 1\n====\n`);
const queue = new ToolChoiceQueue();
const tools = await createTools(
createTestSession(tempDir, {
getToolChoiceQueue: () => queue,
buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }),
steer: () => {},
}),
);
const tool = tools.find(entry => entry.name === "ast_edit");
expect(tool).toBeDefined();
const previewResult = await tool!.execute("ast-edit-tlaplus", {
ops: [{ pat: "Init", out: "Start" }],
path: filePath,
});
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
const details = previewResult.details as { totalReplacements?: number; parseErrors?: string[] } | undefined;
expect(text).toContain("Start");
expect(details?.totalReplacements).toBe(1);
expect(details?.parseErrors).toBeUndefined();
queue.nextToolChoice();
const invoker = queue.peekInFlightInvoker()!;
await invoker({ action: "apply", reason: "apply tlaplus AST edit" });
expect(await Bun.file(filePath).text()).toContain("Start == x = 0");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});