feat: harden ask tool validation and fallback
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { registerAskTool } from "../src/ask-tool.ts";
|
||||
import type { AskParams } from "../src/types.ts";
|
||||
|
||||
const NON_INTERACTIVE_MESSAGE_RE =
|
||||
/Needs user input: ask_user requires interactive UI\./;
|
||||
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 = /duplicate question id "scope"/;
|
||||
const HAS_UI = "hasUI";
|
||||
const noop = () => {
|
||||
// intentional test callback
|
||||
};
|
||||
|
||||
function registerMockTool() {
|
||||
const tools: Record<string, unknown>[] = [];
|
||||
registerAskTool({
|
||||
registerTool(tool: unknown) {
|
||||
tools.push(tool as Record<string, unknown>);
|
||||
},
|
||||
} as never);
|
||||
return tools[0] as {
|
||||
execute: (...args: any[]) => Promise<any>;
|
||||
renderCall: (args: unknown, theme: any) => { text: string };
|
||||
renderResult: (
|
||||
result: any,
|
||||
options: unknown,
|
||||
theme: any
|
||||
) => { text: string };
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(hasUi: boolean): unknown {
|
||||
return { [HAS_UI]: hasUi };
|
||||
}
|
||||
|
||||
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 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.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 rejects invalid payloads before UI opens", 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.questions.length, 0);
|
||||
assert.match(result.content[0].text, DUPLICATE_ID_RE);
|
||||
});
|
||||
|
||||
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,
|
||||
questions: [],
|
||||
answers: {},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
theme
|
||||
).text;
|
||||
assert.equal(resultText, "Cancelled");
|
||||
});
|
||||
+93
-1
@@ -1,6 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createInitialState } from "../src/state/create.ts";
|
||||
|
||||
const DUPLICATE_QUESTION_ID_RE = /duplicate question id "scope"/;
|
||||
const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/;
|
||||
const PREVIEW_REQUIRED_RE =
|
||||
/preview questions require preview text for every option/;
|
||||
|
||||
import { toAskResult } from "../src/state/result.ts";
|
||||
import { getRenderableOptions } from "../src/state/selectors.ts";
|
||||
import {
|
||||
@@ -59,7 +65,7 @@ test("normalize defaults via initial state", () => {
|
||||
|
||||
assert.equal(state.questions[0].label, "Q1");
|
||||
assert.equal(state.questions[0].type, "single");
|
||||
assert.equal(state.questions[0].required, true);
|
||||
assert.equal(state.questions[0].required, false);
|
||||
assert.equal(
|
||||
getRenderableOptions(state.questions[0]).at(-1)?.label,
|
||||
"Type your own"
|
||||
@@ -92,6 +98,92 @@ test("preview questions keep their type and option previews", () => {
|
||||
assert.equal(getRenderableOptions(state.questions[0]).length, 1);
|
||||
});
|
||||
|
||||
test("createInitialState rejects duplicate question ids", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "scope",
|
||||
prompt: "What scope?",
|
||||
options: [{ value: "small", label: "Small" }],
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
prompt: "What tone?",
|
||||
options: [{ value: "direct", label: "Direct" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
DUPLICATE_QUESTION_ID_RE
|
||||
);
|
||||
});
|
||||
|
||||
test("createInitialState rejects duplicate option values within one question", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "scope",
|
||||
prompt: "What scope?",
|
||||
options: [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "small", label: "Also small" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
DUPLICATE_OPTION_VALUE_RE
|
||||
);
|
||||
});
|
||||
|
||||
test("preview questions require preview text for every option", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "layout",
|
||||
prompt: "Pick layout",
|
||||
type: "preview",
|
||||
options: [{ value: "compact", label: "Compact" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
PREVIEW_REQUIRED_RE
|
||||
);
|
||||
});
|
||||
|
||||
test("normalize trims ids, prompts, and option text", () => {
|
||||
const state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: " scope ",
|
||||
label: " Scope ",
|
||||
prompt: " What scope? ",
|
||||
options: [
|
||||
{
|
||||
value: " small ",
|
||||
label: " Small ",
|
||||
description: " Lowest risk ",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(state.questions[0].id, "scope");
|
||||
assert.equal(state.questions[0].label, "Scope");
|
||||
assert.equal(state.questions[0].prompt, "What scope?");
|
||||
assert.deepEqual(state.questions[0].options[0], {
|
||||
value: "small",
|
||||
label: "Small",
|
||||
description: "Lowest risk",
|
||||
preview: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("single-select number shortcut stores answer and advances to next tab", () => {
|
||||
let state = createInitialState(sampleParams());
|
||||
state = applyNumberShortcut(state, 2);
|
||||
|
||||
Reference in New Issue
Block a user