4a3984eb0a
- Marked Markdown cached lines as readonly snapshots and returned copied arrays from `render()`. - Stored fresh snapshot arrays in both L1 and L2 caches so callers appending rows cannot mutate shared cached Markdown output. - Added an AskTool test that renders calls and results twice to verify repeated renders remain stable and do not duplicate option labels.
1239 lines
36 KiB
TypeScript
1239 lines
36 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from "bun:test";
|
|
import { stripVTControlCharacters } from "node:util";
|
|
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import type { ExtensionUISelectItem } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
|
|
import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { AskTool, askToolRenderer } 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: ExtensionUISelectItem[],
|
|
dialogOptions?: {
|
|
initialIndex?: number;
|
|
timeout?: number;
|
|
signal?: AbortSignal;
|
|
outline?: boolean;
|
|
onTimeout?: () => void;
|
|
onLeft?: () => void;
|
|
onRight?: () => void;
|
|
selectionMarker?: "radio" | "checkbox";
|
|
checkedIndices?: readonly number[];
|
|
markableCount?: number;
|
|
},
|
|
) => Promise<string | undefined>;
|
|
editor?: (
|
|
title: string,
|
|
prefill?: string,
|
|
dialogOptions?: { signal?: AbortSignal },
|
|
editorOptions?: { promptStyle?: boolean },
|
|
) => Promise<string | undefined>;
|
|
abort?: () => void;
|
|
}): AgentToolContext {
|
|
// AgentToolContext includes many runtime fields; tests only need UI + abort behavior.
|
|
return {
|
|
hasUI: true,
|
|
ui: {
|
|
select: args.select,
|
|
editor: (
|
|
title: string,
|
|
prefill?: string,
|
|
dialogOptions?: { signal?: AbortSignal },
|
|
editorOptions?: { promptStyle?: boolean },
|
|
) => args.editor?.(title, prefill, dialogOptions, editorOptions) ?? Promise.resolve(undefined),
|
|
},
|
|
abort: args.abort ?? (() => {}),
|
|
} as unknown as AgentToolContext;
|
|
}
|
|
|
|
function stripAnsi(text: string): string {
|
|
return stripVTControlCharacters(text);
|
|
}
|
|
|
|
function selectItemLabel(option: ExtensionUISelectItem | undefined): string | undefined {
|
|
return typeof option === "string" ? option : option?.label;
|
|
}
|
|
|
|
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("defaults to no timeout when ask.timeout is unset", async () => {
|
|
// Regression for the surprise-auto-select report: a fresh install must let the user
|
|
// deliberate indefinitely. The dialog timeout is opt-in via the `ask.timeout` setting.
|
|
const tool = new AskTool(createSession());
|
|
const select = vi.fn(
|
|
async (
|
|
_prompt: string,
|
|
options: ExtensionUISelectItem[],
|
|
_dialogOptions?: { initialIndex?: number; timeout?: number },
|
|
) => (typeof options[0] === "string" ? options[0] : options[0]?.label),
|
|
);
|
|
const context = createContext({ select });
|
|
|
|
await tool.execute(
|
|
"call-default-no-timeout",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "confirm",
|
|
question: "Proceed?",
|
|
options: [{ label: "yes" }, { label: "no" }],
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
undefined,
|
|
context,
|
|
);
|
|
|
|
expect(select).toHaveBeenCalledTimes(1);
|
|
expect(select.mock.calls[0]?.[2]?.timeout).toBeUndefined();
|
|
});
|
|
|
|
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: ExtensionUISelectItem[],
|
|
dialogOptions?: { initialIndex?: number; timeout?: number; onTimeout?: () => void },
|
|
) => {
|
|
const timeout = dialogOptions?.timeout ?? 1;
|
|
await Bun.sleep(timeout + 5);
|
|
dialogOptions?.onTimeout?.();
|
|
const selected = options[dialogOptions?.initialIndex ?? 0];
|
|
return typeof selected === "string" ? selected : selected?.label;
|
|
},
|
|
);
|
|
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("routes custom input through editor with promptStyle after choosing Other", async () => {
|
|
const tool = new AskTool(
|
|
createSession({
|
|
settings: Settings.isolated({ "ask.timeout": 0.001 }),
|
|
}),
|
|
);
|
|
const abort = vi.fn();
|
|
const editor = vi.fn(
|
|
async (
|
|
_title: string,
|
|
_prefill?: string,
|
|
_dialogOptions?: unknown,
|
|
editorOptions?: { promptStyle?: boolean },
|
|
) => {
|
|
// Verify promptStyle is passed
|
|
expect(editorOptions?.promptStyle).toBe(true);
|
|
return "custom response";
|
|
},
|
|
);
|
|
const select = vi.fn(async () => "Other (type your own)");
|
|
const context = createContext({
|
|
select,
|
|
editor,
|
|
abort,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-custom-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("custom response");
|
|
expect(result.details?.selectedOptions).toEqual([]);
|
|
expect(result.details?.customInput).toBe("custom response");
|
|
expect((select.mock.calls[0] as unknown[])?.[2] as Record<string, unknown>).toHaveProperty("timeout");
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
expect(abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not enter 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 editor = 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)";
|
|
},
|
|
editor,
|
|
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(editor).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,
|
|
});
|
|
|
|
await 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 option descriptions", () => {
|
|
it("passes descriptions to the selector while returning selected labels", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const select = vi.fn(async (_prompt: string, options: ExtensionUISelectItem[]) => {
|
|
expect(options[0]).toEqual({
|
|
label: "Use local credentials",
|
|
description: "Authenticate with provider keys already configured under ~/.omp.",
|
|
});
|
|
expect(options[1]).toEqual({
|
|
label: "Set up in terminal",
|
|
description: "Launch the terminal setup flow to add credentials before continuing.",
|
|
});
|
|
const selected = options[1];
|
|
return typeof selected === "string" ? selected : selected?.label;
|
|
});
|
|
const context = createContext({ select });
|
|
|
|
const result = await tool.execute(
|
|
"call-option-descriptions",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "auth",
|
|
question: "How should authentication continue?",
|
|
options: [
|
|
{
|
|
label: "Use local credentials",
|
|
description: "Authenticate with provider keys already configured under ~/.omp.",
|
|
},
|
|
{
|
|
label: "Set up in terminal",
|
|
description: "Launch the terminal setup flow to add credentials before continuing.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
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: Set up in terminal");
|
|
expect(result.details?.selectedOptions).toEqual(["Set up in terminal"]);
|
|
expect(result.content[0].text).not.toContain("Launch the terminal setup flow");
|
|
expect(result.details?.options).toEqual(["Use local credentials", "Set up in terminal"]);
|
|
});
|
|
|
|
it("renders descriptions under labels in ask call previews", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderCall(
|
|
{
|
|
question: "How should authentication continue?",
|
|
options: [
|
|
{
|
|
label: "Use local credentials",
|
|
description: "Authenticate with provider keys already configured under ~/.omp.",
|
|
},
|
|
{
|
|
label: "Set up in terminal",
|
|
description: "Launch the terminal setup flow to add credentials before continuing.",
|
|
},
|
|
],
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
);
|
|
const renderedLines = stripAnsi(rendered.render(120).join("\n")).split("\n");
|
|
const labelLine = renderedLines.findIndex(line => line.includes("Use local credentials"));
|
|
const descriptionLine = renderedLines.findIndex(line =>
|
|
line.includes("Authenticate with provider keys already configured"),
|
|
);
|
|
expect(labelLine).toBeGreaterThanOrEqual(0);
|
|
expect(descriptionLine).toBeGreaterThan(labelLine);
|
|
});
|
|
|
|
it("forwards descriptions through multi-select and returns bare labels", async () => {
|
|
const tool = new AskTool(createSession());
|
|
let step = 0;
|
|
let firstOptions: ExtensionUISelectItem[] = [];
|
|
const editor = vi.fn(async () => undefined);
|
|
const context = createContext({
|
|
select: async (_prompt, options) => {
|
|
if (step === 0) {
|
|
firstOptions = options;
|
|
step += 1;
|
|
return selectItemLabel(options.find(o => selectItemLabel(o)?.endsWith("alpha")));
|
|
}
|
|
if (step === 1) {
|
|
step += 1;
|
|
return selectItemLabel(options.find(o => selectItemLabel(o)?.endsWith("beta")));
|
|
}
|
|
return "Other (type your own)";
|
|
},
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-multi-desc",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "multi",
|
|
question: "Pick answers",
|
|
options: [
|
|
{ label: "alpha", description: "First choice detail." },
|
|
{ label: "beta", description: "Second choice detail." },
|
|
],
|
|
multi: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
undefined,
|
|
context,
|
|
);
|
|
|
|
expect(result.details?.selectedOptions).toEqual(["alpha", "beta"]);
|
|
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: alpha, beta");
|
|
expect(result.content[0].text).not.toContain("First choice detail");
|
|
const alphaOption = firstOptions.find(o => selectItemLabel(o)?.endsWith("alpha"));
|
|
expect(typeof alphaOption === "object" ? alphaOption.description : undefined).toBe("First choice detail.");
|
|
});
|
|
});
|
|
|
|
describe("AskTool custom input", () => {
|
|
it("routes custom input through editor and preserves raw multiline strings", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const abort = vi.fn();
|
|
const multilineText = "first line\nsecond line";
|
|
const editor = vi.fn(async () => multilineText);
|
|
const questions = [
|
|
{
|
|
id: "details",
|
|
question: "Share details",
|
|
options: [{ label: "yes" }, { label: "no" }],
|
|
},
|
|
];
|
|
const context = createContext({
|
|
select: async () => "Other (type your own)",
|
|
editor,
|
|
abort,
|
|
});
|
|
|
|
const result = await tool.execute("call-editor-single", { questions }, 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).toBe("User provided custom input:\n first line\n second line");
|
|
expect(result.details?.customInput).toBe(multilineText);
|
|
expect(result.details?.selectedOptions).toEqual([]);
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
expect(abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("aborts when editor is cancelled in single-question flow", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const abort = vi.fn();
|
|
const editor = vi.fn(async () => undefined);
|
|
const questions = [
|
|
{
|
|
id: "details",
|
|
question: "Share details",
|
|
options: [{ label: "yes" }, { label: "no" }],
|
|
},
|
|
];
|
|
const context = createContext({
|
|
select: async () => "Other (type your own)",
|
|
editor,
|
|
abort,
|
|
});
|
|
|
|
await expect(
|
|
tool.execute("call-editor-cancel", { questions }, undefined, undefined, context),
|
|
).rejects.toBeInstanceOf(ToolAbortError);
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
expect(abort).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("continues multi-question flow when editor is dismissed on a fresh question", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const abort = vi.fn();
|
|
const editor = vi.fn(async () => undefined);
|
|
const questions = [
|
|
{
|
|
id: "first",
|
|
question: "First?",
|
|
options: [{ label: "one" }, { label: "two" }],
|
|
},
|
|
{
|
|
id: "details",
|
|
question: "Details?",
|
|
options: [{ label: "short" }, { label: "long" }],
|
|
},
|
|
];
|
|
const context = createContext({
|
|
select: async prompt => {
|
|
if (prompt.includes("First?")) return "one";
|
|
if (prompt.includes("Details?")) return "Other (type your own)";
|
|
return undefined;
|
|
},
|
|
editor,
|
|
abort,
|
|
});
|
|
|
|
const result = await tool.execute("call-editor-multi-dismiss", { questions }, undefined, undefined, context);
|
|
|
|
// Editor dismissed on "Details?" — flow continues with empty answer, not abort
|
|
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["one"]);
|
|
expect(result.details?.results?.[1]?.selectedOptions).toEqual([]);
|
|
expect(result.details?.results?.[1]?.customInput).toBeUndefined();
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
expect(abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces external abort during editor mode as ToolAbortError", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const abort = vi.fn();
|
|
const controller = new AbortController();
|
|
const editor = vi.fn(async (_title: string, _prefill?: string, dialogOptions?: { signal?: AbortSignal }) => {
|
|
expect(dialogOptions?.signal).toBe(controller.signal);
|
|
return await new Promise<string | undefined>((_resolve, reject) => {
|
|
dialogOptions?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), {
|
|
once: true,
|
|
});
|
|
queueMicrotask(() => controller.abort());
|
|
});
|
|
});
|
|
const questions = [
|
|
{
|
|
id: "details",
|
|
question: "Share details",
|
|
options: [{ label: "yes" }, { label: "no" }],
|
|
},
|
|
];
|
|
const context = createContext({
|
|
select: async () => "Other (type your own)",
|
|
editor,
|
|
abort,
|
|
});
|
|
|
|
await expect(
|
|
tool.execute("call-editor-abort", { questions }, controller.signal, undefined, context),
|
|
).rejects.toBeInstanceOf(ToolAbortError);
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
expect(abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats explicit empty-string custom input as submitted input", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const abort = vi.fn();
|
|
const editor = vi.fn(async () => "");
|
|
const context = createContext({
|
|
select: async () => "Other (type your own)",
|
|
editor,
|
|
abort,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-empty-custom",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "details",
|
|
question: "Share details",
|
|
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 provided custom input:");
|
|
expect(result.details?.customInput).toBe("");
|
|
expect(result.details?.selectedOptions).toEqual([]);
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
expect(abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders checked options together with custom text in multi-select answers", async () => {
|
|
const tool = new AskTool(createSession());
|
|
let step = 0;
|
|
const editor = vi.fn(async () => "custom detail");
|
|
const context = createContext({
|
|
select: async (_prompt, options) => {
|
|
if (step === 0) {
|
|
step += 1;
|
|
const alphaOption = options.find(option => selectItemLabel(option)?.endsWith("alpha"));
|
|
if (!alphaOption) throw new Error("Missing alpha option");
|
|
return selectItemLabel(alphaOption);
|
|
}
|
|
return "Other (type your own)";
|
|
},
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-multi-custom-render",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "multi",
|
|
question: "Pick answers",
|
|
options: [{ label: "alpha" }, { label: "beta" }],
|
|
multi: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
undefined,
|
|
context,
|
|
);
|
|
|
|
expect(result.details?.selectedOptions).toEqual(["alpha"]);
|
|
expect(result.details?.customInput).toBe("custom detail");
|
|
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("alpha");
|
|
expect(result.content[0].text).toContain("custom detail");
|
|
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!);
|
|
const renderedText = stripAnsi(rendered.render(120).join("\n"));
|
|
expect(renderedText).toContain("alpha");
|
|
expect(renderedText).toContain("custom detail");
|
|
});
|
|
|
|
it("preserves prior multi-select answers when custom editor is dismissed", async () => {
|
|
const tool = new AskTool(createSession());
|
|
let step = 0;
|
|
const editor = vi.fn(async () => undefined);
|
|
const context = createContext({
|
|
select: async (_prompt, options) => {
|
|
if (step === 0) {
|
|
step += 1;
|
|
const alphaOption = options.find(option => selectItemLabel(option)?.endsWith("alpha"));
|
|
if (!alphaOption) throw new Error("Missing alpha option");
|
|
return selectItemLabel(alphaOption);
|
|
}
|
|
return "Other (type your own)";
|
|
},
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-multi-custom-dismiss",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "multi",
|
|
question: "Pick answers",
|
|
options: [{ label: "alpha" }, { label: "beta" }],
|
|
multi: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
undefined,
|
|
context,
|
|
);
|
|
|
|
expect(result.details?.selectedOptions).toEqual(["alpha"]);
|
|
expect(result.details?.customInput).toBeUndefined();
|
|
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: alpha");
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("AskTool multiline custom input rendering", () => {
|
|
it("renders multiline custom answer as one block, not multiple checked items", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const multilineText = "first line\nsecond line\nthird line";
|
|
const editor = vi.fn(async () => multilineText);
|
|
const context = createContext({
|
|
select: async () => "Other (type your own)",
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-multiline-render",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "details",
|
|
question: "Share details",
|
|
options: [{ label: "yes" }, { label: "no" }],
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
undefined,
|
|
context,
|
|
);
|
|
|
|
expect(result.details?.customInput).toBe(multilineText);
|
|
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!);
|
|
const renderedText = stripAnsi(rendered.render(120).join("\n"));
|
|
|
|
// All three lines should appear
|
|
expect(renderedText).toContain("first line");
|
|
expect(renderedText).toContain("second line");
|
|
expect(renderedText).toContain("third line");
|
|
|
|
// Count success icons — should be exactly one for the custom input block,
|
|
// plus one for the question status icon (if present). The key contract is that
|
|
// continuation lines do NOT get their own success icon.
|
|
const successIconCount = (
|
|
renderedText.match(new RegExp(theme!.status.success.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []
|
|
).length;
|
|
// One icon on the status line header + one on the custom input first line = 2 max
|
|
expect(successIconCount).toBeLessThanOrEqual(2);
|
|
|
|
// Ensure "second line" and "third line" are NOT preceded by a success icon on their own line
|
|
const lines = renderedText.split("\n");
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.includes("second line") || trimmed.includes("third line")) {
|
|
// These continuation lines must NOT start with a success icon
|
|
expect(trimmed.startsWith(theme!.status.success)).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("does not fabricate placeholder text for empty first-line custom input", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const multilineText = "\nsecond line";
|
|
const editor = vi.fn(async () => multilineText);
|
|
const context = createContext({
|
|
select: async () => "Other (type your own)",
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-leading-empty-line-render",
|
|
{
|
|
questions: [
|
|
{
|
|
id: "details",
|
|
question: "Share details",
|
|
options: [{ label: "yes" }, { label: "no" }],
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
undefined,
|
|
context,
|
|
);
|
|
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!);
|
|
const renderedText = stripAnsi(rendered.render(120).join("\n"));
|
|
|
|
expect(renderedText).toContain("second line");
|
|
expect(renderedText).not.toContain("(empty)");
|
|
});
|
|
});
|
|
|
|
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: ExtensionUISelectItem[][] = [];
|
|
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([]);
|
|
});
|
|
it("preserves custom input when navigating back and forward", async () => {
|
|
const tool = new AskTool(createSession());
|
|
const multilineText = "line 1\nline 2";
|
|
let detailVisits = 0;
|
|
let summaryVisits = 0;
|
|
const editor = vi.fn(async () => multilineText);
|
|
const questions = [
|
|
{
|
|
id: "details",
|
|
question: "Details?",
|
|
options: [{ label: "short" }, { label: "long" }],
|
|
},
|
|
{
|
|
id: "summary",
|
|
question: "Summary?",
|
|
options: [{ label: "one" }, { label: "two" }],
|
|
},
|
|
];
|
|
const context = createContext({
|
|
select: async (prompt, _options, dialogOptions) => {
|
|
if (prompt.includes("Details?")) {
|
|
detailVisits += 1;
|
|
if (detailVisits === 1) return "Other (type your own)";
|
|
dialogOptions?.onRight?.();
|
|
return undefined;
|
|
}
|
|
if (prompt.includes("Summary?")) {
|
|
summaryVisits += 1;
|
|
if (summaryVisits === 1) {
|
|
dialogOptions?.onLeft?.();
|
|
return undefined;
|
|
}
|
|
return "two";
|
|
}
|
|
return undefined;
|
|
},
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute("call-nav-multiline", { questions }, undefined, undefined, context);
|
|
|
|
expect(result.details?.results?.[0]?.customInput).toBe(multilineText);
|
|
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["two"]);
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("preserves prior single-select answer when custom editor is dismissed during navigation", async () => {
|
|
const tool = new AskTool(createSession());
|
|
let detailVisits = 0;
|
|
const editor = vi.fn(async () => undefined);
|
|
const questions = [
|
|
{
|
|
id: "details",
|
|
question: "Details?",
|
|
options: [{ label: "short" }, { label: "long" }],
|
|
},
|
|
{
|
|
id: "summary",
|
|
question: "Summary?",
|
|
options: [{ label: "one" }, { label: "two" }],
|
|
},
|
|
];
|
|
const context = createContext({
|
|
select: async (prompt, _options, dialogOptions) => {
|
|
if (prompt.includes("Details?")) {
|
|
detailVisits += 1;
|
|
if (detailVisits === 1) return "short";
|
|
// Second visit: try Other then dismiss editor, then forward
|
|
if (detailVisits === 2) return "Other (type your own)";
|
|
dialogOptions?.onRight?.();
|
|
return undefined;
|
|
}
|
|
if (prompt.includes("Summary?")) {
|
|
const summaryVisit = detailVisits;
|
|
if (summaryVisit <= 2) {
|
|
// Navigate back to re-visit details
|
|
dialogOptions?.onLeft?.();
|
|
return undefined;
|
|
}
|
|
return "two";
|
|
}
|
|
return undefined;
|
|
},
|
|
editor,
|
|
});
|
|
|
|
const result = await tool.execute("call-nav-single-dismiss", { questions }, undefined, undefined, context);
|
|
|
|
// The prior selection "short" should survive the editor dismiss
|
|
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["short"]);
|
|
expect(result.details?.results?.[0]?.customInput).toBeUndefined();
|
|
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["two"]);
|
|
expect(editor).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("AskTool option markers", () => {
|
|
it("renders single-choice call options with circular radio markers, not checkboxes", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderCall(
|
|
{ question: "Pick one", options: [{ label: "Alpha" }, { label: "Beta" }] },
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
);
|
|
const text = stripAnsi(rendered.render(120).join("\n"));
|
|
expect(text).toContain(theme!.radio.unselected);
|
|
expect(text).not.toContain(theme!.checkbox.unchecked);
|
|
});
|
|
|
|
it("renders multi-select call options with rectangular checkbox markers, not radios", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderCall(
|
|
{ question: "Pick many", options: [{ label: "Alpha" }, { label: "Beta" }], multi: true },
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
);
|
|
const text = stripAnsi(rendered.render(120).join("\n"));
|
|
expect(text).toContain(theme!.checkbox.unchecked);
|
|
expect(text).not.toContain(theme!.radio.unselected);
|
|
});
|
|
|
|
it("keeps option rows stable across repeated renders", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const options = [
|
|
{ label: "TypeScript" },
|
|
{ label: "Rust" },
|
|
{ label: "Python" },
|
|
{ label: "Go" },
|
|
{ label: "C" },
|
|
{ label: "C++" },
|
|
{ label: "Zig" },
|
|
{ label: "Java" },
|
|
{ label: "Swift" },
|
|
{ label: "Haskell" },
|
|
];
|
|
const renderedCall = askToolRenderer.renderCall(
|
|
{ questions: [{ id: "fav_lang", question: "Which programming language?", options }] },
|
|
{ expanded: true, isPartial: true },
|
|
theme!,
|
|
);
|
|
|
|
const firstCall = stripAnsi(renderedCall.render(120).join("\n"));
|
|
const secondCall = stripAnsi(renderedCall.render(120).join("\n"));
|
|
expect(secondCall).toBe(firstCall);
|
|
expect(secondCall.match(/TypeScript/g)?.length).toBe(1);
|
|
expect(secondCall.match(/Haskell/g)?.length).toBe(1);
|
|
|
|
const renderedResult = askToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "" }],
|
|
details: {
|
|
results: [
|
|
{
|
|
id: "fav_lang",
|
|
question: "Which programming language?",
|
|
options: options.map(option => option.label),
|
|
multi: false,
|
|
selectedOptions: ["Python"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
);
|
|
const firstResult = stripAnsi(renderedResult.render(120).join("\n"));
|
|
const secondResult = stripAnsi(renderedResult.render(120).join("\n"));
|
|
expect(secondResult).toBe(firstResult);
|
|
expect(secondResult.match(/TypeScript/g)?.length).toBe(1);
|
|
expect(secondResult.match(/Haskell/g)?.length).toBe(1);
|
|
});
|
|
it("renders single-choice result selection with a filled radio marker", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "" }],
|
|
details: { question: "Pick one", multi: false, selectedOptions: ["Alpha"] },
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
);
|
|
const text = stripAnsi(rendered.render(120).join("\n"));
|
|
expect(text).toContain(theme!.radio.selected);
|
|
expect(text).not.toContain(theme!.checkbox.checked);
|
|
});
|
|
|
|
it("renders multi-select result selections with checkbox markers", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
const rendered = askToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "" }],
|
|
details: { question: "Pick many", multi: true, selectedOptions: ["Alpha", "Beta"] },
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
);
|
|
const text = stripAnsi(rendered.render(120).join("\n"));
|
|
expect(text).toContain(theme!.checkbox.checked);
|
|
expect(text).not.toContain(theme!.radio.selected);
|
|
});
|
|
});
|