fix: align ask_user payload validation
This commit is contained in:
+6
-4
@@ -32,8 +32,9 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
- every question must have at least one option
|
- every question must have at least one option
|
||||||
- question ids must be unique within one tool call
|
- question ids must be unique within one tool call
|
||||||
- option `value`s must be unique within a question
|
- option `value`s must be unique within a question
|
||||||
- optional `label`, `description`, and `preview` fields must not be blank when provided
|
- blank optional `title`, question `label`, option `description`, and option `preview` fields are treated as omitted
|
||||||
- `label` falls back to `Q1`, `Q2`, ...
|
- question `label` falls back to `Q1`, `Q2`, ...
|
||||||
|
- option `label` is required in the public schema; before schema validation, a missing or blank string label is derived from a non-empty `value` by replacing hyphens and underscores with spaces and capitalizing the first character
|
||||||
- `type` defaults to `single`
|
- `type` defaults to `single`
|
||||||
- `required` defaults to `false`
|
- `required` defaults to `false`
|
||||||
- `required` is metadata only; it never blocks submission
|
- `required` is metadata only; it never blocks submission
|
||||||
@@ -161,7 +162,8 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
## Output rules
|
## Output rules
|
||||||
|
|
||||||
- `cancelled: true` means the user dismissed the flow, UI was unavailable, or the payload was invalid before UI opened
|
- `cancelled: true` means the user dismissed the flow, UI was unavailable, or the payload was invalid before UI opened
|
||||||
- invalid payloads return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message
|
- semantically invalid payloads that reach tool execution return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message; their rendered status is `Invalid tool payload`
|
||||||
|
- payloads missing schema-required fields fail Pi's schema validation before tool execution and use Pi's standard tool-error result without structured `details`
|
||||||
- `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes
|
- `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes
|
||||||
- unanswered questions are omitted from `answers`
|
- unanswered questions are omitted from `answers`
|
||||||
- in `mode: "elaborate"`, `answers` contains only committed answers; note-only entries move to `elaboration.items`
|
- in `mode: "elaborate"`, `answers` contains only committed answers; note-only entries move to `elaboration.items`
|
||||||
@@ -259,7 +261,7 @@ Dirty dismiss:
|
|||||||
|
|
||||||
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening custom UI.
|
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening custom UI.
|
||||||
|
|
||||||
Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures.
|
The public tool schema requires question `id` and `prompt` plus option `value` and `label`, so missing required fields fail before execution. The tool still validates trimmed text, uniqueness, question types, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
|
||||||
|
|
||||||
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks when the change is saved or otherwise applied in memory. Load-time migrations and invalid config handling do not rewrite, rename, or back up the config file; invalid files load defaults for the session and show a notice. `Present single-select as multi-select` is applied when an ask flow is created and does not rewrite question semantics for an already-open flow; use `main.changeQuestionType` for live per-question changes.
|
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks when the change is saved or otherwise applied in memory. Load-time migrations and invalid config handling do not rewrite, rename, or back up the config file; invalid files load defaults for the session and show a notice. `Present single-select as multi-select` is applied when an ask flow is created and does not rewrite question semantics for an already-open flow; use `main.changeQuestionType` for live per-question changes.
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -13,13 +13,14 @@ import type {
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
export const ASK_TOOL_DESCRIPTION =
|
export const ASK_TOOL_DESCRIPTION =
|
||||||
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview-pane questions. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
|
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview-pane questions. Always include a stable `id` and non-empty `prompt` for every question, plus a machine-readable `value` and visible `label` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
|
||||||
|
|
||||||
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
||||||
"Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
"Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
||||||
"When multiple valid directions exist, call `ask_user` with 1-3 concise questions instead of committing to one path on your own.",
|
"When multiple valid directions exist, call `ask_user` with 1-3 concise questions instead of committing to one path on your own.",
|
||||||
"When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
"When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
||||||
"When calling `ask_user`, always include a non-empty machine-readable `value` for every option.",
|
"When calling `ask_user`, always include a stable `id` and non-empty `prompt` for every question.",
|
||||||
|
"When calling `ask_user`, always include a non-empty machine-readable `value` and visible `label` for every option.",
|
||||||
"When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
|
"When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
|
||||||
'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
||||||
"After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
"After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
||||||
@@ -88,7 +89,7 @@ export function renderAskToolCall(args: unknown, theme: ToolTheme) {
|
|||||||
? params.questions
|
? params.questions
|
||||||
.map(
|
.map(
|
||||||
(question: AskQuestionInput, index) =>
|
(question: AskQuestionInput, index) =>
|
||||||
question.label || `Q${index + 1}`
|
question.label?.trim() || `Q${index + 1}`
|
||||||
)
|
)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
: "";
|
: "";
|
||||||
@@ -112,17 +113,16 @@ export function renderAskToolResult(
|
|||||||
theme: ToolTheme
|
theme: ToolTheme
|
||||||
) {
|
) {
|
||||||
const details = result.details;
|
const details = result.details;
|
||||||
if (!details) {
|
if (!(details && Array.isArray(details.questions))) {
|
||||||
const text = result.content[0];
|
const text = result.content[0];
|
||||||
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
||||||
}
|
}
|
||||||
if (details.error) {
|
const text = renderResultText(details);
|
||||||
return new Text(theme.fg("warning", "Invalid input"), 0, 0);
|
return new Text(
|
||||||
}
|
details.error || details.cancelled ? theme.fg("warning", text) : text,
|
||||||
if (details.cancelled) {
|
0,
|
||||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
0
|
||||||
}
|
);
|
||||||
return new Text(renderResultText(details), 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorResultDetails(
|
function errorResultDetails(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { getAskConfigStore } from "./config/store.ts";
|
import { getAskConfigStore } from "./config/store.ts";
|
||||||
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
||||||
import { AskParamsSchema } from "./schema.ts";
|
import { AskParamsSchema } from "./schema.ts";
|
||||||
|
import { prepareAskParams } from "./state/normalize.ts";
|
||||||
import type { AskParams } from "./types.ts";
|
import type { AskParams } from "./types.ts";
|
||||||
import { runAskFlow } from "./ui/controller.ts";
|
import { runAskFlow } from "./ui/controller.ts";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export function registerAskTool(
|
|||||||
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
||||||
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
||||||
parameters: AskParamsSchema,
|
parameters: AskParamsSchema,
|
||||||
|
prepareArguments: (args) => prepareAskParams(args) as AskParams,
|
||||||
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
||||||
executeAskTool(
|
executeAskTool(
|
||||||
pi,
|
pi,
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import type { AskResult } from "./types.ts";
|
|||||||
|
|
||||||
export function renderResultText(result: AskResult): string {
|
export function renderResultText(result: AskResult): string {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return "Invalid input";
|
return "Invalid tool payload";
|
||||||
}
|
}
|
||||||
if (result.cancelled) {
|
if (result.cancelled) {
|
||||||
return CANCELLED_RESULT_TEXT;
|
return CANCELLED_RESULT_TEXT;
|
||||||
|
|||||||
+15
-23
@@ -1,17 +1,13 @@
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
export const AskOptionSchema = Type.Object({
|
export const AskOptionSchema = Type.Object({
|
||||||
value: Type.Optional(
|
value: Type.String({
|
||||||
Type.String({
|
description:
|
||||||
description:
|
"Required machine-readable value returned for this option in the result",
|
||||||
"Required machine-readable value returned for this option in the result",
|
}),
|
||||||
})
|
label: Type.String({
|
||||||
),
|
description: "Required short visible option label shown in the list",
|
||||||
label: Type.Optional(
|
}),
|
||||||
Type.String({
|
|
||||||
description: "Required short visible option label shown in the list",
|
|
||||||
})
|
|
||||||
),
|
|
||||||
description: Type.Optional(
|
description: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: "Optional one-line explanation to help the user choose",
|
description: "Optional one-line explanation to help the user choose",
|
||||||
@@ -26,23 +22,19 @@ export const AskOptionSchema = Type.Object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const AskQuestionSchema = Type.Object({
|
export const AskQuestionSchema = Type.Object({
|
||||||
id: Type.Optional(
|
id: Type.String({
|
||||||
Type.String({
|
description:
|
||||||
description:
|
"Required stable question identifier used as the key in returned answers",
|
||||||
"Required stable question identifier used as the key in returned answers",
|
}),
|
||||||
})
|
|
||||||
),
|
|
||||||
label: Type.Optional(
|
label: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: "Short tab label, e.g. Goal, Audience, Tone, Scope",
|
description: "Short tab label, e.g. Goal, Audience, Tone, Scope",
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
prompt: Type.Optional(
|
prompt: Type.String({
|
||||||
Type.String({
|
description:
|
||||||
description:
|
"Required direct question shown to the user; ask about one decision at a time",
|
||||||
"Required direct question shown to the user; ask about one decision at a time",
|
}),
|
||||||
})
|
|
||||||
),
|
|
||||||
type: Type.Optional(
|
type: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ export function createInitialState(
|
|||||||
options: CreateInitialStateOptions = {}
|
options: CreateInitialStateOptions = {}
|
||||||
) {
|
) {
|
||||||
return createBaseState({
|
return createBaseState({
|
||||||
title: params.title,
|
title: params.title?.trim() || undefined,
|
||||||
questions: normalizeQuestions(params, options),
|
questions: normalizeQuestions(params, options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-31
@@ -38,6 +38,51 @@ export function collectValidationIssues(
|
|||||||
return collector.issues;
|
return collector.issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prepareAskParams(input: unknown): unknown {
|
||||||
|
if (!(isRecord(input) && Array.isArray(input.questions))) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
questions: input.questions.map((question) => {
|
||||||
|
if (!(isRecord(question) && Array.isArray(question.options))) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...question,
|
||||||
|
options: question.options.map(prepareOption),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareOption(input: unknown): unknown {
|
||||||
|
if (!isRecord(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.label !== undefined &&
|
||||||
|
!(typeof input.label === "string" && !input.label.trim())
|
||||||
|
) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (typeof input.value !== "string") {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
const words = input.value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
if (!words) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
label: words.charAt(0).toUpperCase() + words.slice(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeQuestion(
|
function normalizeQuestion(
|
||||||
question: AskQuestionInput,
|
question: AskQuestionInput,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -65,8 +110,8 @@ function normalizeOption(option: AskOption): AskOption {
|
|||||||
return {
|
return {
|
||||||
value: option.value.trim(),
|
value: option.value.trim(),
|
||||||
label: option.label.trim(),
|
label: option.label.trim(),
|
||||||
description: option.description?.trim(),
|
description: option.description?.trim() || undefined,
|
||||||
preview: option.preview?.trim(),
|
preview: option.preview?.trim() || undefined,
|
||||||
...(option.freeform ? { freeform: true } : {}),
|
...(option.freeform ? { freeform: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -118,12 +163,6 @@ function validateQuestion(
|
|||||||
`${questionPath}.id`,
|
`${questionPath}.id`,
|
||||||
`Question ${questionNumber}: duplicate question id "${questionId}"`
|
`Question ${questionNumber}: duplicate question id "${questionId}"`
|
||||||
);
|
);
|
||||||
assertOptionalText(
|
|
||||||
question.label,
|
|
||||||
collector,
|
|
||||||
`${questionPath}.label`,
|
|
||||||
`Question ${questionNumber}: label must not be empty`
|
|
||||||
);
|
|
||||||
assertRequired(
|
assertRequired(
|
||||||
question.prompt?.trim(),
|
question.prompt?.trim(),
|
||||||
collector,
|
collector,
|
||||||
@@ -218,18 +257,6 @@ function validateOption(
|
|||||||
`${optionPath}.label`,
|
`${optionPath}.label`,
|
||||||
`${prefix}: label is required`
|
`${prefix}: label is required`
|
||||||
);
|
);
|
||||||
assertOptionalText(
|
|
||||||
option.description,
|
|
||||||
collector,
|
|
||||||
`${optionPath}.description`,
|
|
||||||
`${prefix}: description must not be empty`
|
|
||||||
);
|
|
||||||
assertOptionalText(
|
|
||||||
option.preview,
|
|
||||||
collector,
|
|
||||||
`${optionPath}.preview`,
|
|
||||||
`${prefix}: preview must not be empty`
|
|
||||||
);
|
|
||||||
if (questionType === "preview") {
|
if (questionType === "preview") {
|
||||||
assertRequired(
|
assertRequired(
|
||||||
optionPreview,
|
optionPreview,
|
||||||
@@ -287,17 +314,6 @@ function assertRequired(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertOptionalText(
|
|
||||||
value: string | undefined,
|
|
||||||
collector: IssueCollector,
|
|
||||||
path: string,
|
|
||||||
message: string
|
|
||||||
) {
|
|
||||||
if (value !== undefined && !value.trim()) {
|
|
||||||
collector.add(path, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUnique(
|
function assertUnique(
|
||||||
seen: Set<string>,
|
seen: Set<string>,
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
|
|||||||
+127
-2
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { validateToolArguments } from "@earendil-works/pi-ai/base";
|
||||||
import { registerAskTool } from "../src/ask-tool.ts";
|
import { registerAskTool } from "../src/ask-tool.ts";
|
||||||
import type { AskParams } from "../src/types.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/;
|
/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 =
|
const MISSING_OPTION_VALUE_RE =
|
||||||
/questions\[0\]\.options\[0\]\.value: Question 1, option 1: value is required/;
|
/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 EMPTY_QUESTIONS_RE = /questions: At least one question is required/;
|
||||||
const INVALID_TYPE_RE =
|
const INVALID_TYPE_RE =
|
||||||
/questions\[0\]\.type: Question 1: invalid type "grid"; expected "single", "multi", or "preview"/;
|
/questions\[0\]\.type: Question 1: invalid type "grid"; expected "single", "multi", or "preview"/;
|
||||||
@@ -37,6 +39,8 @@ function registerMockTool() {
|
|||||||
entries,
|
entries,
|
||||||
tool: tools[0] as {
|
tool: tools[0] as {
|
||||||
execute: (...args: any[]) => Promise<any>;
|
execute: (...args: any[]) => Promise<any>;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
prepareArguments: (args: unknown) => unknown;
|
||||||
renderCall: (args: unknown, theme: any) => { text: string };
|
renderCall: (args: unknown, theme: any) => { text: string };
|
||||||
renderResult: (
|
renderResult: (
|
||||||
result: any,
|
result: any,
|
||||||
@@ -304,7 +308,7 @@ test("ask tool reports preview validation with structured issues", async () => {
|
|||||||
id: "layout",
|
id: "layout",
|
||||||
prompt: "Pick layout",
|
prompt: "Pick layout",
|
||||||
type: "preview",
|
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);
|
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", () => {
|
test("ask tool transcript renderers summarize call and cancelled result", () => {
|
||||||
const { tool } = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
const theme = {
|
const theme = {
|
||||||
@@ -377,5 +484,23 @@ test("ask tool transcript renderers summarize call and cancelled result", () =>
|
|||||||
undefined,
|
undefined,
|
||||||
theme
|
theme
|
||||||
).text;
|
).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'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ test("elaborate continuation preserves unrelated committed answers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid input result renders as invalid input", () => {
|
test("invalid tool payload result is attributed to the tool call", () => {
|
||||||
const result = {
|
const result = {
|
||||||
title: "Interview",
|
title: "Interview",
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
@@ -581,7 +581,7 @@ test("invalid input result renders as invalid input", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(renderResultText(result), "Invalid input");
|
assert.equal(renderResultText(result), "Invalid tool payload");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cancelled result renders as cancelled", () => {
|
test("cancelled result renders as cancelled", () => {
|
||||||
|
|||||||
@@ -254,6 +254,36 @@ test("normalize trims ids, prompts, and option text", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalize treats blank optional presentation text as absent", () => {
|
||||||
|
const state = createInitialState({
|
||||||
|
title: " ",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
label: " ",
|
||||||
|
prompt: "Pick scope",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "small",
|
||||||
|
label: "Small",
|
||||||
|
description: " ",
|
||||||
|
preview: "\t",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(state.title, undefined);
|
||||||
|
assert.equal(state.questions[0].label, "Q1");
|
||||||
|
assert.deepEqual(state.questions[0].options[0], {
|
||||||
|
value: "small",
|
||||||
|
label: "Small",
|
||||||
|
description: undefined,
|
||||||
|
preview: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("single-select number shortcut stores answer and advances to next tab", () => {
|
test("single-select number shortcut stores answer and advances to next tab", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = applyNumberShortcut(state, 2);
|
state = applyNumberShortcut(state, 2);
|
||||||
|
|||||||
Reference in New Issue
Block a user