Files
oh-my-pi/packages/coding-agent/test/tools/ask.test.ts
T
2026-03-03 15:09:46 +01:00

497 lines
13 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;
signal?: AbortSignal;
outline?: boolean;
onTimeout?: () => void;
onLeft?: () => void;
onRight?: () => void;
},
) => Promise<string | undefined>;
input?: (
prompt: string,
dialogOptions?: {
timeout?: number;
signal?: AbortSignal;
onTimeout?: () => void;
},
) => 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: (
prompt: string,
_placeholder: string | undefined,
dialogOptions?: {
timeout?: number;
signal?: AbortSignal;
onTimeout?: () => void;
},
) => args.input?.(prompt, dialogOptions) ?? Promise.resolve(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("still aborts when user explicitly cancels with timeout configured", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 30 }),
}),
);
const abort = vi.fn();
const context = createContext({
select: async () => undefined,
abort,
});
expect(
tool.execute(
"call-timeout-cancel",
{
questions: [
{
id: "confirm",
question: "Proceed?",
options: [{ label: "yes" }, { label: "no" }],
},
],
},
undefined,
undefined,
context,
),
).rejects.toBeInstanceOf(ToolAbortError);
expect(abort).toHaveBeenCalledTimes(1);
});
it("auto-selects the recommended option on ask timeout", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 0.001 }),
}),
);
const abort = vi.fn();
const select = vi.fn(
async (
_prompt: string,
options: string[],
dialogOptions?: { initialIndex?: number; timeout?: number; onTimeout?: () => void },
) => {
const timeout = dialogOptions?.timeout ?? 1;
await Bun.sleep(timeout + 5);
dialogOptions?.onTimeout?.();
return options[dialogOptions?.initialIndex ?? 0];
},
);
const context = createContext({
select,
abort,
});
const result = await tool.execute(
"call-2",
{
questions: [
{
id: "confirm",
question: "Proceed?",
options: [{ label: "yes" }, { label: "no" }],
recommended: 1,
},
],
},
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 selected: no");
expect(result.details?.selectedOptions).toEqual(["no"]);
expect(abort).not.toHaveBeenCalled();
expect(select).toHaveBeenCalledTimes(1);
expect(select.mock.calls[0]?.[2]?.initialIndex).toBe(1);
expect(select.mock.calls[0]?.[2]?.timeout).toBeGreaterThan(0);
});
it("auto-selects the first option when timeout elapses without a selected option", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 0.001 }),
}),
);
const abort = vi.fn();
const context = createContext({
select: async (_prompt, _options, dialogOptions) => {
const timeout = dialogOptions?.timeout ?? 1;
await Bun.sleep(timeout + 5);
dialogOptions?.onTimeout?.();
return undefined;
},
abort,
});
const result = await tool.execute(
"call-timeout-none",
{
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 selected: yes");
expect(result.details?.selectedOptions).toEqual(["yes"]);
expect(abort).not.toHaveBeenCalled();
});
it("does not abort when custom input times out after selecting Other", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 0.001 }),
}),
);
const abort = vi.fn();
const input = vi.fn(async (_prompt: string, dialogOptions?: { timeout?: number; onTimeout?: () => void }) => {
const timeout = dialogOptions?.timeout ?? 1;
await Bun.sleep(timeout + 5);
dialogOptions?.onTimeout?.();
return undefined;
});
const context = createContext({
select: async () => "Other (type your own)",
input,
abort,
});
const result = await tool.execute(
"call-timeout-input",
{
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 selected: yes");
expect(result.details?.selectedOptions).toEqual(["yes"]);
expect(input).toHaveBeenCalledTimes(1);
expect(abort).not.toHaveBeenCalled();
});
it("does not prompt for custom input when timeout resolves to Other in multi-select", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 0.001 }),
}),
);
const abort = vi.fn();
const input = vi.fn(async () => "should-not-be-used");
const context = createContext({
select: async (_prompt, _options, dialogOptions) => {
const timeout = dialogOptions?.timeout ?? 1;
await Bun.sleep(timeout + 5);
dialogOptions?.onTimeout?.();
return "Other (type your own)";
},
input,
abort,
});
const result = await tool.execute(
"call-timeout-other-multi",
{
questions: [
{
id: "confirm",
question: "Proceed?",
options: [{ label: "yes" }, { label: "no" }],
multi: true,
},
],
},
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 selected: yes");
expect(result.details?.selectedOptions).toEqual(["yes"]);
expect(result.details?.customInput).toBeUndefined();
expect(input).not.toHaveBeenCalled();
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);
});
});
describe("AskTool multi-question navigation", () => {
const questions = [
{
id: "first",
question: "First?",
options: [{ label: "one" }, { label: "two" }],
},
{
id: "second",
question: "Second?",
options: [{ label: "alpha" }, { label: "beta" }],
},
{
id: "third",
question: "Third?",
options: [{ label: "red" }, { label: "blue" }],
},
];
it("keeps back unavailable on the first question and supports returning from later questions", async () => {
const tool = new AskTool(createSession());
const firstQuestionOptions: string[][] = [];
let firstVisits = 0;
let secondVisits = 0;
const context = createContext({
select: async (prompt, options, dialogOptions) => {
if (prompt.includes("First?")) {
firstQuestionOptions.push(options);
firstVisits += 1;
if (firstVisits === 1) return "one";
dialogOptions?.onRight?.();
return undefined;
}
if (prompt.includes("Second?")) {
secondVisits += 1;
if (secondVisits === 1) {
dialogOptions?.onLeft?.();
return undefined;
}
return "alpha";
}
dialogOptions?.onRight?.();
return undefined;
},
});
const result = await tool.execute("call-nav-1", { questions }, undefined, undefined, context);
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["one"]);
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["alpha"]);
expect(firstQuestionOptions[0]).not.toContain("← Back");
expect(firstQuestionOptions[1]).not.toContain("← Back");
});
it("allows forward action on the last question", async () => {
const tool = new AskTool(createSession());
const context = createContext({
select: async (prompt, _options, dialogOptions) => {
if (prompt.includes("First?")) return "one";
if (prompt.includes("Second?")) return "alpha";
dialogOptions?.onRight?.();
return undefined;
},
});
const result = await tool.execute("call-nav-2", { questions }, undefined, undefined, context);
expect(result.details?.results?.[2]?.selectedOptions).toEqual([]);
expect(result.details?.results?.[2]?.customInput).toBeUndefined();
});
it("persists state when changing an earlier answer and continuing", async () => {
const tool = new AskTool(createSession());
let firstVisits = 0;
let secondVisits = 0;
let thirdVisits = 0;
const context = createContext({
select: async (prompt, _options, dialogOptions) => {
if (prompt.includes("First?")) {
firstVisits += 1;
if (firstVisits === 1) return "one";
return "two";
}
if (prompt.includes("Second?")) {
secondVisits += 1;
if (secondVisits === 1) return "alpha";
if (secondVisits === 2) {
dialogOptions?.onLeft?.();
return undefined;
}
dialogOptions?.onRight?.();
return undefined;
}
if (prompt.includes("Third?")) {
thirdVisits += 1;
if (thirdVisits === 1) {
dialogOptions?.onLeft?.();
return undefined;
}
dialogOptions?.onRight?.();
return undefined;
}
return undefined;
},
});
const result = await tool.execute("call-nav-3", { questions }, undefined, undefined, context);
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["two"]);
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["alpha"]);
});
it("handles timeout with navigation and allows revisiting timed-out questions", async () => {
const tool = new AskTool(
createSession({
settings: Settings.isolated({ "ask.timeout": 0.001 }),
}),
);
let secondVisits = 0;
let thirdVisits = 0;
const context = createContext({
select: async (prompt, _options, dialogOptions) => {
if (prompt.includes("First?")) return "one";
if (prompt.includes("Second?")) {
secondVisits += 1;
if (secondVisits === 1) {
await Bun.sleep(5);
dialogOptions?.onTimeout?.();
return undefined;
}
return "beta";
}
if (prompt.includes("Third?")) {
thirdVisits += 1;
if (thirdVisits === 1) {
dialogOptions?.onLeft?.();
return undefined;
}
dialogOptions?.onRight?.();
return undefined;
}
return undefined;
},
});
const result = await tool.execute("call-nav-4", { questions }, undefined, undefined, context);
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["one"]);
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["beta"]);
expect(result.details?.results?.[2]?.selectedOptions).toEqual([]);
});
});