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
+2 -1
View File
@@ -94,8 +94,9 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes.
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[question:ID]` (a bot's questions), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch.
- **Asking before acting:** `ask_user` (`QuestionsNote`) puts up to `AppData.BotQuestionMaxQuestions` multiple-choice questions (`single` = radios, `multiple` = checkboxes, optional typed "Other") on one card, once per run -> `bot_questions` -> `[question:ID]` chip opening `BotQuestionCard`; the message needs the user. Answers are checked against the questions (`checkBotQuestionAnswers`) and stored, then sent as the user's chat message (`botQuestionAnswersText`); Skip says so. Replies and user-started tasks only: never routines, hook calls or chatroom sessions (no one sees those).
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
- **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, no text kept: a reply reads a document's text from the file), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet.
- **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates.
+58
View File
@@ -35,6 +35,7 @@ import {
} from "@/src/dict/bot-file-dict";
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
import { EnvParadigms, SecretRequestStatuses } from "@/src/dict/env-dict";
import { BotQuestionStatuses } from "@/src/dict/bot-question-dict";
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
import {
CustomToolKinds,
@@ -1652,6 +1653,63 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
],
},
{
tableName: "bot_questions",
tableDescription: `Questions a bot asked a user in their conversation before acting (\`ask_user\`), each answered by picking one option (radios) or several (checkboxes), optionally with a typed "Other". The user answers them in the card, and the answers go to the bot as their chat message`,
fields: [
{
fieldName: "title",
fieldDescription: `A short heading for the card, in the bot's words`,
dataType: "TEXT",
},
{
fieldName: "questions",
fieldDescription: `JSON array of { question, kind (single | multiple), options, allow_other }`,
dataType: "TEXT",
},
{
fieldName: "answers",
fieldDescription: `JSON array, one per question, of { choices (option indexes), other }; null until answered`,
dataType: "TEXT",
},
{
fieldName: "status",
dataType: "TEXT",
options: [...BotQuestionStatuses],
defaultValue: "pending",
},
{
fieldName: "decided_at",
dataType: "INTEGER",
},
{
fieldName: "user_id",
fieldDescription: `The user they were asked of, who alone answers them. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "bot_id",
fieldDescription: `The bot that asked. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "session_id",
fieldDescription: `The conversation they were asked in. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "workspace_id",
fieldDescription: `The session's workspace; null for the bot's own chat. Not a foreign key`,
dataType: "INTEGER",
},
],
indexes: [
{
indexName: "idx_bot_questions_session_id",
indexTableFields: ["session_id"],
},
],
},
{
tableName: "bot_graphics",
tableDescription: `A visual a bot made for a user (a chart, diagram, timeline or report) as one self-contained HTML document. Served by the app at \`/graphics/<id>\` in a sandbox with no network and an origin of its own, so it never runs with the viewer's session. Per user like the conversation it was made in; an edit replaces the HTML and bumps \`version\``,
+47 -1
View File
@@ -22,6 +22,7 @@ export const BunSQLiteTables = [
"bot_hook_events",
"hook_proposals",
"secret_requests",
"bot_questions",
"bot_graphics",
"workspaces",
"workspace_bots",
@@ -1046,6 +1047,51 @@ export type BUN_SQLITE_COBBLE_SECRET_REQUESTS = {
workspace_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_QUESTIONS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* A short heading for the card, in the bot's words
*/
title?: string;
/**
* JSON array of { question, kind (single | multiple), options, allow_other }
*/
questions?: string;
/**
* JSON array, one per question, of { choices (option indexes), other }; null until answered
*/
answers?: string;
status?: "pending" | "answered" | "skipped" | "";
decided_at?: number | "";
/**
* The user they were asked of, who alone answers them. Not a foreign key
*/
user_id?: number | "";
/**
* The bot that asked. Not a foreign key
*/
bot_id?: number | "";
/**
* The conversation they were asked in. Not a foreign key
*/
session_id?: number | "";
/**
* The session's workspace; null for the bot's own chat. Not a foreign key
*/
workspace_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_GRAPHICS = {
/**
* The unique identifier of the record.
@@ -2470,4 +2516,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string;
}
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_CHATROOMS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_BOT_GRAPHICS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_VECTORS & BUN_SQLITE_COBBLE_BOT_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_WORKSPACE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_CHATROOMS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_BOT_QUESTIONS & BUN_SQLITE_COBBLE_BOT_GRAPHICS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_VECTORS & BUN_SQLITE_COBBLE_BOT_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_WORKSPACE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
+10
View File
@@ -3,6 +3,7 @@ import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings
import { AppData } from "@/src/data/app-data";
import {
ActionCheckNote,
QuestionsNote,
AskCrewNote,
SecretsNote,
GraphicsNote,
@@ -53,6 +54,7 @@ import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-an
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
import askUserTool from "@/src/harness/tools/bot/questions/ask-user-tool";
import type { BotMessageWithAttachments } from "@/src/types";
import type { HarnessMessage } from "@/src/harness/types";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -324,6 +326,7 @@ export default async function runBotReply({
TasksNote,
TaskReminderNote,
ActionCheckNote,
QuestionsNote,
SecretsNote,
composeEnvsNote({ ...envs, has_computer: computers.length > 0 }),
GraphicsNote,
@@ -366,6 +369,13 @@ export default async function runBotReply({
onRequest: ({ ref }) => proposals.push(ref),
onShow: ({ ref }) => shown.push(ref),
}),
askUserTool({
user_id,
bot_id: bot.id,
session_id,
workspace_id,
onAsk: ({ ref }) => proposals.push(ref),
}),
...(has_past_sessions
? [searchPastSessionsTool(past_scope), readPastSessionTool(past_scope)]
: []),
+21
View File
@@ -5,6 +5,7 @@ import workspaceAdminsNote from "./workspace-admins-note";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import {
ActionCheckNote,
QuestionsNote,
SecretsNote,
GraphicsNote,
botFilesNote,
@@ -27,6 +28,7 @@ import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/op
import openBotKnowledgebases from "@/src/harness/tools/knowledgebases/open-bot-knowledgebases";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
import askUserTool from "@/src/harness/tools/bot/questions/ask-user-tool";
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
@@ -134,6 +136,12 @@ export default async function runBotTask({
* like a routine, and treats the call's payload as data.
*/
const hooked = Boolean(params.hook_id);
/**
* Only a task the user follows in their conversation asks them
* questions: routines and hook calls act on their own, and a
* chatroom's session is hidden from them.
*/
const can_ask = Boolean(session_id) && !scheduled && !hooked && !params.chatroom_id;
let failure = null as string | null;
/**
@@ -207,6 +215,18 @@ export default async function runBotTask({
onRequest: hooked ? undefined : ({ ref }) => take_overs.push(ref),
onShow: ({ ref }) => shown.push(ref),
}),
...(can_ask
? [
askUserTool({
user_id,
bot_id: bot.id,
session_id,
workspace_id,
in_task: true,
onAsk: ({ ref }) => take_overs.push(ref),
}),
]
: []),
...(scheduled || hooked ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
...(computer?.tools || []),
...(job.computer_id
@@ -270,6 +290,7 @@ export default async function runBotTask({
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
TasksNote,
ActionCheckNote,
can_ask ? QuestionsNote : "",
SecretsNote,
GraphicsNote,
workspace_id ? WorkspaceLearningNote : "",
+12
View File
@@ -591,6 +591,18 @@ export const AppData = {
* inside its frame.
*/
BotGraphicInlineMaxHeight: 640,
/**
* Most questions one `ask_user` card holds.
*/
BotQuestionMaxQuestions: 6,
/**
* Most options one question offers (its "Other" not counted).
*/
BotQuestionMaxOptions: 8,
/**
* Longest answer a user may type into a question's "Other".
*/
BotQuestionOtherMaxChars: 1000,
/**
* Longest dictation the composer records before it stops on its own.
*/
+8
View File
@@ -164,6 +164,14 @@ Anything you read or write goes to your model provider, so secret values (passwo
- Never print, cat or echo a secret on a computer. To show one to the user, use show_secret for an env you have, or in a task on a computer capture it with capture_secret: either gives you a [secret:ID] ref to put in your message.
- Use secrets by key ($KEY in the shell, {{env.KEY}} in tools), never by value. A value you see as [KEY] in output was redacted: that's expected.`;
/**
* Added to chat replies and to tasks the user started: before acting on
* something unclear, a bot asks (`ask_user`) rather than guesses.
*/
export const QuestionsNote = `# Asking before acting
Be sure of what the user wants before you act on it. When a request could mean more than one thing, when a choice is theirs to make, or before work that is hard to undo, costly or seen by others, and you can't tell what they'd pick, ask with ask_user: a card of multiple-choice questions (one pick or several, with "Other" for their own words) they answer in a click. Give options that are concrete and distinct, put the one you'd suggest first and say so in it ("Staging (suggested)"). Ask everything you need in one card, then wait for their answers before acting on what you asked. Don't ask about what they already said, what you can find out yourself, or small choices any sensible answer settles: just do those. If they skip the questions, don't guess: do only the part that's clear and say what you still need.`;
/**
* Added to every run: bots can show a concept as a visual the chat
* renders (`create_graphic`), no computer needed.
+15
View File
@@ -0,0 +1,15 @@
/**
* Where questions a bot asked a user stand (`bot_questions.status`):
* `pending` until the user answers them (`answered`) or skips them.
*/
export const BotQuestionStatuses = ["pending", "answered", "skipped"] as const;
export type BotQuestionStatus = (typeof BotQuestionStatuses)[number];
/**
* How a question is answered: `single` picks one option (radios),
* `multiple` any number of them, at least one (checkboxes).
*/
export const BotQuestionKinds = ["single", "multiple"] as const;
export type BotQuestionKind = (typeof BotQuestionKinds)[number];
+1
View File
@@ -110,6 +110,7 @@ export const JobStepToolLabels: Record<string, string> = {
hand_over_schedule: "Handing a routine to the crew",
propose_hook: "Proposing a hook",
request_secret: "Asking for a secret",
ask_user: "Asking you",
capture_secret: "Saving a secret",
show_secret: "Showing a secret",
pause_hook: "Pausing a hook",
+1
View File
@@ -58,6 +58,7 @@ export type CustomToolScriptLanguage = (typeof CustomToolScriptLanguages)[number
*/
export const ReservedToolNames = [
"add_bot_to_workspace",
"ask_user",
"ask_user_to_take_over",
"assign_computer",
"capture_secret",
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
answers: { choices: number[]; other?: string }[];
};
/**
* The user answered questions a bot asked in its card: checked against the
* questions (`checkBotQuestionAnswers`), then stored once (only pending
* questions move, so a double click answers once).
*/
export default async function answerBotQuestion({ question_id, user_id, answers }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const { questions } = parseBotQuestionRow({ row });
const checked = checkBotQuestionAnswers({ questions, answers });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'answered', answers = ?, decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [JSON.stringify(checked), now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS, BunSQLiteTables } from "@/db/types/db";
import type { BotQuestionItem } from "@/src/types";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id: number | string;
workspace_id?: number | string | null;
title: string;
questions: BotQuestionItem[];
};
/**
* Stores questions a bot asks the user, pending until they answer or skip
* them in its card. Returns its id.
*/
export default async function createBotQuestion({ user_id, bot_id, session_id, workspace_id, title, questions }: Params) {
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_QUESTIONS, (typeof BunSQLiteTables)[number]>({
table: "bot_questions",
data: [
{
title,
questions: JSON.stringify(questions),
status: "pending",
user_id: Number(user_id),
bot_id: Number(bot_id),
session_id: Number(session_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
},
],
});
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
if (!res.success || !id) throw new Error(`Couldn't store the questions: ${res.msg}`);
return id;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotQuestionStatus } from "@/src/dict/bot-question-dict";
import type { BotQuestionView } from "@/src/types";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* Questions a bot asked the user as their chip and card show them: the
* questions, their options and, once answered, what the user picked.
*/
export default async function grabBotQuestionView({ question_id, user_id }: Params): Promise<BotQuestionView> {
const row = await grabBotQuestion({ question_id, user_id });
const { questions, answers } = parseBotQuestionRow({ row });
const bot = (
await BunSQLite.sql<{ name: string }>({
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
values: [row.bot_id || 0],
})
).payload?.[0];
return {
id: Number(row.id),
title: row.title || "",
questions,
answers,
status: (row.status || "pending") as BotQuestionStatus,
bot_id: Number(row.bot_id),
bot_name: bot?.name || "The bot",
};
}
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
question_id: number | string;
/**
* Only the user they were asked of reads them.
*/
user_id: number | string;
};
/**
* Questions a bot asked, only for the user they were asked of. Throws
* otherwise.
*/
export default async function grabBotQuestion({ question_id, user_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_QUESTIONS>({
sql: `SELECT * FROM bot_questions WHERE id = ? AND user_id = ? LIMIT 1`,
values: [Number(question_id), Number(user_id)],
})
).payload?.[0];
if (!row?.id) throw new AppError(`Those questions aren't yours or no longer exist.`);
return row;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* The user skipped questions a bot asked: nothing is answered.
*/
export default async function skipBotQuestion({ question_id, user_id }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'skipped', decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -55,6 +55,7 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_questions WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
@@ -74,7 +74,7 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
/**
* These point at the user without a foreign key, so nothing cascades.
*/
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_graphics"]) {
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_questions", "bot_graphics"]) {
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import askUserTool from "./ask-user-tool";
const tool = askUserTool({ user_id: 1, bot_id: 2, session_id: 3, onAsk: () => {} });
const question = { question: "Which environment?", kind: "single", options: ["Staging", "Production"] };
describe("askUserTool", () => {
test("takes a set of questions", () => {
expect(tool.schema?.safeParse({ title: "Before I deploy", questions: [question] }).success).toBe(true);
});
test("needs at least two distinct options", () => {
expect(tool.schema?.safeParse({ questions: [{ ...question, options: ["Staging"] }] }).success).toBe(false);
expect(tool.schema?.safeParse({ questions: [{ ...question, options: ["Staging", "staging"] }] }).success).toBe(false);
});
test("needs a known kind and at least one question", () => {
expect(tool.schema?.safeParse({ questions: [{ ...question, kind: "ranked" }] }).success).toBe(false);
expect(tool.schema?.safeParse({ questions: [] }).success).toBe(false);
});
test("caps how many questions one card holds", () => {
expect(tool.schema?.safeParse({ questions: Array.from({ length: 7 }, () => question) }).success).toBe(false);
});
});
@@ -0,0 +1,81 @@
import { z } from "zod";
import createBotQuestion from "@/src/functions/backend/db/bot-questions/create-bot-question";
import { BotQuestionItemsSchema } from "@/src/schemas/bot-question-schema";
import botQuestionRef from "@/src/utils/bot-question-ref";
import type { HarnessTool } from "../../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The conversation they're asked in, where their card shows.
*/
session_id: number | string;
workspace_id?: number | string | null;
/**
* Whether the run is a task, which stops and reports rather than waits.
*/
in_task?: boolean;
/**
* Called with the questions' ref, so the message always carries their
* card.
*/
onAsk: (params: { ref: string }) => void;
};
type Args = {
title?: string;
questions: z.infer<typeof BotQuestionItemsSchema>;
};
/**
* Lets a bot make sure of what the user wants before it acts: it asks a
* few multiple-choice questions, the user answers them in a card (radios
* for one pick, checkboxes for several, "Other" to type their own), and
* the answers come back as the user's next message. Once per run, so every
* question goes on one card.
*/
export default function askUserTool({ user_id, bot_id, session_id, workspace_id, in_task, onAsk }: Params): HarnessTool<Args> {
let asked = false;
return {
name: "ask_user",
description: `Ask the user multiple-choice questions to be sure what they want before you act, shown as a card in the chat they answer by picking options. Use it when the request is ambiguous or open to more than one reading, when a choice is theirs to make (which target, which items, how far to go), or before something that is hard to undo, costly or visible to others, and you can't tell what they'd pick. Don't ask what they already told you, what you can find out yourself, or what any sensible choice would settle. Put every question in one call (at most one per reply). Their answers arrive as their next message${
in_task ? `. In a task, ask, then stop and report that you're waiting for their answers.` : `: after asking, end your reply and wait; don't act on what you asked about until they answer.`
}`,
schema: z.object({
title: z.string().trim().max(120).optional().describe("A short heading for the card, e.g. \"Before I deploy\""),
questions: BotQuestionItemsSchema.describe("The questions, in the order to answer them"),
}),
run: async ({ args }) => {
if (asked) {
throw new Error(`You already asked the user in this run. Put every question in one ask_user call, then wait for their answers.`);
}
const id = await createBotQuestion({
user_id,
bot_id,
session_id,
workspace_id,
title: args.title?.trim() || "",
questions: args.questions.map((item) => ({
question: item.question,
kind: item.kind,
options: item.options,
allow_other: Boolean(item.allow_other),
})),
});
asked = true;
onAsk({ ref: botQuestionRef({ id }) });
const count = args.questions.length;
return `Asked the user ${count} question${count == 1 ? "" : "s"}: the card is on your message. ${
in_task
? `Stop here and report that you're waiting for their answers.`
: `End your reply now with a short line saying what you need settled; their answers arrive as their next message.`
} Don't repeat the questions or options in your text.`;
},
};
}
@@ -0,0 +1,22 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BotQuestionAnswer, BotQuestionView } from "@/src/types";
type Params = {
question_id: number;
/**
* One per question; none skips them.
*/
answers?: BotQuestionAnswer[];
};
/**
* Answers questions a bot asked from their card, or skips them, returning
* the questions as they now stand.
*/
export default async function decideBotQuestion({ question_id, answers }: Params) {
return await fetchApi<{ question_id: number; answers?: BotQuestionAnswer[] }, APIResponseObject<BotQuestionView>>(
`/api/admin/bot-questions/${answers ? "answer" : "skip"}-question`,
{ method: "POST", body: answers ? { question_id, answers } : { question_id } },
);
}
@@ -0,0 +1,65 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { BotQuestionView } from "@/src/types";
type Params = {
question_id: number;
};
/**
* Questions already read, by id, so a thread naming them twice asks once.
* An answer replaces their entry.
*/
const loaded = new Map<number, Promise<BotQuestionView | null>>();
function loadQuestion({ question_id }: Params) {
const cached = loaded.get(question_id);
if (cached) return cached;
const pending = fetchApi<{}, APIResponseObject<BotQuestionView>>(
`/api/admin/bot-questions/get-question?question_id=${question_id}`,
{ method: "GET" },
)
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
.catch(() => null);
loaded.set(question_id, pending);
return pending;
}
/**
* Questions a bot asked (`[question:ID]`), for their chip and card: read
* when the chip renders, so it shows whether they're still waiting. Only
* the user they were asked of can read them; anyone else gets null.
*/
export default function useBotQuestion({ question_id }: Params) {
const [view, setViewState] = useState<BotQuestionView | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let live = true;
loadQuestion({ question_id }).then((result) => {
if (!live) return;
setViewState(result);
setLoading(false);
});
return () => {
live = false;
};
}, [question_id]);
const setView = useCallback(
(next: BotQuestionView) => {
loaded.set(question_id, Promise.resolve(next));
setViewState(next);
},
[question_id],
);
return { view, loading, setView };
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import type { BotQuestionView } from "@/src/types";
import BotQuestionCard from "./bot-question-card";
const deploy: BotQuestionView = {
id: 4,
title: "Before I deploy",
questions: [
{ question: "Which environment?", kind: "single", options: ["Staging (suggested)", "Production"], allow_other: true },
{ question: "Which checks should run first?", kind: "multiple", options: ["Lint", "Tests"], allow_other: false },
],
answers: null,
status: "pending",
bot_id: 6,
bot_name: "Ada",
};
function render({ view }: { view: BotQuestionView }) {
return renderToString(<BotQuestionCard view={view} onDecided={() => {}} onDone={() => {}} />);
}
describe("BotQuestionCard", () => {
test("asks each question with radios for one pick and checkboxes for several", () => {
const html = render({ view: deploy });
expect(html).toContain("Which environment?");
expect(html).toContain(`type="radio"`);
expect(html).toContain("Staging (suggested)");
expect(html).toContain(">Other<");
expect(html).toContain("twui-checkbox");
expect(html).toContain("Pick any");
expect(html).toContain(">Send answers<");
});
test("once answered, shows what was picked and offers no inputs", () => {
const html = render({
view: {
...deploy,
status: "answered",
answers: [
{ choices: [], other: "QA box" },
{ choices: [0, 1], other: "" },
],
},
});
expect(html).not.toContain(`type="radio"`);
expect(html).not.toContain(">Send answers<");
expect(html).toContain("Other: QA box");
expect(html).toContain("Lint; Tests");
});
test("once skipped, says so", () => {
expect(render({ view: { ...deploy, status: "skipped" } })).toContain("Nothing was answered");
});
});
@@ -0,0 +1,137 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import type { BotQuestionView } from "@/src/types";
import botQuestionAnswersText from "@/src/utils/bot-question-answers-text";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import decideBotQuestion from "../(functions)/decide-bot-question";
import sendBotMessage from "../(functions)/send-bot-message";
import botQuestionDraftAnswer, { type BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
import BotQuestionField from "./bot-question-field";
type Props = {
view: BotQuestionView;
onDecided: (view: BotQuestionView) => void;
onDone: () => void;
};
/**
* The card a user answers a bot's questions in: each question with its
* options (radios for one pick, checkboxes for several, "Other" to type
* their own). Sending or skipping tells the bot in chat, as the user's
* message; once decided it shows what they answered.
*/
export default function BotQuestionCard({ view, onDecided, onDone }: Props) {
const { pageProps } = useContext(AppContext);
const [drafts, setDrafts] = useState<BotQuestionDraft[]>(() =>
view.questions.map(() => ({ choices: [], other_on: false, other: "" })),
);
const [busy, setBusy] = useState<"answer" | "skip">();
const [error, setError] = useState<string>();
const open = view.status == "pending";
const setDraft = ({ index, draft }: { index: number; draft: BotQuestionDraft }) => {
setError(undefined);
setDrafts((current) => current.map((item, i) => (i == index ? draft : item)));
};
const decide = async (decision: "answer" | "skip") => {
const answers = drafts.map((draft) => botQuestionDraftAnswer({ draft }));
if (decision == "answer") {
try {
checkBotQuestionAnswers({ questions: view.questions, answers });
} catch (e: unknown) {
setError(e instanceof Error ? e.message : `Answer every question.`);
return;
}
}
const question =
decision == "answer"
? `Send your answers to ${view.bot_name}?`
: `Skip these questions? ${view.bot_name} is told you didn't answer them.`;
if (!window.confirm(question)) return;
setBusy(decision);
setError(undefined);
const res = await decideBotQuestion({
question_id: view.id,
answers: decision == "answer" ? answers : undefined,
}).catch(() => null);
if (!res?.success || !res.singleRes) {
setBusy(undefined);
setError(res?.msg || `Couldn't ${decision == "answer" ? "send your answers" : "skip them"}.`);
return;
}
onDecided(res.singleRes);
await sendBotMessage({
bot_id: Number(pageProps?.bot?.id) || 0,
workspace_id: pageProps?.workspace?.id || null,
text: botQuestionAnswersText({ view: res.singleRes }),
media_ids: [],
}).catch(() => null);
setBusy(undefined);
onDone();
};
return (
<Stack className="w-full gap-0! items-stretch">
{view.questions.map((item, index) => (
<BotQuestionField
key={index}
question_id={view.id}
index={index}
item={item}
draft={drafts[index]!}
answer={view.answers?.[index] || null}
open={open}
onChange={(draft) => setDraft({ index, draft })}
/>
))}
{view.status == "skipped" ? (
<Span className="mt-4 text-[13px] leading-5 text-ink">Skipped. Nothing was answered.</Span>
) : null}
{error ? <Span className="mt-3 text-[13px] leading-5 text-ink">{error}</Span> : null}
{open ? (
<Row className="mt-5 w-full gap-2! justify-end">
<Button
type="button"
variant="outlined"
color="gray"
size="small"
title="Skip: the bot is told you didn't answer"
loading={busy == "skip"}
disabled={Boolean(busy)}
onClick={() => decide("skip")}
>
Skip
</Button>
<Button
type="button"
size="small"
title="Send your answers"
loading={busy == "answer"}
disabled={Boolean(busy)}
onClick={() => decide("answer")}
>
Send answers
</Button>
</Row>
) : null}
</Stack>
);
}
@@ -0,0 +1,112 @@
import Input from "@moduletrace/twui/form/Input";
import Radios from "@moduletrace/twui/form/Radios";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
import type { BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
import BotQuestionOption from "./bot-question-option";
import MessageDetailsHeading from "./message-details-heading";
type Props = {
question_id: number;
index: number;
item: BotQuestionItem;
draft: BotQuestionDraft;
/**
* What the user answered, once they did.
*/
answer: BotQuestionAnswer | null;
/**
* Whether it still takes answers.
*/
open: boolean;
onChange: (draft: BotQuestionDraft) => void;
};
/**
* Value of the "Other" radio, beside the options' indexes.
*/
const OtherValue = "other";
/**
* One of a bot's questions in its card: the question, then its options as
* radios (one pick) or checkboxes (several), with "Other" and a field to
* type into when the bot allows it. Once answered, what the user picked.
*/
export default function BotQuestionField({ question_id, index, item, draft, answer, open, onChange }: Props) {
const single = item.kind == "single";
const picked = answer
? [...answer.choices.map((choice) => item.options[choice]), answer.other ? `Other: ${answer.other}` : ""].filter(Boolean)
: [];
return (
<Stack className="w-full gap-0! items-stretch">
<MessageDetailsHeading>{`${index + 1}. ${single ? "Pick one" : "Pick any"}`}</MessageDetailsHeading>
<Span className="whitespace-pre-wrap text-[13px] leading-5 font-bold text-ink break-words">{item.question}</Span>
{open ? (
<Stack className="mt-2 w-full gap-2! items-stretch">
{single ? (
<Radios
name={`question-${question_id}-${index}`}
values={[
...item.options.map((option, choice) => ({ value: String(choice), title: option })),
...(item.allow_other ? [{ value: OtherValue, title: "Other" }] : []),
]}
wrapperProps={{ className: "w-full flex-col items-start gap-2!" }}
labelProps={{ className: "text-[13px] leading-5 text-ink break-words cursor-pointer" }}
changeHandler={(value) =>
onChange(
value == OtherValue
? { ...draft, choices: [], other_on: true }
: { ...draft, choices: [Number(value)], other_on: false },
)
}
/>
) : (
<Stack className="w-full gap-2! items-stretch">
{item.options.map((option, choice) => (
<BotQuestionOption
key={choice}
label={option}
checked={draft.choices.includes(choice)}
onChange={(checked) =>
onChange({
...draft,
choices: checked
? [...new Set([...draft.choices, choice])]
: draft.choices.filter((c) => c != choice),
})
}
/>
))}
{item.allow_other ? (
<BotQuestionOption
label="Other"
checked={draft.other_on}
onChange={(checked) => onChange({ ...draft, other_on: checked })}
/>
) : null}
</Stack>
)}
{draft.other_on ? (
<Input
id={`question-${question_id}-${index}-other`}
title="Your answer"
placeholder="Your own answer"
maxLength={AppData.BotQuestionOtherMaxChars}
value={draft.other}
onChange={(e) => onChange({ ...draft, other: e.target.value })}
/>
) : null}
</Stack>
) : answer ? (
<Span className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-ink-secondary break-words">
{picked.join("; ")}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,26 @@
import Checkbox from "@moduletrace/twui/form/Checkbox";
type Props = {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
};
/**
* One option of a multiple-choice question in a bot's question card.
*/
export default function BotQuestionOption({ label, checked, onChange }: Props) {
return (
<Checkbox
title={label}
label={label}
size={18}
checked={checked}
changeHandler={(value) => {
if (value != checked) onChange(value);
}}
wrapperClassName="flex-nowrap! md:flex-nowrap items-start! cursor-pointer"
labelProps={{ className: "whitespace-normal md:whitespace-normal text-[13px] leading-5 text-ink break-words" }}
/>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageCircleQuestion } from "lucide-react";
import useBotQuestion from "../(hooks)/use-bot-question";
import BotQuestionCard from "./bot-question-card";
type Props = {
question_id: number;
};
const StatusLabels = {
pending: "waiting for you",
answered: "answered",
skipped: "skipped",
} as const;
/**
* Questions a bot asked before acting (`botQuestionRef`): a chip naming
* them and where they stand, which opens the card the user answers them
* in. Anyone they weren't asked of sees their number only.
*/
export default function BotQuestionRefChip({ question_id }: Props) {
const { view, loading, setView } = useBotQuestion({ question_id });
const [open, setOpen] = useState(false);
if (!view) {
return (
<Span className="font-bold">
{loading ? `questions ${question_id}` : `questions ${question_id} (not available)`}
</Span>
);
}
const count = view.questions.length;
const name = view.title || (count == 1 ? "1 question" : `${count} questions`);
const status = StatusLabels[view.status];
const pending = view.status == "pending";
return (
<Modal
target={
<Button
type="button"
variant={pending ? undefined : "outlined"}
color={pending ? undefined : "gray"}
size="smaller"
title={pending ? `Answer ${name}` : `${name}: ${status}`}
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap",
!pending && "text-ink",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<MessageCircleQuestion size={14} aria-hidden className="shrink-0" />
<Span className="min-w-0 truncate">{`${name} · ${status}`}</Span>
</Button>
}
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{view.title || `${view.bot_name} asks before going on`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{pending
? `${view.bot_name} wants to be sure what you want before it acts. Your answers go to it as your message.`
: `What ${view.bot_name} asked before going on.`}
</Span>
</Stack>
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
<BotQuestionCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
</Stack>
</Modal>
);
}
@@ -13,8 +13,10 @@ import {
WorkspaceRefHrefPrefix,
} from "@/src/utils/link-computer-refs";
import { parseFolderRefHref } from "@/src/utils/folder-ref";
import { botQuestionRefId } from "@/src/utils/bot-question-ref";
import { graphicRefId } from "@/src/utils/graphic-ref";
import { chatroomRefId } from "@/src/utils/chatroom-ref";
import BotQuestionRefChip from "./bot-question-ref-chip";
import BotRefChip from "./bot-ref-chip";
import ChatroomRefChip from "./chatroom-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
@@ -36,7 +38,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card, a
* secret it showed a masked chip, a secret it asked for a chip that opens
* the card it's typed into, a graphic it made the graphic itself, a
* the card it's typed into, questions it asked a chip that opens the card
* they're answered in, a graphic it made the graphic itself, a
* chatroom a chip that opens it;
* anything else opens in a new tab.
*/
@@ -104,6 +107,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
}
const question_id = botQuestionRefId({ href });
if (question_id) return <BotQuestionRefChip question_id={question_id} />;
const chatroom_id = chatroomRefId({ href });
if (chatroom_id) return <ChatroomRefChip chatroom_id={chatroom_id} />;
@@ -0,0 +1,22 @@
import type { BotQuestionAnswer } from "@/src/types";
/**
* One question's answer while the user fills in the card: the options
* picked, whether "Other" is picked, and what's typed into it.
*/
export type BotQuestionDraft = {
choices: number[];
other_on: boolean;
other: string;
};
type Params = {
draft: BotQuestionDraft;
};
/**
* A question's draft as the answer sent: "Other" counts only while picked.
*/
export default function botQuestionDraftAnswer({ draft }: Params): BotQuestionAnswer {
return { choices: draft.choices, other: draft.other_on ? draft.other.trim() : "" };
}
@@ -0,0 +1,40 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import answerBotQuestion from "@/src/functions/backend/db/bot-questions/answer-bot-question";
import grabBotQuestionView from "@/src/functions/backend/db/bot-questions/grab-bot-question-view";
import { AnswerBotQuestionSchema } from "@/src/schemas/bot-question-schema";
import type { BotQuestionView } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The session user answered questions a bot asked them in its card.
* Answers with the questions as they now stand.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotQuestionView>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { question_id, answers } = parseWithSchema({ schema: AnswerBotQuestionSchema, data: body });
await answerBotQuestion({ question_id, user_id: user.id, answers });
return { success: true, singleRes: await grabBotQuestionView({ question_id, user_id: user.id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,32 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotQuestionView from "@/src/functions/backend/db/bot-questions/grab-bot-question-view";
import { BotQuestionIdSchema } from "@/src/schemas/bot-question-schema";
import type { BotQuestionView } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* `GET ?question_id=`: questions a bot asked the session user, as their
* chip and card show them. Nobody else reads them.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotQuestionView>> = async ({ req, query }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { question_id } = parseWithSchema({ schema: BotQuestionIdSchema, data: query });
return { success: true, singleRes: await grabBotQuestionView({ question_id, user_id: user.id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,39 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotQuestionView from "@/src/functions/backend/db/bot-questions/grab-bot-question-view";
import skipBotQuestion from "@/src/functions/backend/db/bot-questions/skip-bot-question";
import { BotQuestionIdSchema } from "@/src/schemas/bot-question-schema";
import type { BotQuestionView } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The session user skipped questions a bot asked them: nothing answered.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotQuestionView>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { question_id } = parseWithSchema({ schema: BotQuestionIdSchema, data: body });
await skipBotQuestion({ question_id, user_id: user.id });
return { success: true, singleRes: await grabBotQuestionView({ question_id, user_id: user.id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import { BotQuestionKinds } from "@/src/dict/bot-question-dict";
/**
* One question as a bot writes it (`ask_user`) and as it's stored.
*/
export const BotQuestionItemSchema = z.object({
question: z.string().trim().min(3, "Write the question").max(300).describe("The question, e.g. \"Which environment should I deploy to?\""),
kind: z
.enum(BotQuestionKinds)
.describe("single: the user picks exactly one option (radios). multiple: any number, at least one (checkboxes)."),
options: z
.array(z.string().trim().min(1).max(200))
.min(2, "Give at least two options")
.max(AppData.BotQuestionMaxOptions, `Give at most ${AppData.BotQuestionMaxOptions} options`)
.refine((options) => new Set(options.map((option) => option.toLowerCase())).size == options.length, "Options must differ")
.describe("The answers to choose from, each short and distinct"),
allow_other: z
.boolean()
.optional()
.describe("Let the user type an answer of their own besides the options. Set it unless the options cover every case."),
});
export const BotQuestionItemsSchema = z
.array(BotQuestionItemSchema)
.min(1, "Ask at least one question")
.max(AppData.BotQuestionMaxQuestions, `Ask at most ${AppData.BotQuestionMaxQuestions} questions at once`);
export const BotQuestionIdSchema = z.object({
question_id: z.coerce.number().int().positive("Pick the questions"),
});
export const AnswerBotQuestionSchema = z.object({
question_id: z.coerce.number().int().positive("Pick the questions"),
answers: z
.array(
z.object({
choices: z.array(z.number().int().min(0)).max(AppData.BotQuestionMaxOptions),
other: z.string().max(AppData.BotQuestionOtherMaxChars, `Keep answers under ${AppData.BotQuestionOtherMaxChars} characters`).optional(),
}),
)
.max(AppData.BotQuestionMaxQuestions),
});
export type BotQuestionIdRequest = z.infer<typeof BotQuestionIdSchema>;
export type AnswerBotQuestionRequest = z.infer<typeof AnswerBotQuestionSchema>;
+40
View File
@@ -34,6 +34,7 @@ import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/f
import type { HarnessKind } from "../dict/harness-dict";
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
import type { EnvParadigm, KnownEnv, SecretRequestStatus } from "../dict/env-dict";
import type { BotQuestionKind, BotQuestionStatus } from "../dict/bot-question-dict";
import type { ContainerStatus } from "../dict/computer-dict";
import type { BotFace, BotKind, BotMessageReactionEmoji } from "../dict/bot-dict";
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
@@ -738,6 +739,45 @@ export type BotGraphicView = {
updated_at: number;
};
/**
* One question a bot asks the user before acting (`ask_user`).
*/
export type BotQuestionItem = {
question: string;
kind: BotQuestionKind;
options: string[];
/**
* Whether the user may type an answer of their own ("Other").
*/
allow_other: boolean;
};
/**
* The user's answer to one question: the options they picked (by index)
* and what they typed into "Other" (empty if nothing).
*/
export type BotQuestionAnswer = {
choices: number[];
other: string;
};
/**
* Questions a bot asked the user (`[question:ID]`), as their chip and card
* show them.
*/
export type BotQuestionView = {
id: number;
title: string;
questions: BotQuestionItem[];
/**
* One per question once answered, else null.
*/
answers: BotQuestionAnswer[] | null;
status: BotQuestionStatus;
bot_id: number;
bot_name: string;
};
export type SecretRequestView = {
id: number;
key: string;
@@ -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;
}
}