fix: align ask_user payload validation

This commit is contained in:
Erik Kotsiuba
2026-08-12 14:18:22 +02:00
parent 0145055445
commit 852e625d86
10 changed files with 242 additions and 75 deletions
+127 -2
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateToolArguments } from "@earendil-works/pi-ai/base";
import { registerAskTool } from "../src/ask-tool.ts";
import type { AskParams } from "../src/types.ts";
@@ -14,6 +15,7 @@ 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 MISSING_OPTION_LABEL_RE = /label/;
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"/;
@@ -37,6 +39,8 @@ function registerMockTool() {
entries,
tool: tools[0] as {
execute: (...args: any[]) => Promise<any>;
parameters: Record<string, any>;
prepareArguments: (args: unknown) => unknown;
renderCall: (args: unknown, theme: any) => { text: string };
renderResult: (
result: any,
@@ -304,7 +308,7 @@ test("ask tool reports preview validation with structured issues", async () => {
id: "layout",
prompt: "Pick layout",
type: "preview",
options: [{ value: "compact", label: "Compact" }],
options: [{ value: "compact", label: "Compact", preview: " " }],
},
],
},
@@ -327,6 +331,109 @@ test("ask tool reports preview validation with structured issues", async () => {
assert.match(result.content[0].text, PREVIEW_RULE_RE);
});
test("ask tool accepts blank optional presentation fields", async () => {
const { tool } = registerMockTool();
const params = validateToolArguments(tool as never, {
type: "toolCall",
id: "call-blank-optionals",
name: "ask_user",
arguments: {
title: " ",
questions: [
{
id: "scope",
label: " ",
prompt: "Pick scope",
options: [
{
value: "small",
label: "Small",
description: " ",
preview: "\t",
},
],
},
],
},
}) as AskParams;
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.throws(
() =>
validateToolArguments(tool as never, {
type: "toolCall",
id: "call-missing-label",
name: "ask_user",
arguments: missingLabel,
}),
MISSING_OPTION_LABEL_RE
);
});
test("ask tool transcript renderers summarize call and cancelled result", () => {
const { tool } = registerMockTool();
const theme = {
@@ -377,5 +484,23 @@ test("ask tool transcript renderers summarize call and cancelled result", () =>
undefined,
theme
).text;
assert.equal(invalidText, "Invalid input");
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'
);
});