Files
pi-ask/tests/ask-tool.test.ts
T
jb 80da307828
CI / validate (push) Failing after 13s
feat: support ask flow in RPC clients
2026-08-22 21:55:08 -04:00

630 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { Value } from "typebox/value";
import { registerAskTool } from "../src/ask-tool.ts";
import { AskOptionSchema, AskParamsSchema } from "../src/schema.ts";
import type { AskParams } from "../src/types.ts";
const NON_INTERACTIVE_MESSAGE_RE =
/Needs user input: ask_user requires interactive TUI mode\./;
const FIRST_QUESTION_RE = /1\. Goal: What should I optimize for\?/;
const SPEED_OPTION_RE = /- Speed \[speed\]/;
const CUSTOM_OPTION_RE = /- Type your own \[custom\]/;
const DUPLICATE_ID_RE =
/questions\[1\]\.id: Question 2: duplicate question id "scope"/;
const PREVIEW_RULE_RE =
/questions\[0\]\.options\[0\]\.preview: Question 1, option 1: preview questions require preview text for every option; add preview text or use type "single" instead/;
const MISSING_OPTION_VALUE_RE =
/questions\[0\]\.options\[0\]\.value: Question 1, option 1: value is required/;
const EMPTY_QUESTIONS_RE = /questions: At least one question is required/;
const INVALID_TYPE_RE =
/questions\[0\]\.type: Question 1: invalid type "grid"; expected "single", "multi", or "preview"/;
const HAS_UI = "hasUI";
const noop = () => {
// intentional test callback
};
function registerMockTool() {
const tools: Record<string, unknown>[] = [];
const entries: Array<{ customType: string; data: unknown }> = [];
registerAskTool({
appendEntry(customType: string, data: unknown) {
entries.push({ customType, data });
},
registerTool(tool: unknown) {
tools.push(tool as Record<string, unknown>);
},
} as never);
return {
entries,
tool: tools[0] as {
execute: (...args: any[]) => Promise<any>;
parameters: Record<string, any>;
prepareArguments: (args: unknown) => unknown;
promptGuidelines: string[];
renderCall: (args: unknown, theme: any) => { text: string };
renderResult: (
result: any,
options: unknown,
theme: any
) => { text: string };
},
};
}
function makeCtx(hasUi: boolean, mode = hasUi ? "tui" : "print"): unknown {
return { [HAS_UI]: hasUi, mode };
}
function makeRpcCtx(responses: Array<string | undefined>) {
const requests: Array<{
title: string;
options?: string[];
placeholder?: string;
}> = [];
return {
ctx: {
[HAS_UI]: true,
mode: "rpc",
ui: {
select(title: string, options: string[]) {
requests.push({ title, options });
return Promise.resolve(responses.shift());
},
input(title: string, placeholder?: string) {
requests.push({ title, placeholder });
return Promise.resolve(responses.shift());
},
},
},
requests,
};
}
function sampleParams(): AskParams {
return {
title: "Clarify next step",
questions: [
{
id: "goal",
label: "Goal",
prompt: "What should I optimize for?",
options: [
{ value: "speed", label: "Speed" },
{ value: "safety", label: "Safety" },
],
},
],
};
}
test("ask option schema and tool guidance support grounded recommendations", () => {
const { tool } = registerMockTool();
assert.equal(
Value.Check(AskOptionSchema, {
value: "small",
label: "Small",
description: "Lowest implementation risk",
recommended: true,
}),
true
);
assert(
tool.promptGuidelines.some(
(guideline) =>
guideline.includes("grounded preferences") &&
guideline.includes("description")
)
);
});
test("ask params schema constrains question types", () => {
assert.equal(
Value.Check(AskParamsSchema, {
questions: [
{
id: "layout",
prompt: "Choose a layout",
type: "grid",
options: [{ value: "compact", label: "Compact" }],
},
],
}),
false
);
});
test("ask tool stores valid payloads as soon as they are called", async () => {
const { entries, tool } = registerMockTool();
const params = sampleParams();
await tool.execute("call-1", params, undefined, noop, makeCtx(false));
assert.equal(entries.length, 1);
assert.equal(entries[0].customType, "ask:payload");
assert.deepEqual(entries[0].data, {
version: 1,
source: "tool",
params,
sourceEntryId: "call-1",
timestamp: (entries[0].data as { timestamp: number }).timestamp,
});
});
test("ask tool returns pending questions in non-interactive mode", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
makeCtx(false)
);
assert.equal(result.details.cancelled, true);
assert.equal(result.details.mode, "submit");
assert.deepEqual(result.details.questions, [
{
id: "goal",
label: "Goal",
prompt: "What should I optimize for?",
type: "single",
},
]);
assert.deepEqual(result.details.answers, {});
assert.match(result.content[0].text, NON_INTERACTIVE_MESSAGE_RE);
assert.match(result.content[0].text, FIRST_QUESTION_RE);
assert.match(result.content[0].text, SPEED_OPTION_RE);
assert.match(result.content[0].text, CUSTOM_OPTION_RE);
});
test("ask tool uses standard dialogs in RPC mode", async () => {
const { tool } = registerMockTool();
const { ctx, requests } = makeRpcCtx(["1. Speed"]);
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
ctx
);
assert.deepEqual(requests, [
{
title: "Goal: What should I optimize for?",
options: ["1. Speed", "2. Safety", "3. Type your own"],
},
]);
assert.equal(result.details.cancelled, false);
assert.deepEqual(result.details.answers.goal, {
indices: [0],
labels: ["Speed"],
values: ["speed"],
});
});
test("ask tool collects a custom answer through an RPC input dialog", async () => {
const { tool } = registerMockTool();
const { ctx, requests } = makeRpcCtx(["3. Type your own", "Balance both"]);
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
ctx
);
assert.deepEqual(requests[1], {
title: "Goal: What should I optimize for?",
placeholder: "Type your answer",
});
assert.deepEqual(result.details.answers.goal, {
indices: [],
labels: [],
values: [],
customText: "Balance both",
});
});
test("ask tool collects multiple selections through RPC dialogs", async () => {
const { tool } = registerMockTool();
const params = sampleParams();
params.questions[0].type = "multi";
const { ctx, requests } = makeRpcCtx([
"1. [ ] Speed",
"2. [ ] Safety",
"3. Done",
]);
const result = await tool.execute("call-1", params, undefined, noop, ctx);
assert.equal(requests.length, 3);
assert.deepEqual(requests[1]?.options, [
"1. [x] Speed",
"2. [ ] Safety",
"3. Done",
"4. Type your own",
]);
assert.deepEqual(result.details.answers.goal, {
indices: [0, 1],
labels: ["Speed", "Safety"],
values: ["speed", "safety"],
});
});
test("ask tool returns a cancelled RPC result when a dialog is dismissed", async () => {
const { tool } = registerMockTool();
const { ctx } = makeRpcCtx([undefined]);
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
ctx
);
assert.equal(result.details.cancelled, true);
assert.deepEqual(result.details.answers, {});
});
test("ask tool includes custom answer fallback for preview questions", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
{
title: "Clarify",
questions: [
{
id: "layout",
label: "Layout",
prompt: "Which layout?",
type: "preview",
options: [{ value: "compact", label: "Compact", preview: "A" }],
},
],
},
undefined,
noop,
makeCtx(false)
);
assert.match(result.content[0].text, CUSTOM_OPTION_RE);
});
test("ask tool rejects invalid payloads before UI opens with structured issues", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
{
title: "Clarify",
questions: [
{
id: "scope",
prompt: "Pick scope",
options: [{ value: "small", label: "Small" }],
},
{
id: "scope",
prompt: "Pick tone",
options: [{ value: "direct", label: "Direct" }],
},
],
},
undefined,
noop,
makeCtx(true)
);
assert.equal(result.details.cancelled, true);
assert.equal(result.details.mode, "submit");
assert.equal(result.details.questions.length, 0);
assert.deepEqual(result.details.error, {
kind: "invalid_input",
issues: [
{
path: "questions[1].id",
message: 'Question 2: duplicate question id "scope"',
},
],
});
assert.match(result.content[0].text, DUPLICATE_ID_RE);
});
test("ask tool reports missing option values with structured issues", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
{
title: "Clarify",
questions: [
{
id: "api_style",
prompt: "Pick API style",
options: [{ label: "REST" } as never],
},
],
},
undefined,
noop,
makeCtx(true)
);
assert.equal(result.details.cancelled, true);
assert.deepEqual(result.details.error, {
kind: "invalid_input",
issues: [
{
path: "questions[0].options[0].value",
message: "Question 1, option 1: value is required",
},
],
});
assert.match(result.content[0].text, MISSING_OPTION_VALUE_RE);
});
test("ask tool reports empty questions with structured issues", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
{
title: "Clarify",
questions: [],
},
undefined,
noop,
makeCtx(true)
);
assert.equal(result.details.cancelled, true);
assert.deepEqual(result.details.error, {
kind: "invalid_input",
issues: [
{
path: "questions",
message: "At least one question is required",
},
],
});
assert.match(result.content[0].text, EMPTY_QUESTIONS_RE);
});
test("ask tool reports invalid question types with structured issues", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
{
title: "Clarify",
questions: [
{
id: "layout",
prompt: "Pick layout",
type: "grid" as never,
options: [{ value: "compact", label: "Compact" }],
},
],
},
undefined,
noop,
makeCtx(true)
);
assert.equal(result.details.cancelled, true);
assert.deepEqual(result.details.error, {
kind: "invalid_input",
issues: [
{
path: "questions[0].type",
message:
'Question 1: invalid type "grid"; expected "single", "multi", or "preview"',
},
],
});
assert.match(result.content[0].text, INVALID_TYPE_RE);
});
test("ask tool reports preview validation with structured issues", async () => {
const { tool } = registerMockTool();
const result = await tool.execute(
"call-1",
{
title: "Clarify",
questions: [
{
id: "layout",
prompt: "Pick layout",
type: "preview",
options: [{ value: "compact", label: "Compact", preview: " " }],
},
],
},
undefined,
noop,
makeCtx(true)
);
assert.equal(result.details.cancelled, true);
assert.deepEqual(result.details.error, {
kind: "invalid_input",
issues: [
{
path: "questions[0].options[0].preview",
message:
'Question 1, option 1: preview questions require preview text for every option; add preview text or use type "single" instead',
},
],
});
assert.match(result.content[0].text, PREVIEW_RULE_RE);
});
test("ask tool accepts blank optional presentation fields", async () => {
const { tool } = registerMockTool();
const params: AskParams = {
title: " ",
questions: [
{
id: "scope",
label: " ",
prompt: "Pick scope",
options: [
{
value: "small",
label: "Small",
description: " ",
preview: "\t",
},
],
},
],
};
assert.equal(Value.Check(AskParamsSchema, params), true);
const result = await tool.execute(
"call-blank-optionals",
params,
undefined,
noop,
makeCtx(false)
);
assert.equal(result.details.error, undefined);
assert.equal(result.details.title, undefined);
assert.equal(result.details.questions[0].label, "Q1");
});
test("ask tool prepares missing and blank option labels before schema validation", async () => {
const { tool } = registerMockTool();
const raw = {
questions: [
{
id: "encryption",
prompt: "Choose encryption",
options: [
{ value: "region-local-kms-reencrypt-dek" },
{ value: "follow_up", label: " " },
{ value: "existing", label: "Existing label" },
],
},
],
};
assert.equal(Value.Check(AskParamsSchema, raw), false);
const prepared = tool.prepareArguments(raw) as AskParams;
assert.equal(Value.Check(AskParamsSchema, prepared), true);
assert.deepEqual(
prepared.questions[0]?.options.map((option) => option.label),
["Region local kms reencrypt dek", "Follow up", "Existing label"]
);
const result = await tool.execute(
"call-prepared-labels",
prepared,
undefined,
noop,
makeCtx(false)
);
assert.equal(result.details.error, undefined);
});
test("public schema requires semantic identifiers and labels", () => {
const { tool } = registerMockTool();
assert.deepEqual(tool.parameters.required, ["questions"]);
const questionSchema = tool.parameters.properties.questions.items;
assert.deepEqual(questionSchema.required, ["id", "prompt", "options"]);
assert.deepEqual(questionSchema.properties.options.items.required, [
"value",
"label",
]);
const missingLabel = {
questions: [
{
id: "scope",
prompt: "Pick scope",
options: [{ value: "small" }],
},
],
};
assert.equal(Value.Check(AskParamsSchema, missingLabel), false);
});
test("ask tool transcript renderers summarize call and cancelled result", () => {
const { tool } = registerMockTool();
const theme = {
bold: (text: string) => text,
fg: (_token: string, text: string) => text,
};
const callText = tool.renderCall(
{
questions: [
{ label: "Goal", options: [], prompt: "Q?", id: "goal" },
{ label: "Tone", options: [], prompt: "Q?", id: "tone" },
],
},
theme
).text;
assert.equal(callText, "ask_user 2 question(s) (Goal, Tone)");
const resultText = tool.renderResult(
{
content: [{ type: "text", text: "ignored" }],
details: {
cancelled: true,
mode: "submit",
questions: [],
answers: {},
},
},
undefined,
theme
).text;
assert.equal(resultText, "Cancelled");
const invalidText = tool.renderResult(
{
content: [{ type: "text", text: "ignored" }],
details: {
cancelled: true,
mode: "submit",
questions: [],
answers: {},
error: {
kind: "invalid_input",
issues: [],
},
},
},
undefined,
theme
).text;
assert.equal(invalidText, "Invalid tool payload");
const schemaErrorText = tool.renderResult(
{
content: [
{
type: "text",
text: 'Validation failed for tool "ask_user": missing label',
},
],
details: {},
},
undefined,
theme
).text;
assert.equal(
schemaErrorText,
'Validation failed for tool "ask_user": missing label'
);
});