Let bots ask multiple-choice questions before acting

ask_user puts up to six questions on one card (radios for one pick,
checkboxes for several, an optional typed Other) as a [question:ID] chip.
The user's answers are checked against the questions, stored in
bot_questions and sent to the bot as their chat message; Skip says so.
Offered in chat replies and user-started tasks, never in routines, hook
calls or chatroom sessions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 11:10:23 +01:00
co-authored by Claude Opus 5.5
parent 07f87ce4ea
commit d7dfe0e78e
45 changed files with 1448 additions and 6 deletions
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import type { BotQuestionView } from "@/src/types";
import botQuestionAnswersText from "./bot-question-answers-text";
const view: BotQuestionView = {
id: 4,
title: "Before I deploy",
questions: [
{ question: "Which environment?", kind: "single", options: ["Staging", "Production"], allow_other: false },
{ question: "Which checks?", kind: "multiple", options: ["Lint", "Tests", "Build"], allow_other: true },
],
answers: [
{ choices: [0], other: "" },
{ choices: [0, 2], other: "e2e" },
],
status: "answered",
bot_id: 2,
bot_name: "Ember",
};
describe("botQuestionAnswersText", () => {
test("names each question with what was picked", () => {
expect(botQuestionAnswersText({ view })).toBe(
[
"My answers (questions #4):",
"- **Which environment?** Staging",
"- **Which checks?** Lint; Build; other: e2e",
].join("\n"),
);
});
test("says when the questions were skipped", () => {
expect(botQuestionAnswersText({ view: { ...view, status: "skipped", answers: null } })).toBe(
"I skipped your questions (questions #4) without answering them.",
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { BotQuestionView } from "@/src/types";
type Params = {
view: BotQuestionView;
};
/**
* What the chat tells the bot once the user answered (or skipped) its
* questions, so it carries on from their answers: each question with the
* options picked and anything typed into "Other".
*/
export default function botQuestionAnswersText({ view }: Params) {
if (view.status == "skipped" || !view.answers) {
return `I skipped your questions (questions #${view.id}) without answering them.`;
}
const lines = view.questions.map((item, index) => {
const answer = view.answers?.[index];
const picked = (answer?.choices || []).map((choice) => item.options[choice]).filter(Boolean);
const other = answer?.other ? `other: ${answer.other}` : "";
return `- **${item.question}** ${[...picked, other].filter(Boolean).join("; ")}`;
});
return [`My answers (questions #${view.id}):`, ...lines].join("\n");
}
+28
View File
@@ -0,0 +1,28 @@
type Params = {
id: number | string;
};
/**
* How a bot names questions it asked the user: `[question:4]`. The chat
* turns it into a link (`linkComputerRefs`) that renders as a chip opening
* the card the user answers them in.
*/
export default function botQuestionRef({ id }: Params) {
return `[question:${id}]`;
}
/**
* The link target a question ref becomes, e.g. `#question-4`.
*/
export const BotQuestionRefHrefPrefix = "#question-";
/**
* The id in a question ref's link target, or null.
*/
export function botQuestionRefId({ href }: { href?: string | null }) {
if (!href?.startsWith(BotQuestionRefHrefPrefix)) return null;
const id = Number(href.slice(BotQuestionRefHrefPrefix.length));
return Number.isInteger(id) && id > 0 ? id : null;
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import type { BotQuestionItem } from "@/src/types";
import checkBotQuestionAnswers from "./check-bot-question-answers";
const questions: BotQuestionItem[] = [
{ question: "Which environment?", kind: "single", options: ["Staging", "Production"], allow_other: false },
{ question: "Which checks?", kind: "multiple", options: ["Lint", "Tests", "Build"], allow_other: true },
];
describe("checkBotQuestionAnswers", () => {
test("tidies valid answers", () => {
expect(
checkBotQuestionAnswers({
questions,
answers: [{ choices: [1] }, { choices: [2, 0, 2], other: " e2e " }],
}),
).toEqual([
{ choices: [1], other: "" },
{ choices: [0, 2], other: "e2e" },
]);
});
test("takes Other alone where it's allowed", () => {
expect(checkBotQuestionAnswers({ questions, answers: [{ choices: [0] }, { choices: [], other: "None" }] })[1]).toEqual({
choices: [],
other: "None",
});
});
test("needs one answer per question", () => {
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [0] }] })).toThrow("Answer every question.");
});
test("needs every question answered", () => {
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [0] }, { choices: [], other: " " }] })).toThrow(
"Answer question 2.",
);
});
test("takes one pick for a single-choice question", () => {
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [0, 1] }, { choices: [0] }] })).toThrow(
"Question 1 takes one answer.",
);
});
test("refuses options that don't exist", () => {
expect(() => checkBotQuestionAnswers({ questions, answers: [{ choices: [2] }, { choices: [0] }] })).toThrow(
"Question 1 has an option that doesn't exist.",
);
});
test("refuses Other where it isn't allowed", () => {
expect(() =>
checkBotQuestionAnswers({ questions, answers: [{ choices: [], other: "QA" }, { choices: [0] }] }),
).toThrow("Question 1 only takes its options.");
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
import AppError from "./app-error";
type Params = {
questions: BotQuestionItem[];
answers: { choices: number[]; other?: string }[];
};
/**
* The user's answers checked against the questions they answer: one per
* question, only options that exist, "Other" only where it's allowed,
* exactly one pick for a single-choice question and at least one for a
* multiple-choice one. Returns them tidied (choices sorted and unique,
* "Other" trimmed); throws an `AppError` naming the first question that
* isn't answered right.
*/
export default function checkBotQuestionAnswers({ questions, answers }: Params): BotQuestionAnswer[] {
if (answers.length != questions.length) {
throw new AppError(`Answer every question.`);
}
return questions.map((item, index) => {
const answer = answers[index]!;
const choices = [...new Set(answer.choices)].sort((a, b) => a - b);
const other = (answer.other || "").trim();
const label = `Question ${index + 1}`;
if (choices.some((choice) => !Number.isInteger(choice) || choice < 0 || choice >= item.options.length)) {
throw new AppError(`${label} has an option that doesn't exist.`);
}
if (other && !item.allow_other) {
throw new AppError(`${label} only takes its options.`);
}
const picked = choices.length + (other ? 1 : 0);
if (!picked) throw new AppError(`Answer ${label.toLowerCase()}.`);
if (item.kind == "single" && picked > 1) {
throw new AppError(`${label} takes one answer.`);
}
return { choices, other };
});
}
+4
View File
@@ -20,6 +20,10 @@ describe("exportMessageText", () => {
);
});
test("words questions by number", () => {
expect(exportMessageText({ text: "Pick: [question:4]", ...names, heading_offset: 0 })).toBe("Pick: (questions 4)");
});
test("words graphics by number", () => {
expect(exportMessageText({ text: "See [graphic:5]", ...names, heading_offset: 0 })).toBe("See (graphic 5)");
});
+1
View File
@@ -85,6 +85,7 @@ function wordRefs({
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
.replace(/\[secret:(\d+)\](?!\()/g, "(secret $1, hidden)")
.replace(/\[secret-request:(\d+)\](?!\()/g, "(secret request $1)")
.replace(/\[question:(\d+)\](?!\()/g, "(questions $1)")
.replace(/\[graphic:(\d+)\](?!\()/g, "(graphic $1)")
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
+8
View File
@@ -7,6 +7,7 @@ import hookProposalRef from "./hook-proposal-ref";
import proposalRef from "./proposal-ref";
import secretRef from "./secret-ref";
import secretRequestRef from "./secret-request-ref";
import botQuestionRef from "./bot-question-ref";
import takeOverRef from "./take-over-ref";
describe("linkComputerRefs", () => {
@@ -42,6 +43,13 @@ describe("linkComputerRefs", () => {
).toBe("[secret 12](#secret-ref-12) and [secret request 4](#secret-request-4)");
});
test("links questions a bot asked, also when quoted alone in code", () => {
expect(linkComputerRefs({ text: `Before I start: ${botQuestionRef({ id: 4 })}` })).toBe(
"Before I start: [questions 4](#question-4)",
);
expect(linkComputerRefs({ text: "See `[question:4]`" })).toBe("See [questions 4](#question-4)");
});
test("links take-over requests", () => {
expect(
linkComputerRefs({
+1 -1
View File
@@ -69,7 +69,7 @@ export const SecretRequestRefHrefPrefix = "#secret-request-";
/**
* The kinds of ref a message can carry, as written before the colon.
*/
const RefKinds = "computer|take-over|bot|workspace|proposal|hook-proposal|secret|secret-request|graphic|room|file|folder";
const RefKinds = "computer|take-over|bot|workspace|proposal|hook-proposal|secret|secret-request|question|graphic|room|file|folder";
/**
* A code span holding nothing but one ref (`` `[file:12]` ``): bots often
+6 -1
View File
@@ -1,3 +1,4 @@
import { BotQuestionRefHrefPrefix } from "./bot-question-ref";
import { ChatroomRefHrefPrefix } from "./chatroom-ref";
import { GraphicRefHrefPrefix } from "./graphic-ref";
import {
@@ -19,7 +20,7 @@ import {
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
* request ref (`[secret-request:4]`), graphic ref (`[graphic:5]`) and
* request ref (`[secret-request:4]`), question ref (`[question:4]`), graphic ref (`[graphic:5]`) and
* chatroom ref (`[room:<id>]`) in a
* stretch of message text (never code) into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button,
@@ -57,6 +58,10 @@ export default function linkRefsInText({ text }: { text: string }) {
/\[secret-request:(\d+)\](?!\()/g,
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
)
.replace(
/\[question:(\d+)\](?!\()/g,
(_, id: string) => `[questions ${id}](${BotQuestionRefHrefPrefix}${id})`,
)
.replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`)
.replace(/\[room:(\d+)\](?!\()/g, (_, id: string) => `[room ${id}](${ChatroomRefHrefPrefix}${id})`)
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import parseBotQuestionRow from "./parse-bot-question-row";
const questions = [{ question: "Which?", kind: "single", options: ["A", "B"], allow_other: false }];
describe("parseBotQuestionRow", () => {
test("reads questions and answers", () => {
expect(
parseBotQuestionRow({
row: { questions: JSON.stringify(questions), answers: JSON.stringify([{ choices: [1], other: "" }]) },
}),
).toEqual({ questions: questions as never, answers: [{ choices: [1], other: "" }] });
});
test("has no answers until answered, or when they don't match", () => {
expect(parseBotQuestionRow({ row: { questions: JSON.stringify(questions), answers: null } }).answers).toBeNull();
expect(parseBotQuestionRow({ row: { questions: JSON.stringify(questions), answers: "[]" } }).answers).toBeNull();
});
test("survives unreadable JSON", () => {
expect(parseBotQuestionRow({ row: { questions: "{", answers: "{" } })).toEqual({ questions: [], answers: null });
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
type Params = {
row: { questions?: string | null; answers?: string | null };
};
/**
* A `bot_questions` row's stored JSON read back: its questions (none if
* unreadable) and its answers (null until answered, or when they don't
* match the questions).
*/
export default function parseBotQuestionRow({ row }: Params): {
questions: BotQuestionItem[];
answers: BotQuestionAnswer[] | null;
} {
const questions = parseJson<BotQuestionItem[]>({ text: row.questions });
const answers = parseJson<BotQuestionAnswer[]>({ text: row.answers });
const final_questions = Array.isArray(questions) ? questions : [];
return {
questions: final_questions,
answers: Array.isArray(answers) && answers.length == final_questions.length ? answers : null,
};
}
function parseJson<T>({ text }: { text?: string | null }): T | null {
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}