feat: mark recommended ask options
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -8,7 +8,7 @@ const params: AskParams = {
|
||||
{
|
||||
id: "goal",
|
||||
prompt: "Goal?",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
options: [{ value: "a", label: "A", recommended: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user