Files
oh-my-pi/packages/coding-agent/test/tools/ask.test.ts
T
can1357 1643cd4673 feat(ask): migrated ask tool to AbortSignal-based cancellation for unified abort handling
- Replaced timeout-based cancellation with AbortSignal-based cancellation in ask tool for unified abort handling.
- Added signal parameter to UIContext select() and input() methods to support external cancellation propagation.
- Fixed race condition in dialog overlay handling by replacing direct resolve() calls with settled flag wrapper.
- Added comprehensive test coverage for ask tool cancellation behavior across user-initiated and timeout scenarios.
2026-03-01 11:19:32 +01:00

148 lines
3.5 KiB
TypeScript

import { beforeAll, describe, expect, it, vi } from "bun:test";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { AskTool } from "@oh-my-pi/pi-coding-agent/tools/ask";
import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
return {
cwd: "/tmp/test",
hasUI: true,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
...overrides,
};
}
function createContext(args: {
select: (
prompt: string,
options: string[],
dialogOptions?: { initialIndex?: number; timeout?: number },
) => Promise<string | undefined>;
input?: (prompt: string) => Promise<string | undefined>;
abort?: () => void;
}): AgentToolContext {
// AgentToolContext includes many runtime fields; tests only need UI + abort behavior.
return {
hasUI: true,
ui: {
select: args.select,
input: args.input ?? (async () => undefined),
},
abort: args.abort ?? (() => {}),
} as unknown as AgentToolContext;
}
beforeAll(async () => {
await initTheme(false);
});
describe("AskTool cancellation", () => {
it("aborts the turn when the user cancels selection", async () => {
const tool = new AskTool(createSession());
const abort = vi.fn();
const context = createContext({
select: async () => undefined,
abort,
});
expect(
tool.execute(
"call-1",
{
questions: [
{
id: "confirm",
question: "Proceed?",
options: [{ label: "yes" }, { label: "no" }],
},
],
},
undefined,
undefined,
context,
),
).rejects.toBeInstanceOf(ToolAbortError);
expect(abort).toHaveBeenCalledTimes(1);
});
it("does not abort the turn when cancellation is from ask timeout", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 0.001 }),
}),
);
const abort = vi.fn();
const context = createContext({
select: async () => {
await Bun.sleep(5);
return undefined;
},
abort,
});
const result = await tool.execute(
"call-2",
{
questions: [
{
id: "confirm",
question: "Proceed?",
options: [{ label: "yes" }, { label: "no" }],
},
],
},
undefined,
undefined,
context,
);
expect(result.content[0]?.type).toBe("text");
if (result.content[0]?.type !== "text") {
throw new Error("Expected text result");
}
expect(result.content[0].text).toContain("User cancelled the selection");
expect(abort).not.toHaveBeenCalled();
});
it("aborts multi-question ask when any question is explicitly cancelled", async () => {
const tool = new AskTool(createSession());
const abort = vi.fn();
const context = createContext({
select: async prompt => {
if (prompt.includes("First")) return "one";
return undefined;
},
abort,
});
expect(
tool.execute(
"call-3",
{
questions: [
{
id: "first",
question: "First",
options: [{ label: "one" }, { label: "two" }],
},
{
id: "second",
question: "Second",
options: [{ label: "alpha" }, { label: "beta" }],
},
],
},
undefined,
undefined,
context,
),
).rejects.toBeInstanceOf(ToolAbortError);
expect(abort).toHaveBeenCalledTimes(1);
});
});