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
+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?.length, 1);
assert.equal(
(context.systemPrompt ?? "").includes("Never add recommendation metadata."),
true
);
const prompt = JSON.stringify(context.messages[0]?.content);
assert.equal(prompt.includes("Choose an implementation direction."), true);
assert.equal(prompt.includes("Which direction: A or B?"), true);
+1 -1
View File
@@ -8,7 +8,7 @@ const params: AskParams = {
{
id: "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 test from "node:test";
import { validateToolArguments } from "@earendil-works/pi-ai/base";
import { Value } from "typebox/value";
import { registerAskTool } from "../src/ask-tool.ts";
import { AskOptionSchema } from "../src/schema.ts";
import type { AskParams } from "../src/types.ts";
const NON_INTERACTIVE_MESSAGE_RE =
@@ -41,6 +43,7 @@ function registerMockTool() {
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,
@@ -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 () => {
const { entries, tool } = registerMockTool();
const params = sampleParams();
+4 -1
View File
@@ -152,7 +152,7 @@ test("started event questions are cloned", async () => {
{
id: "decision",
prompt: "Proceed?",
options: [{ value: "yes", label: "Yes" }],
options: [{ value: "yes", label: "Yes", recommended: true }],
},
],
}).questions;
@@ -167,9 +167,12 @@ test("started event questions are cloned", async () => {
const started = bus.events.find(
(event) => event.channel === PI_ASK_STARTED_EVENT
)?.data as RemoteAskStartedEvent;
assert.equal(started.questions[0].options[0].recommended, true);
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].recommended, true);
});
test("remote runtime disposes active flows and submit listener", () => {
+75 -6
View File
@@ -9,13 +9,14 @@ import {
} from "../src/state/transitions.ts";
import { renderQuestionScreen } from "../src/ui/render-question.ts";
function mockTheme() {
function mockTheme(onColor?: (color: string, text: string) => void) {
return {
fg(color: string, text: string) {
return `<${color}>${text}</${color}>`;
onColor?.(color, text);
return onColor ? text : `<${color}>${text}</${color}>`;
},
bg(color: string, text: string) {
return `{${color}}${text}{/${color}}`;
return onColor ? text : `{${color}}${text}{/${color}}`;
},
bold(text: string) {
return text;
@@ -60,6 +61,56 @@ test("custom option stays labeled before selection", () => {
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", () => {
let state = createInitialState({
questions: [
@@ -230,30 +281,48 @@ test("freeform-only question renders label without numbering or pointer and sepa
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({
questions: [
{
id: "q1",
prompt: "Pick one",
type: "preview",
options: [{ value: "a", label: "A", preview: "Preview A" }],
options: [
{
value: "a",
label: "A",
preview: "Preview A",
recommended: true,
},
],
},
],
});
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(),
theme: mockTheme((color, text) => calls.push({ color, text })),
width: 80,
});
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", () => {
+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", () => {
const result = {
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", () => {
let state = createInitialState(sampleParams());
state = applyNumberShortcut(state, 2);