feat: mark recommended ask options

This commit is contained in:
Erik Kotsiuba
2026-08-12 14:18:59 +02:00
parent 4de596ae54
commit bd5f4520a3
20 changed files with 231 additions and 20 deletions
+3 -2
View File
@@ -3,7 +3,7 @@
# @eko24ive/pi-ask # @eko24ive/pi-ask
[![npm downloads](https://badgen.net/npm/dm/@eko24ive/pi-ask)](https://www.npmjs.com/package/@eko24ive/pi-ask) [![npm downloads](https://badgen.net/npm/dm/@eko24ive/pi-ask)](https://www.npmjs.com/package/@eko24ive/pi-ask)
[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=0145055)](https://github.com/eko24ive/pi-ask/commits/main) [![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=6fef4ac)](https://github.com/eko24ive/pi-ask/commits/main)
[![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers) [![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers)
> [!IMPORTANT] > [!IMPORTANT]
@@ -48,6 +48,7 @@ pi -e npm:@eko24ive/pi-ask
Once installed, this package gives the agent a native way to ask for clarification instead of guessing. Once installed, this package gives the agent a native way to ask for clarification instead of guessing.
- 🧭 Familiar ask-style interface: tabbed questions, single/multi select, and preview mode - 🧭 Familiar ask-style interface: tabbed questions, single/multi select, and preview mode
- ⭐ Optional warning-colored `(recommended)` markers that do not preselect answers
- ✍️ Inline free-form `Type your own` answers - ✍️ Inline free-form `Type your own` answers
- 📎 Native pi-style `@` file references inside answer and note editors - 📎 Native pi-style `@` file references inside answer and note editors
- 📝 Question-level and option-level notes - 📝 Question-level and option-level notes
@@ -198,7 +199,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa
After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands. After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands.
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour and notification settings are binary `on`/`off` toggles that save immediately when the config file is writable; save failures revert the toggle and show a manual-edit message. The settings overlay includes a guarded double-press reset-to-defaults action; keymaps, notification channels, and extraction settings are changed by editing the shown config file path. Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. They can mark any number of grounded preferences with `recommended: true` and use option descriptions for reasons. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour and notification settings are binary `on`/`off` toggles that save immediately when the config file is writable; save failures revert the toggle and show a manual-edit message. The settings overlay includes a guarded double-press reset-to-defaults action; keymaps, notification channels, and extraction settings are changed by editing the shown config file path.
### Answer and replay commands ### Answer and replay commands
+8
View File
@@ -20,6 +20,7 @@ This document defines the stable external behavior. It does not explain internal
label: string; label: string;
description?: string; description?: string;
preview?: string; preview?: string;
recommended?: boolean;
}>; }>;
}>; }>;
} }
@@ -35,6 +36,8 @@ This document defines the stable external behavior. It does not explain internal
- blank optional `title`, question `label`, option `description`, and option `preview` fields are treated as omitted - blank optional `title`, question `label`, option `description`, and option `preview` fields are treated as omitted
- question `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 - 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
- `recommended` is optional presentation metadata; zero, one, or multiple options may set it to `true`
- recommended options render warning-colored `(recommended)` followed by muted ` | <description>`, or only `(recommended)` when no description exists, but are not preselected
- `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
@@ -107,6 +110,7 @@ This document defines the stable external behavior. It does not explain internal
label: string; label: string;
description?: string; description?: string;
preview?: string; preview?: string;
recommended?: boolean;
}>; }>;
}; };
answered: boolean; answered: boolean;
@@ -133,6 +137,7 @@ This document defines the stable external behavior. It does not explain internal
label: string; label: string;
description?: string; description?: string;
preview?: string; preview?: string;
recommended?: boolean;
}>; }>;
}; };
option: { option: {
@@ -140,6 +145,7 @@ This document defines the stable external behavior. It does not explain internal
label: string; label: string;
description?: string; description?: string;
preview?: string; preview?: string;
recommended?: boolean;
}; };
selected: boolean; selected: boolean;
answered: boolean; answered: boolean;
@@ -172,6 +178,7 @@ This document defines the stable external behavior. It does not explain internal
- `continuation.affectedQuestionIds` lists the only questions that should be revisited - `continuation.affectedQuestionIds` lists the only questions that should be revisited
- `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered` - `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered`
- single-select answers still use arrays - single-select answers still use arrays
- recommendation markers never change canonical submitted labels or values
- when `behaviour.presentSingleAsMulti` is enabled, requested single-select questions are presented and handled as multi-select in future/replayed ask flows; result question metadata keeps the requested `type`, adds `presentedType` when final presentation differs, and result text uses one compact note when any answered questions were presented differently - when `behaviour.presentSingleAsMulti` is enabled, requested single-select questions are presented and handled as multi-select in future/replayed ask flows; result question metadata keeps the requested `type`, adds `presentedType` when final presentation differs, and result text uses one compact note when any answered questions were presented differently
- `indices` are 1-based rendered option positions - `indices` are 1-based rendered option positions
- `customText` stores the free-form answer - `customText` stores the free-form answer
@@ -199,6 +206,7 @@ This document defines the stable external behavior. It does not explain internal
- tabbed multi-question flow - tabbed multi-question flow
- single-select, multi-select, and preview questions - single-select, multi-select, and preview questions
- optional warning-colored recommendation subtitles in standard and preview option lists without automatic selection
- active question type changes via configurable `main.changeQuestionType` hotkey, default `t`; non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi` - active question type changes via configurable `main.changeQuestionType` hotkey, default `t`; non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi`
- inline free-form answers for all question types - inline free-form answers for all question types
- native pi-style `@` file path autocomplete inside free-form answer and note editors - native pi-style `@` file path autocomplete inside free-form answer and note editors
+1 -1
View File
@@ -32,7 +32,7 @@ type PiAskStartedEvent = {
}; };
``` ```
Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers. `ask:resume` identifies a form recovered from an interrupted tool call. Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers. `ask:resume` identifies a form recovered from an interrupted tool call. Started-event options preserve the public optional `recommended` boolean, which is presentation metadata only and never changes remote submission values or labels.
## Submit an answer ## Submit an answer
+1
View File
@@ -39,6 +39,7 @@ Rules:
- Never mix a freeform option with normal options. - Never mix a freeform option with normal options.
- Do not create an option that merely restates the question. - Do not create an option that merely restates the question.
- Include descriptions only when helpful. - Include descriptions only when helpful.
- Never add recommendation metadata.
- If there are no questions, call ask_user with {"questions":[]}.`; - If there are no questions, call ask_user with {"questions":[]}.`;
interface ExtractionPromptOptions { interface ExtractionPromptOptions {
+1
View File
@@ -21,6 +21,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
"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 stable `id` and non-empty `prompt` for every question.", "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`, always include a non-empty machine-readable `value` and visible `label` for every option.",
"When calling `ask_user`, mark grounded preferences with `recommended: true` and use the option `description` to state the reason.",
"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.",
+1
View File
@@ -14,6 +14,7 @@ export const UI_DIMENSIONS = {
} as const; } as const;
export const UI_TEXT = { export const UI_TEXT = {
recommendedMarker: "(recommended)",
questionNoteTitle: "Note:", questionNoteTitle: "Note:",
reviewTitle: "Review answers", reviewTitle: "Review answers",
unanswered: "→ unanswered", unanswered: "→ unanswered",
+6
View File
@@ -19,6 +19,12 @@ export const AskOptionSchema = Type.Object({
"Optional preview content shown in the dedicated preview pane for preview questions", "Optional preview content shown in the dedicated preview pane for preview questions",
}) })
), ),
recommended: Type.Optional(
Type.Boolean({
description:
"Optional presentation marker for a grounded preference; use the description to explain the reason",
})
),
}); });
export const AskQuestionSchema = Type.Object({ export const AskQuestionSchema = Type.Object({
+3
View File
@@ -112,6 +112,9 @@ function normalizeOption(option: AskOption): AskOption {
label: option.label.trim(), label: option.label.trim(),
description: option.description?.trim() || undefined, description: option.description?.trim() || undefined,
preview: option.preview?.trim() || undefined, preview: option.preview?.trim() || undefined,
...(option.recommended === undefined
? {}
: { recommended: option.recommended }),
...(option.freeform ? { freeform: true } : {}), ...(option.freeform ? { freeform: true } : {}),
}; };
} }
+3
View File
@@ -205,6 +205,9 @@ function cloneOption(option: AskState["questions"][number]["options"][number]) {
label: option.label, label: option.label,
...(option.description ? { description: option.description } : {}), ...(option.description ? { description: option.description } : {}),
...(option.preview ? { preview: option.preview } : {}), ...(option.preview ? { preview: option.preview } : {}),
...(option.recommended === undefined
? {}
: { recommended: option.recommended }),
}; };
} }
+1
View File
@@ -5,6 +5,7 @@ export interface AskOption {
freeform?: boolean; freeform?: boolean;
label: string; label: string;
preview?: string; preview?: string;
recommended?: boolean;
value: string; value: string;
} }
+9 -2
View File
@@ -311,16 +311,23 @@ export function mergeColumns(
} }
export function measurePreviewLeftWidth( export function measurePreviewLeftWidth(
options: Array<{ label: string; description?: string }>, options: Array<{
description?: string;
label: string;
recommended?: boolean;
}>,
width: number width: number
): number { ): number {
let widest = 0; let widest = 0;
for (let index = 0; index < options.length; index++) { for (let index = 0; index < options.length; index++) {
const option = options[index]; const option = options[index];
const description = option.recommended
? `${UI_TEXT.recommendedMarker}${option.description ? ` | ${option.description}` : ""}`
: option.description;
widest = Math.max( widest = Math.max(
widest, widest,
visibleWidth(`${index + 1}. ${option.label}`), visibleWidth(`${index + 1}. ${option.label}`),
option.description ? visibleWidth(option.description) : 0 description ? visibleWidth(description) : 0
); );
} }
+39 -7
View File
@@ -1,5 +1,9 @@
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import {
import { UI_DIMENSIONS } from "../constants/ui.ts"; truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts";
import { import {
measurePreviewLeftWidth, measurePreviewLeftWidth,
mergeColumns, mergeColumns,
@@ -83,7 +87,13 @@ function renderStandardOption(
row.pointer, row.pointer,
" ".repeat(visibleWidth(row.pointer)) " ".repeat(visibleWidth(row.pointer))
); );
renderOptionDescription(lines, row.description, context.width, context.theme); renderOptionSubtitle(
lines,
row.description,
row.recommended,
context.width,
context.theme
);
renderOptionDetail(lines, row.detail, context, { renderOptionDetail(lines, row.detail, context, {
suppressLeadingGap: !!row.description, suppressLeadingGap: !!row.description,
}); });
@@ -170,7 +180,7 @@ function renderPreviewOptionList(
row.pointer, row.pointer,
" " " "
); );
renderOptionDescription(lines, row.description, width, theme); renderOptionSubtitle(lines, row.description, row.recommended, width, theme);
} }
return lines; return lines;
} }
@@ -275,14 +285,36 @@ function renderInteractiveCustomOption(
}); });
} }
function renderOptionDescription( function renderOptionSubtitle(
lines: string[], lines: string[],
description: string | undefined, description: string | undefined,
recommended: boolean,
width: number, width: number,
theme: Theme theme: Theme
) { ) {
if (!description) { if (!recommended) {
if (description) {
pushWrappedText(
lines,
description,
width,
theme,
"muted",
" ",
" "
);
}
return; return;
} }
pushWrappedText(lines, description, width, theme, "muted", " ", " ");
const indent = " ";
const text =
theme.fg("warning", UI_TEXT.recommendedMarker) +
(description ? theme.fg("muted", ` | ${description}`) : "");
for (const line of wrapTextWithAnsi(
text,
Math.max(1, width - visibleWidth(indent))
)) {
lines.push(truncateToWidth(`${indent}${line}`, width));
}
} }
+2
View File
@@ -30,6 +30,7 @@ export interface OptionRowModel {
label: string; label: string;
pointer: string; pointer: string;
prefix: string; prefix: string;
recommended: boolean;
selected: boolean; selected: boolean;
} }
@@ -104,6 +105,7 @@ function buildOptionRowModel(
label: option.label, label: option.label,
pointer, pointer,
prefix: getOptionPrefix(question.type, option, answered), prefix: getOptionPrefix(question.type, option, answered),
recommended: option.recommended === true,
selected, selected,
}; };
} }
+4
View File
@@ -139,6 +139,10 @@ test("extraction context includes the tool and preceding turn", () => {
assert.equal(context.tools?.[0]?.name, "ask_user"); assert.equal(context.tools?.[0]?.name, "ask_user");
assert.equal(context.tools?.length, 1); assert.equal(context.tools?.length, 1);
assert.equal(
(context.systemPrompt ?? "").includes("Never add recommendation metadata."),
true
);
const prompt = JSON.stringify(context.messages[0]?.content); const prompt = JSON.stringify(context.messages[0]?.content);
assert.equal(prompt.includes("Choose an implementation direction."), true); assert.equal(prompt.includes("Choose an implementation direction."), true);
assert.equal(prompt.includes("Which direction: A or B?"), true); assert.equal(prompt.includes("Which direction: A or B?"), true);
+1 -1
View File
@@ -8,7 +8,7 @@ const params: AskParams = {
{ {
id: "goal", id: "goal",
prompt: "Goal?", prompt: "Goal?",
options: [{ value: "a", label: "A" }], options: [{ value: "a", label: "A", recommended: true }],
}, },
], ],
}; };
+24
View File
@@ -1,7 +1,9 @@
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 { validateToolArguments } from "@earendil-works/pi-ai/base";
import { Value } from "typebox/value";
import { registerAskTool } from "../src/ask-tool.ts"; import { registerAskTool } from "../src/ask-tool.ts";
import { AskOptionSchema } from "../src/schema.ts";
import type { AskParams } from "../src/types.ts"; import type { AskParams } from "../src/types.ts";
const NON_INTERACTIVE_MESSAGE_RE = const NON_INTERACTIVE_MESSAGE_RE =
@@ -41,6 +43,7 @@ function registerMockTool() {
execute: (...args: any[]) => Promise<any>; execute: (...args: any[]) => Promise<any>;
parameters: Record<string, any>; parameters: Record<string, any>;
prepareArguments: (args: unknown) => unknown; prepareArguments: (args: unknown) => unknown;
promptGuidelines: string[];
renderCall: (args: unknown, theme: any) => { text: string }; renderCall: (args: unknown, theme: any) => { text: string };
renderResult: ( renderResult: (
result: any, result: any,
@@ -72,6 +75,27 @@ function sampleParams(): AskParams {
}; };
} }
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 tool stores valid payloads as soon as they are called", async () => { test("ask tool stores valid payloads as soon as they are called", async () => {
const { entries, tool } = registerMockTool(); const { entries, tool } = registerMockTool();
const params = sampleParams(); const params = sampleParams();
+4 -1
View File
@@ -152,7 +152,7 @@ test("started event questions are cloned", async () => {
{ {
id: "decision", id: "decision",
prompt: "Proceed?", prompt: "Proceed?",
options: [{ value: "yes", label: "Yes" }], options: [{ value: "yes", label: "Yes", recommended: true }],
}, },
], ],
}).questions; }).questions;
@@ -167,9 +167,12 @@ test("started event questions are cloned", async () => {
const started = bus.events.find( const started = bus.events.find(
(event) => event.channel === PI_ASK_STARTED_EVENT (event) => event.channel === PI_ASK_STARTED_EVENT
)?.data as RemoteAskStartedEvent; )?.data as RemoteAskStartedEvent;
assert.equal(started.questions[0].options[0].recommended, true);
started.questions[0].options[0].label = "Mutated"; started.questions[0].options[0].label = "Mutated";
started.questions[0].options[0].recommended = false;
assert.equal(questions[0].options[0].label, "Yes"); assert.equal(questions[0].options[0].label, "Yes");
assert.equal(questions[0].options[0].recommended, true);
}); });
test("remote runtime disposes active flows and submit listener", () => { test("remote runtime disposes active flows and submit listener", () => {
+75 -6
View File
@@ -9,13 +9,14 @@ import {
} from "../src/state/transitions.ts"; } from "../src/state/transitions.ts";
import { renderQuestionScreen } from "../src/ui/render-question.ts"; import { renderQuestionScreen } from "../src/ui/render-question.ts";
function mockTheme() { function mockTheme(onColor?: (color: string, text: string) => void) {
return { return {
fg(color: string, text: string) { fg(color: string, text: string) {
return `<${color}>${text}</${color}>`; onColor?.(color, text);
return onColor ? text : `<${color}>${text}</${color}>`;
}, },
bg(color: string, text: string) { bg(color: string, text: string) {
return `{${color}}${text}{/${color}}`; return onColor ? text : `{${color}}${text}{/${color}}`;
}, },
bold(text: string) { bold(text: string) {
return text; return text;
@@ -60,6 +61,56 @@ test("custom option stays labeled before selection", () => {
assert(!lines.some((line) => line.includes("Type your answer..."))); assert(!lines.some((line) => line.includes("Type your answer...")));
}); });
test("standard options show recommendation metadata without changing answers", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [
{
value: "a",
label: "Option A",
description: "Best fit for the stated constraints",
recommended: true,
},
{ value: "b", label: "Option B" },
],
},
],
});
const lines: string[] = [];
const calls: Array<{ color: string; text: string }> = [];
renderQuestionScreen({
editor: mockEditor(),
lines,
options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
state,
theme: mockTheme((color, text) => calls.push({ color, text })),
width: 80,
});
assert(lines.some((line) => line.includes("(recommended) | Best fit")));
assert(
calls.some(
(call) => call.color === "warning" && call.text === "(recommended)"
)
);
assert(
calls.some(
(call) =>
call.color === "muted" &&
call.text === " | Best fit for the stated constraints"
)
);
assert.equal(state.answers.q1, undefined);
state = applyNumberShortcut(state, 1);
assert.equal(state.answers.q1.selected[0]?.label, "Option A");
});
test("selected custom option keeps its label and renders editor below", () => { test("selected custom option keeps its label and renders editor below", () => {
let state = createInitialState({ let state = createInitialState({
questions: [ questions: [
@@ -230,30 +281,48 @@ test("freeform-only question renders label without numbering or pointer and sepa
assert(!lines.some((line) => line.includes("❯"))); assert(!lines.some((line) => line.includes("❯")));
}); });
test("preview questions also show the custom answer option", () => { test("preview questions show custom and recommended options", () => {
const state = createInitialState({ const state = createInitialState({
questions: [ questions: [
{ {
id: "q1", id: "q1",
prompt: "Pick one", prompt: "Pick one",
type: "preview", type: "preview",
options: [{ value: "a", label: "A", preview: "Preview A" }], options: [
{
value: "a",
label: "A",
preview: "Preview A",
recommended: true,
},
],
}, },
], ],
}); });
const lines: string[] = []; const lines: string[] = [];
const calls: Array<{ color: string; text: string }> = [];
renderQuestionScreen({ renderQuestionScreen({
editor: mockEditor(), editor: mockEditor(),
lines, lines,
options: getRenderableOptions(state.questions[0]), options: getRenderableOptions(state.questions[0]),
question: state.questions[0], question: state.questions[0],
state, state,
theme: mockTheme(), theme: mockTheme((color, text) => calls.push({ color, text })),
width: 80, width: 80,
}); });
assert(lines.some((line) => line.includes("Type your own"))); assert(lines.some((line) => line.includes("Type your own")));
assert(lines.some((line) => line.trim() === "(recommended)"));
assert(
calls.some(
(call) => call.color === "warning" && call.text === "(recommended)"
)
);
assert(
!calls.some((call) => call.color === "muted" && call.text.startsWith(" |"))
);
assert.equal(state.answers.q1, undefined);
}); });
test("preview custom option reuses the normal inline editor", () => { test("preview custom option reuses the normal inline editor", () => {
+23
View File
@@ -634,6 +634,29 @@ test("elaborate continuation preserves unrelated committed answers", () => {
}); });
}); });
test("elaboration preserves recommendation metadata", () => {
let state = createInitialState({
questions: [
{
id: "framework",
label: "Framework",
prompt: "Pick a framework",
options: [{ value: "react", label: "React", recommended: true }],
},
],
});
state = enterOptionNoteMode(state, "framework", "react");
state = saveNote(state, "Explain this preference");
state = { ...state, mode: "elaborate" };
const item = toAskResult(state).elaboration?.items[0];
assert.equal(item?.question.options[0]?.recommended, true);
assert.equal(
item && "option" in item ? item.option.recommended : undefined,
true
);
});
test("invalid tool payload result is attributed to the tool call", () => { test("invalid tool payload result is attributed to the tool call", () => {
const result = { const result = {
title: "Interview", title: "Interview",
+22
View File
@@ -284,6 +284,28 @@ test("normalize treats blank optional presentation text as absent", () => {
}); });
}); });
test("normalization preserves any number of recommended options without selecting them", () => {
const state = createInitialState({
questions: [
{
id: "scope",
prompt: "What scope?",
options: [
{ value: "small", label: "Small", recommended: true },
{ value: "medium", label: "Medium", recommended: false },
{ value: "large", label: "Large", recommended: true },
],
},
],
});
assert.deepEqual(
state.questions[0].options.map((option) => option.recommended),
[true, false, true]
);
assert.deepEqual(state.answers, {});
});
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);