diff --git a/CLAUDE.md b/CLAUDE.md index 786222c1..9b6a17cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon - Código, identificadores, comentários, commits e README em **inglês**. Docs em `docs/` podem ser em português. - Arquivos com no máximo **~300 linhas**. Passou disso, divida por responsabilidade. - Erros: `thiserror` nos crates de biblioteca, `anyhow` só no binário. -- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`). Tempo em ms Unix. +- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`, `qst_`). Tempo em ms Unix. - Tudo que é específico de Windows fica em `crates/botloftd/src/platform/`. O resto do daemon não chama Win32 direto. - A UI depende só da interface `BotloftApi` (`app/src/lib/api.ts`). Testes de componente usam `FakeBotloft`. - Nenhum texto de interface escrito direto no componente: tudo vai em `app/src/i18n/{en,pt-BR,es}/<área>.ts` e é lido com `useT()` (ou `t()` fora do React). Texto novo entra nos três idiomas no mesmo commit; o TypeScript acusa o que faltar (spec 15.6). Palavras simples, sem "daemon" nem outro jargão. diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index f2c64292..6a89508c 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -16,6 +16,7 @@ import { seedChief } from "./seedChief"; import { seedContext } from "./seedContext"; import { seedHands } from "./seedHands"; import { activeTab, drawPage } from "./seedPage"; +import { seedQuestions } from "./seedQuestions"; import { seedRoutines } from "./seedRoutines"; import { seedScreens } from "./seedScreens"; @@ -123,6 +124,7 @@ function seed(fake: FakeBotloft): void { planner: planner.id, }); seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); + seedQuestions(fake, { scout: scout.id, analyst: analyst.id }); seedBrowser(fake, scout.id); // Analyst hit the limit in the middle of a search: its browser rests. fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats"); diff --git a/app/src/dev/seedQuestions.ts b/app/src/dev/seedQuestions.ts new file mode 100644 index 00000000..9959d5b2 --- /dev/null +++ b/app/src/dev/seedQuestions.ts @@ -0,0 +1,24 @@ +// Dev only: questions the bots left for the owner overnight (spec 23). + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId } from "../lib/protocol.gen"; + +const HOUR = 60 * 60 * 1000; + +export function seedQuestions(fake: FakeBotloft, crew: { scout: BotId; analyst: BotId }): void { + const now = Date.now(); + fake.now = now - 7 * HOUR; + fake.chat.tool(crew.analyst, "mcp__botloft__ask_owner", { status: "done" }); + fake.questions.ask( + crew.analyst, + "Two sources disagree on last year's **flour prices** (12% and 19% up). Which one should the report trust?\n\nThe 19% figure comes from a smaller survey.", + ); + fake.now = now - 20 * 60 * 1000; + fake.chat.tool(crew.scout, "mcp__botloft__ask_owner", { status: "done" }); + fake.questions.ask( + crew.scout, + "Should next week's summary cover only papers, or blog posts too?", + ["Only papers", "Papers and blog posts", "Skip next week"], + ); + fake.now = now; +} diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 453e2310..5e844c27 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -9,6 +9,7 @@ import { duration, tokens, usedTokens, when } from "../../lib/format"; import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen"; import { useArrival } from "../../ui/motion"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; +import { QuestionCard } from "../questions/QuestionCard"; import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; import { runParts, splitDraft } from "./rows"; @@ -140,6 +141,12 @@ function BotRunView({ ); + case "question": + return ( + + + + ); case "turn": return ( diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index bf007d81..f08716a4 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -39,6 +39,7 @@ const inbound = (body: string, at?: number) => body, taskId: null, routineId: null, + questionId: null, attachments: [], createdAt: at ?? start, }, diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index 5d3e9efa..32cebe7f 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -13,6 +13,14 @@ export type Row = | { kind: "run"; key: string; items: ChatItem[] } | { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem }; +/** The bot asking the owner (spec 23.2): its question card says it all. */ +const ASK_TOOL = "mcp__botloft__ask_owner"; + +/** A call to ask the owner that worked; the card stands for it. */ +function isAskLine(item: ChatItem): boolean { + return item.body.kind === "tool" && item.body.name === ASK_TOOL && item.body.status !== "failed"; +} + function dayOf(ms: number): string { return new Date(ms).toDateString(); } @@ -22,6 +30,9 @@ export function chatRows(items: ChatItem[]): Row[] { let day: string | null = null; let run: ChatItem[] | null = null; for (const item of items) { + if (isAskLine(item)) { + continue; + } const today = dayOf(item.createdAt); if (today !== day) { day = today; diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 89240959..23455007 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -1,4 +1,11 @@ -import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react"; +import { + ChevronDown, + ChevronsDownUp, + ChevronsUpDown, + MessageCircleQuestion, + Pause, + Plus, +} from "lucide-react"; import { memo, useCallback, useState } from "react"; import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; @@ -7,6 +14,7 @@ import { when } from "../../lib/format"; import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { activityOf, botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; +import { openQuestions } from "../../store/questions"; import { isUnread, unreadIn } from "../../store/seen"; import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; @@ -25,7 +33,7 @@ export function Sidebar() { const t = useT(); const words = t.crews.sidebar; const crews = useApp(useShallow(crewList)); - const overview = useApp((state) => state.selectedCrewId === null); + const overview = useApp((state) => state.selectedCrewId === null && !state.questionBox); const selectCrew = useApp((state) => state.selectCrew); const collapsed = useCollapsedSet(); const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id)); @@ -60,6 +68,7 @@ export function Sidebar() { onClick={() => setCreating(true)} /> + {crews.map((crew) => ( @@ -75,6 +84,39 @@ export function Sidebar() { const row = "relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken"; +/** The question box (spec 23.6), with how many questions wait. */ +function QuestionsEntry() { + const t = useT(); + const words = t.questions.box; + const count = useApp((state) => openQuestions(state).length); + const open = useApp((state) => state.questionBox); + const openBox = useApp((state) => state.openQuestionBox); + return ( + + + 0 ? "text-warn" : "text-muted"} + /> + {words.label} + {count > 0 && ( + + {count} + {words.open(count)} + + )} + + + ); +} + function CrewEntry({ crew }: { crew: Crew }) { const t = useT(); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); @@ -218,6 +260,8 @@ function activityText(activity: Activity, t: Messages): string { return t.crews.sidebar.fromOwner(text); case "approval": return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text); + case "question": + return t.questions.activity(text); case "tool": { if (!tool) { return text; diff --git a/app/src/features/questions/QuestionBox.tsx b/app/src/features/questions/QuestionBox.tsx new file mode 100644 index 00000000..5633e38c --- /dev/null +++ b/app/src/features/questions/QuestionBox.tsx @@ -0,0 +1,93 @@ +// The question box (spec 23.6): every question the bots asked and the owner +// has not answered, newest first, each with its card to answer right here. + +import { MessageCircleQuestion, MessageSquare } from "lucide-react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import { AVATAR_PALETTE, type Question } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; +import { openQuestions } from "../../store/questions"; +import { Button } from "../../ui/Button"; +import { BotAvatar } from "../bots/BotAvatar"; +import { QuestionCard } from "./QuestionCard"; + +export function QuestionBox() { + const t = useT(); + const words = t.questions.box; + const questions = useApp(openQuestions); + return ( + + + {words.title} + {words.intro} + + + {questions.length === 0 ? ( + + ) : ( + + {questions.map((question) => ( + + ))} + + )} + + + ); +} + +function Entry({ question }: { question: Question }) { + const t = useT(); + const words = t.questions.box; + const bot = useApp((state) => state.bots[question.botId]); + const crew = useApp((state) => state.crews[question.crewId]); + const selectBot = useApp((state) => state.selectBot); + if (!bot) { + return null; + } + return ( + + + + + {bot.name} + {crew && {words.inCrew(crew.name)}} + + {when(question.createdAt)} + + selectBot(bot.id)} + /> + + + + + ); +} + +function Empty() { + const t = useT(); + const words = t.questions.box; + return ( + + + + + + {words.emptyTitle} + {words.emptyBody} + + ); +} diff --git a/app/src/features/questions/QuestionCard.tsx b/app/src/features/questions/QuestionCard.tsx new file mode 100644 index 00000000..c065d6c2 --- /dev/null +++ b/app/src/features/questions/QuestionCard.tsx @@ -0,0 +1,156 @@ +// A question a bot asked the owner (spec 23.6): the question, ready answers +// as buttons and a field for any other answer. The same card is in the +// bot's chat and in the question box. Once closed it shrinks to one line. + +import { Check, MessageCircleQuestion, Reply, X } from "lucide-react"; +import { useId, useState } from "react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import type { Question } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; +import { Markdown } from "../chat/Markdown"; + +export function QuestionCard({ question, bot }: { question: Question; bot: string }) { + if (question.status !== "open") { + return ; + } + return ; +} + +function OpenQuestion({ question, bot }: { question: Question; bot: string }) { + const t = useT(); + const q = t.questions.card; + const api = useApi(); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const fieldId = useId(); + + const answer = async (value: string) => { + setBusy(true); + const sent = await attempt(q.answerFailed, () => + api.call("questions.answer", { questionId: question.id, answer: value }), + ); + setBusy(false); + if (sent) { + setText(""); + } + }; + const dismiss = async () => { + setBusy(true); + await attempt(q.dismissFailed, () => + api.call("questions.dismiss", { questionId: question.id }), + ); + setBusy(false); + }; + + return ( + + + + + + {q.asks(bot)} + + + + {question.options.length > 0 && ( + + {question.options.map((option) => ( + answer(option)}> + {option} + + ))} + + )} + + { + event.preventDefault(); + if (text.trim()) { + void answer(text); + } + }} + > + + {q.answerLabel(bot)} + + setText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey && text.trim()) { + event.preventDefault(); + void answer(text); + } + }} + placeholder={q.placeholder} + className="block w-full resize-none rounded-xl border border-line-strong bg-panel px-3 py-2 text-sm outline-none placeholder:text-muted focus:border-muted" + /> + + + {q.send} + + + {q.dismiss} + + + + + ); +} + +/** One line: what the owner answered, or that they dismissed it. */ +function Closed({ question }: { question: Question }) { + const t = useT(); + const q = t.questions.card; + const answered = question.status === "answered"; + return ( + + + {answered ? ( + + ) : ( + + )} + {answered ? q.answered : q.dismissed} + {answered && question.answer && ( + {`“${question.answer}”`} + )} + {question.answeredAt !== null && ( + + {when(question.answeredAt)} + + )} + + + + {answered && question.answer && ( + + {question.answer} + + )} + + + ); +} diff --git a/app/src/features/questions/questions.test.tsx b/app/src/features/questions/questions.test.tsx new file mode 100644 index 00000000..7199a088 --- /dev/null +++ b/app/src/features/questions/questions.test.tsx @@ -0,0 +1,107 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with @scout and @writer. */ +function crew() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const writer = fake.addBot(ops.id, "Writer", "Writes drafts"); + fake.setBotState(scout.id, "idle"); + fake.setBotState(writer.id, "idle"); + return { fake, scout, writer }; +} + +const entry = () => within(sidebar()).getByRole("button", { name: /^Questions/ }); + +describe("questions to the owner", () => { + test("a question in the chat is answered with a ready answer", async () => { + const { fake, scout } = crew(); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const chat = await screen.findByRole("list", { name: "Messages" }); + + act(() => { + fake.chat.tool(scout.id, "mcp__botloft__ask_owner", { status: "done" }); + fake.questions.ask(scout.id, "Which **client** first?", ["Acme", "Globex"]); + }); + const card = within(chat).getByRole("region", { name: "Scout asks" }); + expect(within(card).getByText("client").tagName).toBe("STRONG"); + // The card stands for the call; no tool line repeats it. + expect(within(chat).queryByText("Ask you a question")).toBeNull(); + expect(within(sidebar()).getByText("Question: Which **client** first?")).toBeDefined(); + + fireEvent.click(within(card).getByRole("button", { name: "Globex" })); + await within(chat).findByText("You answered"); + expect(fake.calls.at(-1)).toEqual({ + method: "questions.answer", + params: { questionId: fake.questions.asked[0]?.question.id, answer: "Globex" }, + }); + const sent = fake.conversation.messages.at(-1); + expect(sent?.body).toBe("Globex"); + expect(sent?.questionId).toBe(fake.questions.asked[0]?.question.id); + expect(within(chat).queryByRole("region", { name: "Scout asks" })).toBeNull(); + }); + + test("the box gathers open questions, counts them and marks the taskbar", async () => { + const { fake, scout, writer } = crew(); + const { host } = renderApp(fake); + await crewOpened("Ops"); + expect(entry().textContent).toBe("Questions"); + expect(host.attention).toBe(false); + + act(() => { + fake.questions.ask(scout.id, "Which client first?"); + fake.now += 1000; + fake.questions.ask(writer.id, "Formal or casual?", ["Formal", "Casual"]); + }); + expect(entry().getAttribute("title")).toBe("2 questions wait for you"); + await waitFor(() => expect(host.attention).toBe(true)); + + fireEvent.click(entry()); + const box = await screen.findByRole("region", { name: "Questions" }); + expect( + screen.getByRole("heading", { level: 1, name: "Questions from your bots" }), + ).toBeDefined(); + const cards = within(box).getAllByRole("region", { name: /asks$/ }); + expect(cards.map((card) => card.getAttribute("aria-label"))).toEqual([ + "Writer asks", + "Scout asks", + ]); + + const field = within(box).getByLabelText("Your answer to Scout"); + fireEvent.change(field, { target: { value: "Acme, then Globex" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => + expect(within(box).queryByRole("region", { name: "Scout asks" })).toBeNull(), + ); + expect(fake.conversation.messages.at(-1)?.body).toBe("Acme, then Globex"); + expect(entry().getAttribute("title")).toBe("1 question waits for you"); + + fireEvent.click(within(box).getByRole("button", { name: "Dismiss" })); + await within(box).findByText("No question is waiting for you"); + expect(fake.questions.asked[1]?.question.status).toBe("dismissed"); + expect(fake.conversation.messages).toHaveLength(1); + await waitFor(() => expect(host.attention).toBe(false)); + }); + + test("a question in the box leads to its bot's chat", async () => { + const { fake, scout } = crew(); + renderApp(fake); + await crewOpened("Ops"); + act(() => { + fake.questions.ask(scout.id, "Ship on Friday?"); + }); + fireEvent.click(entry()); + const box = await screen.findByRole("region", { name: "Questions" }); + fireEvent.click(within(box).getByRole("button", { name: "Open the chat with Scout" })); + const chat = await screen.findByRole("list", { name: "Messages" }); + expect(within(chat).getByRole("region", { name: "Scout asks" })).toBeDefined(); + expect(screen.queryByRole("region", { name: "Questions" })).toBeNull(); + }); +}); diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index 5d268479..419bc339 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -10,6 +10,7 @@ import { crews } from "./crews"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; import { setup } from "./setup"; @@ -30,6 +31,7 @@ export const en = { crews, files, messages, + questions, routines, screens, setup, diff --git a/app/src/i18n/en/questions.ts b/app/src/i18n/en/questions.ts new file mode 100644 index 00000000..a682c85f --- /dev/null +++ b/app/src/i18n/en/questions.ts @@ -0,0 +1,40 @@ +// Questions the bots ask the owner (spec 23): the card in a bot's chat and +// the question box on the left. What the bots ask is data and never passes +// through here. + +export const questions = { + card: { + asks: (bot: string) => `${bot} asks`, + pick: "Pick an answer", + answerLabel: (bot: string) => `Your answer to ${bot}`, + placeholder: "Write your answer", + send: "Answer", + dismiss: "Dismiss", + dismissHint: (bot: string) => + `Closes the question without telling ${bot}. To let it know, answer instead.`, + answered: "You answered", + dismissed: "Dismissed without an answer", + answerFailed: "Could not send the answer", + dismissFailed: "Could not dismiss the question", + }, + box: { + label: "Questions", + /** The entry on the left, with how many questions wait. */ + open: (count: number) => + count === 0 + ? "Questions from your bots" + : count === 1 + ? "1 question waits for you" + : `${count} questions wait for you`, + title: "Questions from your bots", + intro: + "When a bot needs your decision to go on, it asks here and carries on. Your answer goes to it as a message.", + emptyTitle: "No question is waiting for you", + emptyBody: + "Bots ask here when they need you, also at night, in a routine. Answer whenever you can.", + openChat: (bot: string) => `Open the chat with ${bot}`, + inCrew: (crew: string) => `in ${crew}`, + }, + /** The conversation-list line for a question the bot asked. */ + activity: (text: string) => `Question: ${text}`, +}; diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index 26e03557..97a6e568 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -34,6 +34,7 @@ export const tools = { suggest_bot: "Suggest a bot", schedule_routine: "Set up a routine", share_file: "Share a file", + ask_owner: "Ask you a question", browser: "Use a site", browser_help: "Ask for your hand", browser_open: "Open a page", diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index b28d4461..867c0417 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -11,6 +11,7 @@ import { crews } from "./crews"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; import { setup } from "./setup"; @@ -31,6 +32,7 @@ export const es: Messages = { crews, files, messages, + questions, routines, screens, setup, diff --git a/app/src/i18n/es/questions.ts b/app/src/i18n/es/questions.ts new file mode 100644 index 00000000..48e3ed8f --- /dev/null +++ b/app/src/i18n/es/questions.ts @@ -0,0 +1,38 @@ +// Preguntas de los bots al dueño (spec 23). + +import type { Messages } from "../en"; + +export const questions: Messages["questions"] = { + card: { + asks: (bot: string) => `${bot} pregunta`, + pick: "Elige una respuesta", + answerLabel: (bot: string) => `Tu respuesta para ${bot}`, + placeholder: "Escribe tu respuesta", + send: "Responder", + dismiss: "Descartar", + dismissHint: (bot: string) => + `Cierra la pregunta sin avisar a ${bot}. Para que lo sepa, responde.`, + answered: "Respondiste", + dismissed: "Descartada sin respuesta", + answerFailed: "No se pudo enviar la respuesta", + dismissFailed: "No se pudo descartar la pregunta", + }, + box: { + label: "Preguntas", + open: (count: number) => + count === 0 + ? "Preguntas de tus bots" + : count === 1 + ? "1 pregunta te espera" + : `${count} preguntas te esperan`, + title: "Preguntas de tus bots", + intro: + "Cuando un bot necesita una decisión tuya para seguir, pregunta aquí y sigue trabajando. Tu respuesta le llega como mensaje.", + emptyTitle: "Ninguna pregunta te espera", + emptyBody: + "Los bots preguntan aquí cuando te necesitan, incluso de madrugada, en una rutina. Responde cuando puedas.", + openChat: (bot: string) => `Abrir el chat con ${bot}`, + inCrew: (crew: string) => `en ${crew}`, + }, + activity: (text: string) => `Pregunta: ${text}`, +}; diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index 04388b93..c562d89d 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -35,6 +35,7 @@ export const tools: Messages["tools"] = { suggest_bot: "Sugerir un bot", schedule_routine: "Crear una rutina", share_file: "Compartir un archivo", + ask_owner: "Hacerte una pregunta", browser: "Usar un sitio", browser_help: "Pedir tu ayuda", browser_open: "Abrir una página", diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index 09eb9136..de05b61c 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -11,6 +11,7 @@ import { crews } from "./crews"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; import { setup } from "./setup"; @@ -31,6 +32,7 @@ export const ptBR: Messages = { crews, files, messages, + questions, routines, screens, setup, diff --git a/app/src/i18n/pt-BR/questions.ts b/app/src/i18n/pt-BR/questions.ts new file mode 100644 index 00000000..b486c0ec --- /dev/null +++ b/app/src/i18n/pt-BR/questions.ts @@ -0,0 +1,37 @@ +// Perguntas dos bots ao dono (spec 23). + +import type { Messages } from "../en"; + +export const questions: Messages["questions"] = { + card: { + asks: (bot: string) => `${bot} pergunta`, + pick: "Escolha uma resposta", + answerLabel: (bot: string) => `Sua resposta para ${bot}`, + placeholder: "Escreva sua resposta", + send: "Responder", + dismiss: "Descartar", + dismissHint: (bot: string) => `Fecha a pergunta sem avisar ${bot}. Para ele saber, responda.`, + answered: "Você respondeu", + dismissed: "Descartada sem resposta", + answerFailed: "Não foi possível enviar a resposta", + dismissFailed: "Não foi possível descartar a pergunta", + }, + box: { + label: "Perguntas", + open: (count: number) => + count === 0 + ? "Perguntas dos seus bots" + : count === 1 + ? "1 pergunta espera você" + : `${count} perguntas esperam você`, + title: "Perguntas dos seus bots", + intro: + "Quando um bot precisa de uma decisão sua para continuar, ele pergunta aqui e segue o trabalho. Sua resposta chega a ele como mensagem.", + emptyTitle: "Nenhuma pergunta esperando você", + emptyBody: + "Os bots perguntam aqui quando precisam de você, até de madrugada, numa rotina. Responda quando puder.", + openChat: (bot: string) => `Abrir a conversa com ${bot}`, + inCrew: (crew: string) => `em ${crew}`, + }, + activity: (text: string) => `Pergunta: ${text}`, +}; diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index 9ce0f8c7..c77f1552 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -34,6 +34,7 @@ export const tools: Messages["tools"] = { suggest_bot: "Sugerir um bot", schedule_routine: "Criar uma rotina", share_file: "Compartilhar um arquivo", + ask_owner: "Fazer uma pergunta a você", browser: "Usar um site", browser_help: "Pedir sua ajuda", browser_open: "Abrir uma página", diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index ce265fd7..e08bd848 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -10,6 +10,7 @@ import { FakeChat } from "./fakeChat"; import { FakeConversation } from "./fakeConversation"; import { crewHandlers } from "./fakeCrews"; import { FakeFiles } from "./fakeFiles"; +import { FakeQuestions } from "./fakeQuestions"; import { FakeRoutines } from "./fakeRoutines"; import { conflict, invalid, notFound, slugify } from "./fakeRules"; import { FakeScreens } from "./fakeScreens"; @@ -74,6 +75,7 @@ export class FakeBotloft implements BotloftApi { readonly files = new FakeFiles(this); readonly browser = new FakeBrowser(this); readonly screens = new FakeScreens(this); + readonly questions = new FakeQuestions(this); /** Clock for created and updated times. */ now = Date.now(); closed = false; @@ -257,6 +259,7 @@ export class FakeBotloft implements BotloftApi { ...this.browser.handlers(), ...this.screens.handlers(), ...this.routines.handlers(), + ...this.questions.handlers(), ...usageHandlers(this), }; } diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index fea077e3..3e7d2c22 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -60,6 +60,10 @@ export function activityLine( text = body.explanation ?? body.summary; tool = body.toolName; break; + case "question": + kind = "question"; + text = body.question.text; + break; case "notice": // A compaction is housekeeping, not news about the conversation. if ( @@ -266,7 +270,8 @@ export class FakeChat { }; } - private update(id: string, body: ChatBody): ChatItem { + /** Saves a new body for an item; the conversation line stays. */ + update(id: string, body: ChatBody): ChatItem { const item = this.items.find((entry) => entry.id === id); if (!item) { throw notFound(`chat item ${id}`); diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts index 4f4e72a5..0655826c 100644 --- a/app/src/lib/fakeConversation.ts +++ b/app/src/lib/fakeConversation.ts @@ -53,6 +53,7 @@ export class FakeConversation { body: options.body, taskId: options.taskId ?? null, routineId: null, + questionId: null, attachments: [], createdAt: this.fake.now, }; @@ -129,6 +130,7 @@ export class FakeConversation { body: body.trim(), taskId: null, routineId: null, + questionId: null, attachments: files.map((file) => this.saved(file)), createdAt: this.fake.now, }; @@ -179,7 +181,8 @@ export class FakeConversation { }; } - private record(message: Message): Delivery { + /** Saves a message for the bot with its delivery and chat item. */ + record(message: Message): Delivery { this.messages.push(message); const delivery: Delivery = { id: this.fake.id("dlv"), diff --git a/app/src/lib/fakeQuestions.ts b/app/src/lib/fakeQuestions.ts new file mode 100644 index 00000000..d8a9ff9e --- /dev/null +++ b/app/src/lib/fakeQuestions.ts @@ -0,0 +1,99 @@ +// The fake daemon's questions to the owner (spec 23): `ask` plays a bot +// calling `ask_owner`, and `questions.*` answer and dismiss them like the +// daemon does, the answer going to the bot as the owner's message. + +import type { FakeBotloft, Handlers } from "./fake"; +import { conflict, invalid, notFound } from "./fakeRules"; +import type { BotId, ChatItem, Question } from "./protocol.gen"; + +type QuestionMethods = Extract; + +export class FakeQuestions { + /** Every question, oldest first, with the chat item that shows it. */ + readonly asked: { question: Question; itemId: string }[] = []; + + constructor(private readonly fake: FakeBotloft) {} + + /** The bot asks the owner something; the app hears of it. */ + ask(botId: BotId, text: string, options: string[] = []): Question { + const bot = this.fake.bot(botId); + const question: Question = { + id: this.fake.id("qst"), + crewId: bot.crewId, + botId, + text, + options, + status: "open", + answer: null, + createdAt: this.fake.now, + answeredAt: null, + }; + const item: ChatItem = this.fake.chat.add(botId, { kind: "question", question }); + this.asked.push({ question, itemId: item.id }); + this.fake.emit({ name: "question.changed", params: question }); + return question; + } + + private open(questionId: string): { question: Question; itemId: string } { + const found = this.asked.find((entry) => entry.question.id === questionId); + if (!found) { + throw notFound(`question ${questionId}`); + } + if (found.question.status !== "open") { + throw conflict(`question ${questionId} was already answered or dismissed`); + } + this.fake.bot(found.question.botId); + return found; + } + + private close(entry: { question: Question; itemId: string }, changes: Partial) { + const question: Question = { ...entry.question, ...changes, answeredAt: this.fake.now }; + entry.question = question; + this.fake.chat.update(entry.itemId, { kind: "question", question }); + this.fake.emit({ name: "question.changed", params: question }); + return question; + } + + handlers(): Pick { + return { + "questions.list": ({ status }) => { + const wanted = status ?? "open"; + return this.asked + .map((entry) => entry.question) + .filter((question) => { + const bot = this.fake.bots.get(question.botId); + const crew = this.fake.crews.get(question.crewId); + return ( + question.status === wanted && bot?.archivedAt === null && crew?.archivedAt === null + ); + }) + .reverse(); + }, + "questions.answer": ({ questionId, answer }) => { + const body = answer.trim(); + if (!body) { + throw invalid("answer must not be empty"); + } + const entry = this.open(questionId); + const question = this.close(entry, { status: "answered", answer: body }); + this.fake.conversation.record({ + id: this.fake.id("msg"), + crewId: question.crewId, + fromKind: "owner", + fromBotId: null, + toBotId: question.botId, + kind: "note", + body, + taskId: null, + routineId: null, + questionId: question.id, + attachments: [], + createdAt: this.fake.now, + }); + return question; + }, + "questions.dismiss": ({ questionId }) => + this.close(this.open(questionId), { status: "dismissed" }), + }; + } +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 5f5dcc3d..1e342679 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -70,6 +70,11 @@ export type RoutineRunId = string; */ export type RuleId = string; +/** + * Identifies a question a bot asked the owner (spec 23). + */ +export type QuestionId = string; + /** * A group of bots that can message each other and share a folder. */ @@ -121,7 +126,7 @@ export type BotModel = "default" | "fable" | "opus" | "sonnet" | "haiku"; /** * What the conversation-list line shows; the app words it. */ -export type ActivityKind = "owner" | "message" | "reply" | "tool" | "approval" | "notice"; +export type ActivityKind = "owner" | "message" | "reply" | "tool" | "approval" | "question" | "notice"; /** * The last thing in a bot's chat, for the conversation list. @@ -607,6 +612,10 @@ taskId: TaskId | null, * The routine that sent it (spec 20.5). */ routineId: RoutineId | null, +/** + * The bot's question this message answers (spec 23.4). + */ +questionId: QuestionId | null, /** * Files the owner attached (spec 9.5); empty for everything else. */ @@ -853,6 +862,45 @@ archived: boolean, */ turns: number, tokens: TokenUsage, }; +export type QuestionStatus = "open" | "answered" | "dismissed"; + +export type Question = { id: QuestionId, crewId: CrewId, botId: BotId, +/** + * What the bot asks, markdown. + */ +text: string, +/** + * Ready answers the owner may pick; empty for a free answer only. + */ +options: Array, status: QuestionStatus, +/** + * What the owner answered; `null` unless `answered`. + */ +answer: string | null, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * Unix time in milliseconds the owner answered or dismissed it. + */ +answeredAt: number | null, }; + +export type QuestionItem = { question: Question, }; + +/** + * Newest first. + */ +export type QuestionsListParams = { +/** + * `open` when absent. + */ +status?: QuestionStatus, }; + +export type QuestionsAnswerParams = { questionId: QuestionId, answer: string, }; + +export type QuestionIdParams = { questionId: QuestionId, }; + export type TurnItem = { durationMs: number, /** * Tokens the turn used (spec 8.7); `null` when Claude Code did not say @@ -877,7 +925,7 @@ text: string, }; /** * What a chat item holds, by `kind`. */ -export type ChatBody = { "kind": "inbound" } & InboundItem | { "kind": "reply" } & ReplyItem | { "kind": "tool" } & ToolItem | { "kind": "approval" } & ApprovalItem | { "kind": "turn" } & TurnItem | { "kind": "notice" } & NoticeItem; +export type ChatBody = { "kind": "inbound" } & InboundItem | { "kind": "reply" } & ReplyItem | { "kind": "tool" } & ToolItem | { "kind": "approval" } & ApprovalItem | { "kind": "question" } & QuestionItem | { "kind": "turn" } & TurnItem | { "kind": "notice" } & NoticeItem; /** * One entry of a bot's chat. @@ -1301,6 +1349,9 @@ export interface RpcMethods { "routines.runNow": { params: RoutineIdParams; result: RoutineRun }; "routines.archive": { params: RoutineIdParams; result: Routine }; "routines.runs": { params: RoutinesRunsParams; result: Array }; + "questions.list": { params: QuestionsListParams; result: Array }; + "questions.answer": { params: QuestionsAnswerParams; result: Question }; + "questions.dismiss": { params: QuestionIdParams; result: Question }; "browser.list": { params: undefined; result: Array }; "browser.watch": { params: BrowserWatchParams; result: BrowserView }; "browser.unwatch": { params: undefined; result: null }; @@ -1331,6 +1382,7 @@ export interface RpcNotifications { "bot.context": BotContextChanged; "routine.changed": Routine; "routine.run": RoutineRun; + "question.changed": Question; "bot.rules": BotRules; "browser.changed": BrowserState; "browser.action": BrowserAction; diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index d7d580ed..98c76a24 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -12,6 +12,7 @@ import { FailedDeliveries } from "../features/messages/FailedDeliveries"; import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; import { SignInButton } from "../features/onboarding/SignIn"; import { Welcome } from "../features/onboarding/Welcome"; +import { QuestionBox } from "../features/questions/QuestionBox"; import { UpdateButton } from "../features/updates/UpdateButton"; import { useT } from "../i18n"; import { useApp } from "../store/context"; @@ -107,8 +108,12 @@ export function Workspace() { } function Selection() { + const box = useApp((state) => state.questionBox); const crew = useApp((state) => (state.selectedCrewId ? state.crews[state.selectedCrewId] : null)); const bot = useApp((state) => (state.selectedBotId ? state.bots[state.selectedBotId] : null)); + if (box) { + return ; + } if (crew && bot) { return ; } @@ -123,6 +128,15 @@ function Breadcrumb() { const crew = useApp((state) => (state.selectedCrewId ? state.crews[state.selectedCrewId] : null)); const bot = useApp((state) => (state.selectedBotId ? state.bots[state.selectedBotId] : null)); const selectCrew = useApp((state) => state.selectCrew); + const box = useApp((state) => state.questionBox); + if (box) { + return ( + <> + + {t.questions.box.label} + > + ); + } if (!crew) { // Only shown with crews: the overview is where "no crew" leads. return ( diff --git a/app/src/shell/attention.ts b/app/src/shell/attention.ts index 5a02d12a..b604840c 100644 --- a/app/src/shell/attention.ts +++ b/app/src/shell/attention.ts @@ -5,12 +5,14 @@ import { useEffect } from "react"; import { actionableDead } from "../features/messages/FailedDeliveries"; import type { AppState } from "../store/app"; import { useApp, useHost } from "../store/context"; +import { openQuestions } from "../store/questions"; import { anyUnread, unseenFailures } from "../store/seen"; import { prefs, usePref } from "./prefs"; /** - * Bots waiting for an answer or a sign-in, messages that gave up, and - * routine runs that failed since the owner last opened their bot. + * Bots waiting for an answer or a sign-in, messages that gave up, routine + * runs that failed since the owner last opened their bot, and questions + * the bots asked (spec 23.6). */ export function attentionCount(state: AppState): number { // Asked on every change to the store; the answer changes only with these. @@ -20,6 +22,7 @@ export function attentionCount(state: AppState): number { state.routines, state.seenAt, state.selectedBotId, + state.questions, ] as const; if (last?.inputs.every((input, index) => input === inputs[index])) { return last.count; @@ -27,7 +30,11 @@ export function attentionCount(state: AppState): number { const bots = Object.values(state.bots).filter( (bot) => bot.state === "needs_approval" || bot.state === "auth_error", ).length; - const count = bots + actionableDead(state).length + unseenFailures(state).length; + const count = + bots + + actionableDead(state).length + + unseenFailures(state).length + + openQuestions(state).length; last = { inputs, count }; return count; } diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 5a630018..59f3c669 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -13,6 +13,8 @@ import type { CrewId, Delivery, MessageId, + Question, + QuestionId, Routine, RoutineId, Settings, @@ -21,6 +23,7 @@ import type { TaskId, } from "../lib/protocol.gen"; import { viewTransition } from "../ui/motion"; +import { withQuestion } from "./questions"; import { loadMarked, loadSeen, loadSeenSince, seenActions, withReply } from "./seen"; /** A panel beside a bot's chat (spec 15.1). */ @@ -52,6 +55,10 @@ export interface AppState { routines: Record; /** The bots' browsers that are not closed (spec 21.7), then every change. */ browsers: Record; + /** Questions waiting for the owner (spec 23.5), then every change. */ + questions: Record; + /** The question box is open in the middle, instead of a crew or bot. */ + questionBox: boolean; /** * When the owner last looked at each bot's chat (`seen.ts`), for unread * replies and failed routine runs. @@ -72,6 +79,7 @@ export interface AppState { selectedBotId: BotId | null; selectCrew(crewId: CrewId | null): void; selectBot(botId: BotId): void; + openQuestionBox(): void; setPanel(botId: BotId, panel: BotPanel | null): void; /** The owner is looking at the bot's chat: what it said so far is seen. */ markSeen(botId: BotId): void; @@ -136,6 +144,8 @@ export function createAppStore(api: BotloftApi): AppStore { tasks: {}, routines: {}, browsers: {}, + questions: {}, + questionBox: false, seenAt: loadSeen(), seenSince: loadSeenSince(), replyAt: {}, @@ -144,16 +154,25 @@ export function createAppStore(api: BotloftApi): AppStore { selectedCrewId: null, selectedBotId: null, selectCrew: (crewId) => { - const { selectedCrewId, selectedBotId } = get(); - if (selectedCrewId !== crewId || selectedBotId !== null) { - viewTransition(() => set({ selectedCrewId: crewId, selectedBotId: null })); + const { selectedCrewId, selectedBotId, questionBox } = get(); + if (selectedCrewId !== crewId || selectedBotId !== null || questionBox) { + viewTransition(() => + set({ selectedCrewId: crewId, selectedBotId: null, questionBox: false }), + ); } }, selectBot: (botId) => { const bot = get().bots[botId]; - if (bot && get().selectedBotId !== botId) { + if (bot && (get().selectedBotId !== botId || get().questionBox)) { get().markSeen(botId); - viewTransition(() => set({ selectedCrewId: bot.crewId, selectedBotId: botId })); + viewTransition(() => + set({ selectedCrewId: bot.crewId, selectedBotId: botId, questionBox: false }), + ); + } + }, + openQuestionBox: () => { + if (!get().questionBox) { + viewTransition(() => set({ questionBox: true, selectedCrewId: null, selectedBotId: null })); } }, ...seenActions(get, set), @@ -216,6 +235,7 @@ function withoutBots(state: AppState, botIds: BotId[]): Partial { replyAt: Object.fromEntries(Object.entries(state.replyAt).filter(([id]) => !gone.has(id))), routines: kept(state.routines, (routine) => !gone.has(routine.botId)), browsers: kept(state.browsers, (browser) => !gone.has(browser.botId)), + questions: kept(state.questions, (question) => !gone.has(question.botId)), deliveries: kept(state.deliveries, (delivery) => !gone.has(delivery.botId)), tasks: kept( state.tasks, @@ -284,6 +304,8 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial { + const { [question.id]: _, ...rest } = state.questions; + return { + questions: question.status === "open" ? { ...rest, [question.id]: question } : rest, + }; +} + +export function questionsById(list: Question[]): Record { + return Object.fromEntries(list.map((question) => [question.id, question])); +} + +let last: { questions: AppState["questions"]; bots: AppState["bots"]; list: Question[] } | null = + null; + +/** + * Open questions of the bots the app shows, newest first. The same array + * while nothing it depends on changed, for selectors. + */ +export function openQuestions(state: AppState): Question[] { + if (last && last.questions === state.questions && last.bots === state.bots) { + return last.list; + } + const list = Object.values(state.questions) + .filter((question) => state.bots[question.botId] !== undefined) + .sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)); + last = { questions: state.questions, bots: state.bots, list }; + return list; +} diff --git a/app/src/store/sync.ts b/app/src/store/sync.ts index 85d835c6..e052d318 100644 --- a/app/src/store/sync.ts +++ b/app/src/store/sync.ts @@ -4,6 +4,7 @@ import { type BotloftApi, type ConnectionState, errorText } from "../lib/api"; import type { Delivery } from "../lib/protocol.gen"; import { onVisibility, windowHidden } from "../shell/visibility"; import { type AppStore, applyEvent, crewList } from "./app"; +import { questionsById } from "./questions"; import { withReply } from "./seen"; /** @@ -75,6 +76,11 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void { alive && store.setState({ browsers: Object.fromEntries(list.map((b) => [b.botId, b])) }), ) .catch(() => {}); + api + .call("questions.list", {}) + .then((list) => alive && store.setState({ questions: questionsById(list) })) + // An older daemon has no questions. + .catch(() => {}); api .call("tasks.list", {}) .then( diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 6c37c7ba..f7ba588b 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -168,6 +168,7 @@ pub fn activity(body: &ChatBody, at: i64) -> Option { ActivityKind::Approval, item.explanation.as_ref().unwrap_or(&item.summary).clone(), ), + ChatBody::Question(item) => (ActivityKind::Question, item.question.text.clone()), // A compaction is housekeeping, not news about the conversation. ChatBody::Notice(item) if matches!( diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 09f5971a..cc600987 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -93,6 +93,21 @@ impl RoutineEnvelope<'_> { } } +/// Longest part of a question quoted above the owner's answer, in +/// characters. +const QUOTED_QUESTION_MAX_CHARS: usize = 300; + +/// The owner's answer to a bot's question (spec 23.4). No `[botloft]`: it is +/// the owner speaking, after a line that says what it answers. +pub fn answer(question_id: &str, question: &str, body: &str) -> String { + let quoted = crate::chat::one_line(question, QUOTED_QUESTION_MAX_CHARS); + format!( + "Answer to your question {question_id}: \"{quoted}\" + +{body}" + ) +} + /// Time left until `deadline_ms`, rounded for a reader who does not know /// the current time. pub fn due(deadline_ms: i64, now_ms: i64) -> String { diff --git a/crates/botloft-core/src/ids.rs b/crates/botloft-core/src/ids.rs index 9e1445ca..d649fe64 100644 --- a/crates/botloft-core/src/ids.rs +++ b/crates/botloft-core/src/ids.rs @@ -112,6 +112,10 @@ prefixed_id!( /// Identifies a request a bot may make without asking (spec 10.1). RuleId, "rule", "rul_" ); +prefixed_id!( + /// Identifies a question a bot asked the owner (spec 23). + QuestionId, "question", "qst_" +); /// A random version 4 UUID, for Claude Code session ids and the uuid of /// each message written to a bot (spec 7.3, 9.2). diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 49bb188f..88b4f3de 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use super::{AllowScope, Message, TokenUsage}; +use super::{AllowScope, Message, Question, TokenUsage}; use crate::ids::{ApprovalId, BotId, ChatItemId}; text_enum!( @@ -65,6 +65,8 @@ text_enum!( Tool => "tool", /// A permission request; `text` is the tool. Approval => "approval", + /// A question for the owner (spec 23); `text` is the question. + Question => "question", Notice => "notice", } ); @@ -95,6 +97,8 @@ pub enum ChatBody { Reply(ReplyItem), Tool(ToolItem), Approval(ApprovalItem), + /// A question for the owner (spec 23.4). + Question(QuestionItem), /// The end of a turn. Turn(TurnItem), Notice(NoticeItem), @@ -160,6 +164,13 @@ pub struct ApprovalItem { pub always: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct QuestionItem { + pub question: Question, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index b021acff..c9969603 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -10,12 +10,14 @@ use ts_rs::{Config, TS}; use super::*; use crate::avatar::PALETTE; use crate::ids::{ - ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, RoutineId, - RoutineRunId, RuleId, TaskId, + ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, QuestionId, + RoutineId, RoutineRunId, RuleId, TaskId, }; #[path = "export_browser.rs"] mod browser; +#[path = "export_questions.rs"] +mod questions; #[path = "export_routines.rs"] mod routines; #[path = "export_rules.rs"] @@ -100,6 +102,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -169,6 +172,7 @@ fn export_bindings() { rules::decls(&mut out); out.decl::(); usage::decls(&mut out); + questions::decls(&mut out); out.decl::(); out.decl::(); out.decl::(); @@ -328,6 +332,7 @@ fn export_bindings() { usage::methods(&mut out); rules::methods(&mut out); routines::methods(&mut out); + questions::methods(&mut out); browser::methods(&mut out); out.text.push_str("}\n\n"); @@ -372,6 +377,7 @@ fn export_bindings() { let _ = writeln!(out.text, " \"{}\": {task};", notification::TASK_CHANGED); session::notifications(&mut out); routines::notifications(&mut out); + questions::notifications(&mut out); rules::notifications(&mut out); browser::notifications(&mut out); out.text.push_str("}\n\n"); diff --git a/crates/botloft-core/src/protocol/export_questions.rs b/crates/botloft-core/src/protocol/export_questions.rs new file mode 100644 index 00000000..f49980b0 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_questions.rs @@ -0,0 +1,44 @@ +//! The questions part of the TypeScript bindings (spec 23). + +use std::fmt::Write as _; + +use super::super::*; +use super::Out; + +/// Before `ChatBody`, which holds `QuestionItem`. +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + let question = out.name::(); + out.method( + method::QUESTIONS_LIST, + &out.name::(), + &out.name::>(), + ); + out.method( + method::QUESTIONS_ANSWER, + &out.name::(), + &question, + ); + out.method( + method::QUESTIONS_DISMISS, + &out.name::(), + &question, + ); +} + +pub(super) fn notifications(out: &mut Out) { + let question = out.name::(); + let _ = writeln!( + out.text, + " \"{}\": {question};", + notification::QUESTION_CHANGED + ); +} diff --git a/crates/botloft-core/src/protocol/messaging.rs b/crates/botloft-core/src/protocol/messaging.rs index dec3f88b..f2b0750d 100644 --- a/crates/botloft-core/src/protocol/messaging.rs +++ b/crates/botloft-core/src/protocol/messaging.rs @@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize}; -use crate::ids::{AttachmentId, BotId, CrewId, DeliveryId, MessageId, RoutineId, TaskId}; +use crate::ids::{ + AttachmentId, BotId, CrewId, DeliveryId, MessageId, QuestionId, RoutineId, TaskId, +}; text_enum!( /// Who wrote a message. @@ -70,6 +72,9 @@ pub struct Message { pub task_id: Option, /// The routine that sent it (spec 20.5). pub routine_id: Option, + /// The bot's question this message answers (spec 23.4). + #[serde(default)] + pub question_id: Option, /// Files the owner attached (spec 9.5); empty for everything else. pub attachments: Vec, /// Unix time in milliseconds. diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index f6b208a1..55b7e616 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -58,6 +58,7 @@ mod files; mod messaging; mod methods; mod model; +mod questions; mod routines; mod rules; mod screens; @@ -72,6 +73,7 @@ pub use files::*; pub use messaging::*; pub use methods::*; pub use model::*; +pub use questions::*; pub use routines::*; pub use rules::*; pub use screens::*; @@ -139,6 +141,9 @@ pub mod method { pub const BROWSER_SWITCH_TAB: &str = "browser.switchTab"; pub const BROWSER_OPEN: &str = "browser.open"; pub const SCREENS_LIST: &str = "screens.list"; + pub const QUESTIONS_LIST: &str = "questions.list"; + pub const QUESTIONS_ANSWER: &str = "questions.answer"; + pub const QUESTIONS_DISMISS: &str = "questions.dismiss"; pub const SETTINGS_GET: &str = "settings.get"; pub const SETTINGS_UPDATE: &str = "settings.update"; pub const USAGE_TOKENS: &str = "usage.tokens"; @@ -166,6 +171,7 @@ pub mod notification { /// Only to the connection watching that bot's browser (spec 21.7). pub const BROWSER_FRAME: &str = "browser.frame"; pub const SCREEN_DRAFT: &str = "screen.draft"; + pub const QUESTION_CHANGED: &str = "question.changed"; } /// Error codes: the standard JSON-RPC ones plus the daemon's (spec 11.4). diff --git a/crates/botloft-core/src/protocol/questions.rs b/crates/botloft-core/src/protocol/questions.rs new file mode 100644 index 00000000..583ffd82 --- /dev/null +++ b/crates/botloft-core/src/protocol/questions.rs @@ -0,0 +1,72 @@ +//! Questions a bot asks the owner without waiting (spec 23), with the +//! params of the `questions.*` methods. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{BotId, CrewId, QuestionId}; + +/// Longest question, in characters (spec 23.2). +pub const QUESTION_MAX_CHARS: usize = 2_000; +/// Longest ready answer, in characters. +pub const QUESTION_OPTION_MAX_CHARS: usize = 100; +/// Fewest and most ready answers a question may offer. +pub const QUESTION_OPTIONS_MIN: usize = 2; +pub const QUESTION_OPTIONS_MAX: usize = 5; +/// Most questions one bot may have open at once. +pub const OPEN_QUESTIONS_MAX: usize = 5; + +text_enum!( + QuestionStatus, "question status" { + /// Waiting for the owner. + Open => "open", + Answered => "answered", + /// Closed by the owner without an answer; the bot is not told. + Dismissed => "dismissed", + } +); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Question { + pub id: QuestionId, + pub crew_id: CrewId, + pub bot_id: BotId, + /// What the bot asks, markdown. + pub text: String, + /// Ready answers the owner may pick; empty for a free answer only. + pub options: Vec, + pub status: QuestionStatus, + /// What the owner answered; `null` unless `answered`. + pub answer: Option, + /// Unix time in milliseconds. + pub created_at: i64, + /// Unix time in milliseconds the owner answered or dismissed it. + pub answered_at: Option, +} + +/// Newest first. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct QuestionsListParams { + /// `open` when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct QuestionsAnswerParams { + pub question_id: QuestionId, + pub answer: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct QuestionIdParams { + pub question_id: QuestionId, +} diff --git a/crates/botloft-store/migrations/0015_questions.sql b/crates/botloft-store/migrations/0015_questions.sql new file mode 100644 index 00000000..aeee39fb --- /dev/null +++ b/crates/botloft-store/migrations/0015_questions.sql @@ -0,0 +1,24 @@ +-- Questions a bot asks the owner without waiting (spec 23.7). The answer +-- goes to the bot as a message that points back to its question. + +CREATE TABLE questions ( + id TEXT PRIMARY KEY, + crew_id TEXT NOT NULL REFERENCES crews (id), + bot_id TEXT NOT NULL REFERENCES bots (id), + chat_item_id TEXT NOT NULL REFERENCES chat_items (id), + text TEXT NOT NULL, + -- JSON array of strings; empty for a free answer only. + options TEXT NOT NULL, + status TEXT NOT NULL, + answer TEXT, + created_at INTEGER NOT NULL, + answered_at INTEGER +); + +CREATE INDEX questions_status ON questions (status, created_at); +CREATE INDEX questions_bot ON questions (bot_id); +CREATE INDEX questions_crew ON questions (crew_id); +CREATE INDEX questions_chat_item ON questions (chat_item_id); + +ALTER TABLE messages ADD COLUMN question_id TEXT REFERENCES questions (id); +CREATE INDEX messages_question ON messages (question_id); diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index 1460b589..fe034546 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -31,6 +31,7 @@ fn kind(body: &ChatBody) -> &'static str { ChatBody::Reply(_) => "reply", ChatBody::Tool(_) => "tool", ChatBody::Approval(_) => "approval", + ChatBody::Question(_) => "question", ChatBody::Turn(_) => "turn", ChatBody::Notice(_) => "notice", } @@ -69,9 +70,18 @@ impl Store { id: &ChatItemId, body: &ChatBody, now: i64, + ) -> Result> { + Self::update_chat_item_in(&self.conn, id, body, now) + } + + pub(crate) fn update_chat_item_in( + conn: &Connection, + id: &ChatItemId, + body: &ChatBody, + now: i64, ) -> Result> { Ok(cached_row( - &self.conn, + conn, &format!( "UPDATE chat_items SET kind = ?2, data = ?3, updated_at = ?4 \ WHERE id = ?1 RETURNING {COLUMNS}" diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs index 66fa7c11..1ff13145 100644 --- a/crates/botloft-store/src/delete.rs +++ b/crates/botloft-store/src/delete.rs @@ -10,6 +10,9 @@ use crate::{Result, Store}; /// Every statement takes the bot's id. The order follows the foreign keys: /// what points at a row goes, or lets go of it, before the row. const DELETE_BOT: &[&str] = &[ + // Questions point at chat items, and answers at questions. + "UPDATE messages SET question_id = NULL WHERE question_id IN (SELECT id FROM questions WHERE bot_id = ?1)", + "DELETE FROM questions WHERE bot_id = ?1", "DELETE FROM approvals WHERE bot_id = ?1", "DELETE FROM chat_items WHERE bot_id = ?1", "DELETE FROM browser_sites WHERE bot_id = ?1", diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs index a8207ce6..77ddd0dc 100644 --- a/crates/botloft-store/src/delete/tests.rs +++ b/crates/botloft-store/src/delete/tests.rs @@ -146,6 +146,15 @@ fn fill(fx: &Fixture) -> Owned { finished_at: None, }; fx.store.fire_run(&run, &fired, &delivery).expect("run"); + + // A question the owner answered. + let question = crate::questions::tests::ask(&fx.store, fx, scout, "Which client?", 120); + let (mut answer, delivery) = message_to(&fx.crew.id, scout, "Acme"); + answer.question_id = Some(question.id.clone()); + fx.store + .answer_question(&question.id, &answer, &delivery) + .expect("answer") + .expect("open"); Owned { attachment, approval: record.approval.id, @@ -207,6 +216,7 @@ fn a_deleted_bot_takes_what_was_its_own() { ); assert!(fx.store.browser_sites(&scout).expect("sites").is_empty()); assert!(fx.store.allow_rules(&scout).expect("rules").is_empty()); + assert_eq!(count(&fx.store, "questions"), 0); let crew = fx.store.crew(&fx.crew.id).expect("crew").expect("kept"); assert_eq!(crew.lead_bot_id, None); assert_eq!(dangling(&fx.store), 0); diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index df5a0dd2..cff0da8f 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -16,6 +16,7 @@ mod messages; mod migrate; #[cfg(test)] mod plans; +mod questions; mod routine_runs; mod routines; mod tasks; @@ -33,6 +34,7 @@ pub use bots::BotRecord; pub use deliveries::DeliveryOutcome; pub use messages::MessageFilter; pub use migrate::LATEST_VERSION; +pub use questions::{Answered, QuestionRecord}; pub use tasks::TaskFilter; #[derive(Debug, thiserror::Error)] @@ -207,6 +209,7 @@ pub(crate) mod tests { body: body.to_owned(), task_id: None, routine_id: None, + question_id: None, attachments: Vec::new(), created_at: 0, }; diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index 7cab2964..3cbd4587 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -2,7 +2,9 @@ //! its attachments, its delivery, the task it creates and the recipient's //! chat item, in one transaction (spec 9.1). -use botloft_core::ids::{AttachmentId, BotId, ChatItemId, CrewId, MessageId, RoutineId, TaskId}; +use botloft_core::ids::{ + AttachmentId, BotId, ChatItemId, CrewId, MessageId, QuestionId, RoutineId, TaskId, +}; use botloft_core::protocol::{ Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, Task, }; @@ -10,8 +12,7 @@ use rusqlite::{Connection, OptionalExtension, Row, params}; use crate::{Result, Store, cached_execute, cached_row, parse_column, to_sql_int}; -const COLUMNS: &str = - "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at, routine_id"; +const COLUMNS: &str = "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at, routine_id, \n question_id"; fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(Message { @@ -24,6 +25,7 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { body: row.get(6)?, task_id: optional_column(row, 7)?, routine_id: optional_column(row, 9)?, + question_id: optional_column(row, 10)?, attachments: Vec::new(), created_at: row.get(8)?, }) @@ -103,7 +105,7 @@ impl Store { cached_execute( conn, &format!( - "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" + "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" ), params![ message.id.as_str(), @@ -116,6 +118,7 @@ impl Store { message.task_id.as_ref().map(TaskId::as_str), message.created_at, message.routine_id.as_ref().map(RoutineId::as_str), + message.question_id.as_ref().map(QuestionId::as_str), ], )?; for attachment in &message.attachments { diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 01170315..8f24d64c 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -21,6 +21,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (12, include_str!("../migrations/0012_perf_indexes.sql")), (13, include_str!("../migrations/0013_reply_index.sql")), (14, include_str!("../migrations/0014_allow_rules.sql")), + (15, include_str!("../migrations/0015_questions.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloft-store/src/questions.rs b/crates/botloft-store/src/questions.rs new file mode 100644 index 00000000..0ec3fef1 --- /dev/null +++ b/crates/botloft-store/src/questions.rs @@ -0,0 +1,191 @@ +//! `questions` table: what bots asked the owner without waiting (spec 23). +//! Each question has a chat item in its bot's chat that mirrors it, and an +//! answer is a message to the bot, saved in the same transaction. + +use botloft_core::ids::{BotId, ChatItemId, QuestionId}; +use botloft_core::protocol::{ + ChatBody, ChatItem, Delivery, Message, Question, QuestionItem, QuestionStatus, +}; +use rusqlite::types::Type; +use rusqlite::{Connection, OptionalExtension, Row, params}; + +use crate::{Result, Store, parse_column}; + +const COLUMNS: &str = "q.id, q.crew_id, q.bot_id, q.text, q.options, q.status, q.answer, q.created_at, \ + q.answered_at, q.chat_item_id"; + +/// A question and the chat item that shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestionRecord { + pub question: Question, + pub chat_item_id: ChatItemId, +} + +/// What changed when the owner answered: the question, its chat item and +/// the answer's own item in the bot's chat. +#[derive(Debug, Clone, PartialEq)] +pub struct Answered { + pub question: Question, + pub question_item: ChatItem, + pub answer_item: ChatItem, +} + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + let options: String = row.get(4)?; + let options = serde_json::from_str(&options) + .map_err(|err| rusqlite::Error::FromSqlConversionFailure(4, Type::Text, Box::new(err)))?; + Ok(QuestionRecord { + question: Question { + id: parse_column(row, 0)?, + crew_id: parse_column(row, 1)?, + bot_id: parse_column(row, 2)?, + text: row.get(3)?, + options, + status: parse_column(row, 5)?, + answer: row.get(6)?, + created_at: row.get(7)?, + answered_at: row.get(8)?, + }, + chat_item_id: parse_column(row, 9)?, + }) +} + +fn find(conn: &Connection, id: &QuestionId) -> Result> { + Ok(conn + .query_row( + &format!("SELECT {COLUMNS} FROM questions q WHERE q.id = ?1"), + [id.as_str()], + from_row, + ) + .optional()?) +} + +/// Closes an open question; `None` if it was not open. +fn settle( + conn: &Connection, + id: &QuestionId, + status: QuestionStatus, + answer: Option<&str>, + now: i64, +) -> Result> { + let changed = conn.execute( + "UPDATE questions SET status = ?2, answer = ?3, answered_at = ?4 \ + WHERE id = ?1 AND status = 'open'", + params![id.as_str(), status.as_str(), answer, now], + )?; + if changed == 0 { + return Ok(None); + } + find(conn, id) +} + +/// Rewrites the question's chat item with how it stands now. +fn mirror(conn: &Connection, record: &QuestionRecord, now: i64) -> Result { + let body = ChatBody::Question(QuestionItem { + question: record.question.clone(), + }); + Store::update_chat_item_in(conn, &record.chat_item_id, &body, now)?.ok_or( + crate::StoreError::Sqlite(rusqlite::Error::QueryReturnedNoRows), + ) +} + +impl Store { + /// Saves a new question with the chat item that shows it. + pub fn insert_question(&self, question: &Question, item: &ChatItem) -> Result<()> { + let options = serde_json::to_string(&question.options) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?; + let tx = self.conn.unchecked_transaction()?; + Self::insert_chat_item_in(&tx, item)?; + tx.execute( + "INSERT INTO questions (id, crew_id, bot_id, chat_item_id, text, options, status, \ + answer, created_at, answered_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + question.id.as_str(), + question.crew_id.as_str(), + question.bot_id.as_str(), + item.id.as_str(), + question.text, + options, + question.status.as_str(), + question.answer, + question.created_at, + question.answered_at, + ], + )?; + tx.commit()?; + Ok(()) + } + + pub fn question(&self, id: &QuestionId) -> Result> { + find(&self.conn, id) + } + + /// How many questions `bot` has open. + pub fn open_questions(&self, bot: &BotId) -> Result { + let count: i64 = self.conn.query_row( + "SELECT count(*) FROM questions WHERE bot_id = ?1 AND status = 'open'", + [bot.as_str()], + |row| row.get(0), + )?; + Ok(usize::try_from(count).unwrap_or(0)) + } + + /// Questions of active bots in active crews with `status`, newest first. + pub fn questions(&self, status: QuestionStatus) -> Result> { + let mut stmt = self.conn.prepare_cached(&format!( + "SELECT {COLUMNS} FROM questions q \ + JOIN bots b ON b.id = q.bot_id JOIN crews c ON c.id = q.crew_id \ + WHERE q.status = ?1 AND b.archived_at IS NULL AND c.archived_at IS NULL \ + ORDER BY q.created_at DESC, q.rowid DESC" + ))?; + let rows = stmt.query_map([status.as_str()], |row| { + from_row(row).map(|record| record.question) + })?; + Ok(rows.collect::>()?) + } + + /// Answers an open question and saves `message` (the answer, to the + /// bot) with its delivery, atomically. `None`, with nothing saved, if + /// the question was not open. + pub fn answer_question( + &self, + id: &QuestionId, + message: &Message, + delivery: &Delivery, + ) -> Result> { + let now = message.created_at; + let tx = self.conn.unchecked_transaction()?; + let Some(record) = settle(&tx, id, QuestionStatus::Answered, Some(&message.body), now)? + else { + return Ok(None); + }; + let question_item = mirror(&tx, &record, now)?; + let answer_item = Self::insert_message_in(&tx, message, delivery)?; + tx.commit()?; + Ok(Some(Answered { + question: record.question, + question_item, + answer_item, + })) + } + + /// Closes an open question without an answer. `None` if it was not + /// open. + pub fn dismiss_question( + &self, + id: &QuestionId, + now: i64, + ) -> Result> { + let tx = self.conn.unchecked_transaction()?; + let Some(record) = settle(&tx, id, QuestionStatus::Dismissed, None, now)? else { + return Ok(None); + }; + let item = mirror(&tx, &record, now)?; + tx.commit()?; + Ok(Some((record.question, item))) + } +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/botloft-store/src/questions/tests.rs b/crates/botloft-store/src/questions/tests.rs new file mode 100644 index 00000000..0ba34566 --- /dev/null +++ b/crates/botloft-store/src/questions/tests.rs @@ -0,0 +1,142 @@ +//! Tests for the questions bots ask the owner. + +use botloft_core::ids::{BotId, ChatItemId, QuestionId}; +use botloft_core::protocol::{ChatBody, ChatItem, Question, QuestionItem, QuestionStatus}; + +use crate::Store; +use crate::tests::{Fixture, message_to}; + +/// Asks `text` as `bot`; returns the question. +pub(crate) fn ask(store: &Store, fx: &Fixture, bot: &BotId, text: &str, at: i64) -> Question { + let question = Question { + id: QuestionId::generate(), + crew_id: fx.crew.id.clone(), + bot_id: bot.clone(), + text: text.into(), + options: vec!["Yes".into(), "No".into()], + status: QuestionStatus::Open, + answer: None, + created_at: at, + answered_at: None, + }; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body: ChatBody::Question(QuestionItem { + question: question.clone(), + }), + created_at: at, + updated_at: at, + }; + store.insert_question(&question, &item).expect("question"); + question +} + +#[test] +fn open_questions_are_listed_newest_first_and_counted_per_bot() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + let first = ask(&fx.store, &fx, scout, "Which client?", 10); + let second = ask(&fx.store, &fx, writer, "Which tone?", 20); + + let open = fx.store.questions(QuestionStatus::Open).expect("list"); + assert_eq!( + open.iter().map(|q| &q.id).collect::>(), + [&second.id, &first.id] + ); + assert_eq!(open[1].options, ["Yes", "No"]); + assert_eq!(fx.store.open_questions(scout).expect("count"), 1); + + let record = fx.store.question(&first.id).expect("find").expect("there"); + let item = fx + .store + .chat_item(&record.chat_item_id) + .expect("item") + .expect("there"); + assert!(matches!(item.body, ChatBody::Question(_))); +} + +#[test] +fn an_answer_closes_the_question_and_becomes_a_message_once() { + let fx = Fixture::new(); + let scout = &fx.bots[0].id; + let question = ask(&fx.store, &fx, scout, "Ship on Friday?", 10); + let (mut message, delivery) = message_to(&fx.crew.id, scout, "Yes"); + message.question_id = Some(question.id.clone()); + message.created_at = 50; + + let answered = fx + .store + .answer_question(&question.id, &message, &delivery) + .expect("answer") + .expect("was open"); + assert_eq!(answered.question.status, QuestionStatus::Answered); + assert_eq!(answered.question.answer.as_deref(), Some("Yes")); + assert_eq!(answered.question.answered_at, Some(50)); + let ChatBody::Question(item) = &answered.question_item.body else { + panic!("question item"); + }; + assert_eq!(item.question, answered.question); + let ChatBody::Inbound(inbound) = &answered.answer_item.body else { + panic!("answer item"); + }; + assert_eq!(inbound.message.question_id.as_ref(), Some(&question.id)); + let stored = fx.store.message(&message.id).expect("read").expect("saved"); + assert_eq!(stored.question_id, Some(question.id.clone())); + + let (again, delivery) = message_to(&fx.crew.id, scout, "No"); + assert_eq!( + fx.store + .answer_question(&question.id, &again, &delivery) + .expect("again"), + None + ); + assert_eq!(fx.store.message(&again.id).expect("read"), None); + assert_eq!( + fx.store + .dismiss_question(&question.id, 60) + .expect("dismiss"), + None + ); + assert!( + fx.store + .questions(QuestionStatus::Open) + .expect("list") + .is_empty() + ); + assert_eq!(fx.store.open_questions(scout).expect("count"), 0); +} + +#[test] +fn a_dismissed_question_leaves_the_box_and_archived_bots_hide_theirs() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + let dismissed = ask(&fx.store, &fx, scout, "Coffee?", 10); + ask(&fx.store, &fx, writer, "Tea?", 20); + + let (question, item) = fx + .store + .dismiss_question(&dismissed.id, 30) + .expect("dismiss") + .expect("was open"); + assert_eq!(question.status, QuestionStatus::Dismissed); + assert_eq!(question.answer, None); + assert_eq!(item.updated_at, 30); + assert_eq!( + fx.store + .questions(QuestionStatus::Dismissed) + .expect("list") + .len(), + 1 + ); + + let mut archived = fx.bots[1].clone(); + archived.archived_at = Some(40); + fx.store.update_bot(&archived).expect("archive"); + assert!( + fx.store + .questions(QuestionStatus::Open) + .expect("list") + .is_empty() + ); +} diff --git a/crates/botloftd/src/chat/items.rs b/crates/botloftd/src/chat/items.rs index 5e11672a..32502a29 100644 --- a/crates/botloftd/src/chat/items.rs +++ b/crates/botloftd/src/chat/items.rs @@ -32,6 +32,15 @@ pub(crate) fn announce(daemon: &Daemon, item: ChatItem) { daemon.emit(Event::ChatItem(ChatItemChanged { item, activity })); } +/// Announces an item saved again with a new body, which leaves the +/// conversation-list line as it was. +pub(crate) fn changed(daemon: &Daemon, item: ChatItem) { + daemon.emit(Event::ChatItem(ChatItemChanged { + item, + activity: None, + })); +} + /// Replaces an item's body, e.g. a tool that finished, and announces it. pub(crate) fn update(daemon: &Daemon, id: &ChatItemId, body: ChatBody) -> Option { let now = daemon.clock.now_ms(); diff --git a/crates/botloftd/src/courier/context.rs b/crates/botloftd/src/courier/context.rs new file mode 100644 index 00000000..00122be5 --- /dev/null +++ b/crates/botloftd/src/courier/context.rs @@ -0,0 +1,38 @@ +//! What the courier reads besides the message to write its line: the +//! routine that sent it (spec 20.5) or the question it answers (spec 23.4). + +use botloft_core::protocol::Message; +use botloft_store::{Store, StoreError}; + +use crate::routines; + +/// A routine's name, the local time the run is for and the zone. +pub(super) fn routine( + store: &Store, + message: &Message, +) -> Result, StoreError> { + let Some(id) = &message.routine_id else { + return Ok(None); + }; + let (Some(routine), Some(run)) = (store.routine(id)?, store.run_of_message(&message.id)?) + else { + return Ok(None); + }; + let scheduled = routines::schedule::Plan::new(&routine.schedule, &routine.timezone) + .map(|plan| plan.local_time(run.scheduled_for)) + .unwrap_or_default(); + Ok(Some((routine.name, scheduled, routine.timezone))) +} + +/// The id and text of the question a message answers. +pub(super) fn question( + store: &Store, + message: &Message, +) -> Result, StoreError> { + let Some(id) = &message.question_id else { + return Ok(None); + }; + Ok(store + .question(id)? + .map(|record| (record.question.id.to_string(), record.question.text))) +} diff --git a/crates/botloftd/src/courier/mod.rs b/crates/botloftd/src/courier/mod.rs index 68f46124..761f8962 100644 --- a/crates/botloftd/src/courier/mod.rs +++ b/crates/botloftd/src/courier/mod.rs @@ -2,6 +2,7 @@ //! in order and one at a time per bot, and puts back what a process that //! ended never began. Each cycle also expires overdue tasks (spec 9.4). +mod context; mod render; mod settings; mod sleep; @@ -19,9 +20,9 @@ use tracing::{debug, warn}; use self::render::{Context, Rendered, render}; pub use self::settings::CourierSettings; use self::sleep::{became_ready, hasten, next_wait}; +use crate::clock; use crate::service::tasks; use crate::state::{Daemon, Event}; -use crate::{clock, routines}; /// How long to wait for a bot that cannot take messages yet (spec 9.1). const WAIT_FOR_BOT: Duration = Duration::from_secs(5); @@ -211,7 +212,8 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result store.bot(id)?.map(|sender| sender.handle), _ => None, }; - let routine = routine_context(store, &message)?; + let routine = context::routine(store, &message)?; + let question = context::question(store, &message)?; Ok(Step::Send(Box::new(Draft { workspace: daemon.paths.bot_workspace(&crew.slug, &bot.slug), message, @@ -219,6 +221,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result, workspace: PathBuf, routine: Option<(String, String, String)>, + /// The question an answer is for: its id and text. + question: Option<(String, String)>, } impl Draft { @@ -244,29 +249,15 @@ impl Draft { .routine .as_ref() .map(|(name, scheduled, zone)| (name.as_str(), scheduled.as_str(), zone.as_str())), + question: self + .question + .as_ref() + .map(|(id, text)| (id.as_str(), text.as_str())), }; render(&self.message, &context, now) } } -/// A routine's name, the local time the run is for and the zone. -fn routine_context( - store: &Store, - message: &Message, -) -> Result, StoreError> { - let Some(id) = &message.routine_id else { - return Ok(None); - }; - let (Some(routine), Some(run)) = (store.routine(id)?, store.run_of_message(&message.id)?) - else { - return Ok(None); - }; - let scheduled = routines::schedule::Plan::new(&routine.schedule, &routine.timezone) - .map(|plan| plan.local_time(run.scheduled_for)) - .unwrap_or_default(); - Ok(Some((routine.name, scheduled, routine.timezone))) -} - /// The process of `generation` ended: what it never began goes back in /// the queue, counting an attempt (spec 9.1). pub fn requeue_unread(daemon: &Daemon, bot: &BotId, generation: u64) { diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index 2283adb7..c2eefec5 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -4,7 +4,7 @@ use std::path::Path; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; -use botloft_core::envelope::{Envelope, RoutineEnvelope, Sender}; +use botloft_core::envelope::{self, Envelope, RoutineEnvelope, Sender}; use botloft_core::ids::random_uuid; use botloft_core::protocol::{Attachment, Message, SenderKind, Task}; use bytes::Bytes; @@ -26,6 +26,8 @@ pub(super) struct Context<'a> { /// For a routine's message: its name, the local time it is for and the /// zone (spec 20.5). pub routine: Option<(&'a str, &'a str, &'a str)>, + /// For the owner's answer to a question: its id and text (spec 23.4). + pub question: Option<(&'a str, &'a str)>, } pub(super) struct Rendered { @@ -38,7 +40,13 @@ pub(super) struct Rendered { pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rendered { let text = match message.from_kind { // The owner is the session's user: their words go as written. - SenderKind::Owner => with_attachment_list(&message.body, &message.attachments), + SenderKind::Owner => { + let body = with_attachment_list(&message.body, &message.attachments); + match context.question { + Some((id, question)) => envelope::answer(id, question, &body), + None => body, + } + } SenderKind::Bot => { let from = match context.sender_handle { Some(handle) => Sender::Bot { handle }, @@ -144,6 +152,7 @@ mod tests { body: body.into(), task_id: None, routine_id: None, + question_id: None, attachments: Vec::new(), created_at: 0, } @@ -163,6 +172,7 @@ mod tests { task: None, workspace: dir.path(), routine: None, + question: None, }; let owner = render(&message(SenderKind::Owner, "Ship it"), &context, 0); let line = parse(&owner); @@ -207,6 +217,7 @@ mod tests { task: None, workspace: dir.path(), routine: None, + question: None, }; let line = parse(&render(&owner, &context, 0)); let content = line["message"]["content"].as_array().expect("blocks"); diff --git a/crates/botloftd/src/routines/fire.rs b/crates/botloftd/src/routines/fire.rs index 55ffa2e7..911548b8 100644 --- a/crates/botloftd/src/routines/fire.rs +++ b/crates/botloftd/src/routines/fire.rs @@ -137,6 +137,7 @@ fn attempt( body: routine.prompt.clone(), task_id: None, routine_id: Some(routine.id.clone()), + question_id: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 4efbab10..12a78893 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,7 +9,7 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead, - messages, models, modes, routines, rules, screens, settings, tasks, usage, + messages, models, modes, questions, routines, rules, screens, settings, tasks, usage, }; use crate::state::Daemon; @@ -63,6 +63,9 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(routines::runs(daemon, parse(params)?)), method::TASKS_LIST => reply(tasks::list(daemon, parse(params)?)), method::BROWSER_LIST => reply(Ok(daemon.browsers.list())), + method::QUESTIONS_LIST => reply(questions::list(daemon, parse(params)?)), + method::QUESTIONS_ANSWER => reply(questions::answer(daemon, parse(params)?)), + method::QUESTIONS_DISMISS => reply(questions::dismiss(daemon, parse(params)?)), method::SCREENS_LIST => reply(screens::list(daemon, parse(params)?)), method::SETTINGS_GET => reply(settings::get(daemon)), method::SETTINGS_UPDATE => reply(settings::update(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index f9be90d6..fe17397a 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -286,5 +286,6 @@ fn to_notification(event: &Event) -> String { Event::BrowserChanged(state) => note(notification::BROWSER_CHANGED, state), Event::BrowserAction(action) => note(notification::BROWSER_ACTION, action), Event::ScreenDraft(draft) => note(notification::SCREEN_DRAFT, draft), + Event::QuestionChanged(question) => note(notification::QUESTION_CHANGED, question), } } diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs index a62a54ac..c2f05cf4 100644 --- a/crates/botloftd/src/service/delete.rs +++ b/crates/botloftd/src/service/delete.rs @@ -184,6 +184,7 @@ fn tell(daemon: &Daemon, store: &Store, crew: &Crew, to: BotId, body: String) { body, task_id: None, routine_id: None, + question_id: None, attachments: Vec::new(), created_at: daemon.clock.now_ms(), }; diff --git a/crates/botloftd/src/service/messages.rs b/crates/botloftd/src/service/messages.rs index 138630ea..65b1e69a 100644 --- a/crates/botloftd/src/service/messages.rs +++ b/crates/botloftd/src/service/messages.rs @@ -46,6 +46,7 @@ pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { body, task_id: None, routine_id: None, + question_id: None, attachments, created_at: now, }; diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 8ffb2a7b..d9f2650c 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -13,6 +13,7 @@ pub mod lead; pub mod messages; pub mod models; pub mod modes; +pub mod questions; pub mod routines; pub mod rules; pub mod screens; diff --git a/crates/botloftd/src/service/questions.rs b/crates/botloftd/src/service/questions.rs new file mode 100644 index 00000000..41f9e749 --- /dev/null +++ b/crates/botloftd/src/service/questions.rs @@ -0,0 +1,170 @@ +//! Questions a bot asks the owner without waiting (spec 23): asking, from +//! the bot's tool, and `questions.*`, from the app. + +use botloft_core::ids::{BotId, ChatItemId, MessageId, QuestionId}; +use botloft_core::protocol::{ + ChatBody, ChatItem, Message, MessageKind, OPEN_QUESTIONS_MAX, QUESTION_MAX_CHARS, + QUESTION_OPTION_MAX_CHARS, QUESTION_OPTIONS_MAX, QUESTION_OPTIONS_MIN, Question, + QuestionIdParams, QuestionItem, QuestionStatus, QuestionsAnswerParams, QuestionsListParams, + SenderKind, +}; +use botloft_core::validate; +use botloft_store::Store; + +use super::{ApiError, ApiResult, bots, messages}; +use crate::chat::items; +use crate::state::{Daemon, Event}; + +/// What a bot asks: the text and the ready answers, if any. +pub struct Ask { + pub text: String, + pub options: Vec, +} + +/// Checks and keeps a bot's question, and shows it in its chat and the +/// owner's box. +pub fn ask(daemon: &Daemon, bot: &BotId, ask: Ask) -> ApiResult { + let text = ask.text.trim(); + if text.is_empty() { + return Err(ApiError::validation("question must not be empty")); + } + if text.chars().count() > QUESTION_MAX_CHARS { + return Err(ApiError::validation(format!( + "question must be at most {QUESTION_MAX_CHARS} characters" + ))); + } + let options = options(ask.options)?; + let store = daemon.store(); + let (crew, record) = bots::active(&store, bot)?; + if store.open_questions(bot)? >= OPEN_QUESTIONS_MAX { + return Err(ApiError::Conflict(format!( + "you already have {OPEN_QUESTIONS_MAX} questions the owner has not answered; wait \ + for an answer before asking another" + ))); + } + let now = daemon.clock.now_ms(); + let question = Question { + id: QuestionId::generate(), + crew_id: crew.id, + bot_id: record.id, + text: text.to_owned(), + options, + status: QuestionStatus::Open, + answer: None, + created_at: now, + answered_at: None, + }; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body: ChatBody::Question(QuestionItem { + question: question.clone(), + }), + created_at: now, + updated_at: now, + }; + store.insert_question(&question, &item)?; + drop(store); + items::announce(daemon, item); + daemon.emit(Event::QuestionChanged(question.clone())); + Ok(question) +} + +/// None, or 2 to 5 distinct one-line answers. +fn options(options: Vec) -> ApiResult> { + if options.is_empty() { + return Ok(options); + } + if !(QUESTION_OPTIONS_MIN..=QUESTION_OPTIONS_MAX).contains(&options.len()) { + return Err(ApiError::validation(format!( + "options takes {QUESTION_OPTIONS_MIN} to {QUESTION_OPTIONS_MAX} answers, or none" + ))); + } + let mut kept: Vec = Vec::with_capacity(options.len()); + for option in options { + let option = option.trim(); + if option.is_empty() { + return Err(ApiError::validation("options must not be empty")); + } + if option.chars().count() > QUESTION_OPTION_MAX_CHARS + || option.chars().any(char::is_control) + { + return Err(ApiError::validation(format!( + "each option must be one line of at most {QUESTION_OPTION_MAX_CHARS} characters" + ))); + } + if kept.iter().any(|seen| seen.eq_ignore_ascii_case(option)) { + return Err(ApiError::validation(format!( + "the option \"{option}\" is repeated" + ))); + } + kept.push(option.to_owned()); + } + Ok(kept) +} + +pub fn list(daemon: &Daemon, params: QuestionsListParams) -> ApiResult> { + let status = params.status.unwrap_or(QuestionStatus::Open); + Ok(daemon.store().questions(status)?) +} + +/// The owner answers: the question closes and the answer goes to the bot +/// as the owner's message. +pub fn answer(daemon: &Daemon, params: QuestionsAnswerParams) -> ApiResult { + let body = validate::message("answer", ¶ms.answer)?; + let store = daemon.store(); + let question = open(&store, ¶ms.question_id)?; + let (crew, bot) = bots::active(&store, &question.bot_id)?; + let message = Message { + id: MessageId::generate(), + crew_id: crew.id, + from_kind: SenderKind::Owner, + from_bot_id: None, + to_bot_id: bot.id, + kind: MessageKind::Note, + body, + task_id: None, + routine_id: None, + question_id: Some(question.id.clone()), + attachments: Vec::new(), + created_at: daemon.clock.now_ms(), + }; + let delivery = messages::pending_delivery(&message); + let answered = store + .answer_question(&question.id, &message, &delivery)? + .ok_or_else(|| already_closed(&question.id))?; + drop(store); + items::changed(daemon, answered.question_item); + messages::announce(daemon, None, &message, delivery, answered.answer_item); + daemon.emit(Event::QuestionChanged(answered.question.clone())); + Ok(answered.question) +} + +/// The owner closes a question without answering; the bot is not told. +pub fn dismiss(daemon: &Daemon, params: QuestionIdParams) -> ApiResult { + let store = daemon.store(); + let question = open(&store, ¶ms.question_id)?; + bots::active(&store, &question.bot_id)?; + let (question, item) = store + .dismiss_question(&question.id, daemon.clock.now_ms())? + .ok_or_else(|| already_closed(&question.id))?; + drop(store); + items::changed(daemon, item); + daemon.emit(Event::QuestionChanged(question.clone())); + Ok(question) +} + +/// The question, if it is still open. +fn open(store: &Store, id: &QuestionId) -> ApiResult { + let record = store + .question(id)? + .ok_or_else(|| ApiError::NotFound(format!("question {id} does not exist")))?; + if record.question.status != QuestionStatus::Open { + return Err(already_closed(id)); + } + Ok(record.question) +} + +fn already_closed(id: &QuestionId) -> ApiError { + ApiError::Conflict(format!("question {id} was already answered or dismissed")) +} diff --git a/crates/botloftd/src/service/tasks.rs b/crates/botloftd/src/service/tasks.rs index c1c18006..919731b6 100644 --- a/crates/botloftd/src/service/tasks.rs +++ b/crates/botloftd/src/service/tasks.rs @@ -104,6 +104,7 @@ pub fn send( body, task_id: task.as_ref().map(|task| task.id.clone()), routine_id: None, + question_id: None, attachments: Vec::new(), created_at: now, }; @@ -228,6 +229,7 @@ pub fn complete( body: result.clone(), task_id: Some(task.id.clone()), routine_id: None, + question_id: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/src/service/tasks/expire.rs b/crates/botloftd/src/service/tasks/expire.rs index 6496ca70..c208556e 100644 --- a/crates/botloftd/src/service/tasks/expire.rs +++ b/crates/botloftd/src/service/tasks/expire.rs @@ -43,6 +43,7 @@ fn expire(daemon: &Daemon, store: &Store, task: &Task, now: i64) -> ApiResult<() ), task_id: Some(task.id.clone()), routine_id: None, + question_id: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index db50c52a..38be4459 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -7,7 +7,7 @@ use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotContextChanged, BotDeleted, BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, - Routine, RoutineRun, ScreenDraft, Task, + Question, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::runtime::{Handle, RuntimeFlavor}; @@ -51,6 +51,7 @@ pub enum Event { BrowserChanged(BrowserState), BrowserAction(BrowserAction), ScreenDraft(ScreenDraft), + QuestionChanged(Question), } /// Events buffered per connection before a slow client is dropped. diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 481e0d30..05312a44 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -16,6 +16,7 @@ use tracing::warn; use super::Failure; use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE}; +use super::question::{self, ASK_OWNER}; use super::share::{self, SHARE_FILE}; use crate::service::tasks::{self, BotMessage}; use crate::service::{ApiError, bots, lead}; @@ -38,6 +39,7 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result parse(arguments).and_then(|args| complete(daemon, bot, args)), MY_TASKS => parse(arguments).and_then(|args| my_tasks(daemon, bot, args)), SHARE_FILE => parse(arguments).and_then(|args| share::share(daemon, bot, args)), + ASK_OWNER => parse(arguments).and_then(|args| question::ask(daemon, bot, args)), other => return Err(invalid(&format!("Unknown tool: {other}"))), }; Ok(tool_result(outcome)) diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index b96e8cc6..c18d13b5 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -24,7 +24,9 @@ pub fn tools() -> Value { if let Value::Array(list) = &mut tools { // Before permission_prompt, which stays last. let at = list.len() - 1; - let more = std::iter::once(super::share::tool()).chain(super::browser_catalog::tools()); + let more = [super::share::tool(), super::question::tool()] + .into_iter() + .chain(super::browser_catalog::tools()); list.splice(at..at, more); } tools diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index 5c4984ef..37487012 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -15,6 +15,7 @@ mod browser_sites; mod calls; mod catalog; mod era; +mod question; mod routine; mod share; mod suggest; @@ -41,7 +42,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \ - suggest new bots. schedule_routine asks the owner for work at set times. share_file shows the \ + suggest new bots. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. share_file shows the \ owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \ live and take over when you ask with browser_ask_owner. The owner writes to you directly; messages from other bots and from Botloft start with \ [botloft]."; diff --git a/crates/botloftd/src/tools/question.rs b/crates/botloftd/src/tools/question.rs new file mode 100644 index 00000000..0b63faec --- /dev/null +++ b/crates/botloftd/src/tools/question.rs @@ -0,0 +1,76 @@ +//! `ask_owner` (spec 23.2): the bot asks the owner something and carries +//! on. The answer comes back later as the owner's message. + +use botloft_core::ids::BotId; +use botloft_core::protocol::{ + QUESTION_MAX_CHARS, QUESTION_OPTION_MAX_CHARS, QUESTION_OPTIONS_MAX, QUESTION_OPTIONS_MIN, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::explain; +use crate::service::questions::{self, Ask}; +use crate::state::Daemon; + +pub const ASK_OWNER: &str = "ask_owner"; + +pub(super) fn tool() -> Value { + json!({ + "name": ASK_OWNER, + "title": "Ask the owner", + "description": "Asks the owner a question when you need their decision or information to \ + go on. It does not wait: the question goes to their question box and your chat, and \ + their answer arrives later as a new message. Use it instead of only writing the \ + question in your reply, above all in a routine or a task from another bot, when \ + nobody is reading your chat. Then carry on with whatever does not depend on the \ + answer, or end your turn; never guess the answer. Never ask for passwords, codes or \ + card numbers.", + "inputSchema": { + "type": "object", + "properties": { + "question": { + "type": "string", + "maxLength": QUESTION_MAX_CHARS, + "description": "One question, short and clear on its own, in the language \ + the owner writes to you in. Markdown is fine.", + }, + "options": { + "type": "array", + "items": { "type": "string", "maxLength": QUESTION_OPTION_MAX_CHARS }, + "minItems": QUESTION_OPTIONS_MIN, + "maxItems": QUESTION_OPTIONS_MAX, + "description": "Ready answers the owner can pick with one click, when the \ + possible answers are few. They can still write something else.", + }, + }, + "required": ["question"], + "additionalProperties": false, + }, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct AskArgs { + question: String, + #[serde(default)] + options: Vec, +} + +pub(super) fn ask(daemon: &Daemon, bot: &BotId, args: AskArgs) -> Result { + let ask = Ask { + text: args.question, + options: args.options, + }; + let question = questions::ask(daemon, bot, ask).map_err(explain)?; + Ok(json!({ + "question_id": question.id, + "note": format!( + "The owner sees your question in your chat and in their question box. Their answer \ + arrives later as a new message that starts with \"Answer to your question {}\". \ + Don't wait for it and don't guess it: carry on with what doesn't depend on it, or \ + end your turn.", + question.id + ), + })) +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 442fa86c..7f6f88d7 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -138,6 +138,12 @@ app; the other bots of your crew send you messages too. routines in the Botloft app. It is the only way to schedule: say it is scheduled only after the tool says it was created. With `bot`, the routine is for another bot of your crew. +- When you need the owner's decision or information to go on, above all in + a routine or a task from another bot, when nobody reads your chat, call + `ask_owner`: one short question, with `options` when the answers are few. + It does not wait: carry on with what doesn't depend on it, or end your + turn, and never guess the answer. It arrives later as a message that + starts with \"Answer to your question\". ## Working with your crew diff --git a/crates/botloftd/tests/common/context.rs b/crates/botloftd/tests/common/context.rs index 2e77351d..3e8d5364 100644 --- a/crates/botloftd/tests/common/context.rs +++ b/crates/botloftd/tests/common/context.rs @@ -92,6 +92,7 @@ pub fn kinds(s: &Setup) -> Vec<&'static str> { ChatBody::Reply(_) => "reply", ChatBody::Tool(_) => "tool", ChatBody::Approval(_) => "approval", + ChatBody::Question(_) => "question", ChatBody::Turn(_) => "turn", ChatBody::Notice(_) => "notice", }) diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 86f026a6..55d8f9bd 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -74,6 +74,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "suggest_bot", "schedule_routine", "share_file", + "ask_owner", "browser_open", "browser_look", "browser_click", @@ -129,7 +130,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(19) + Some(20) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/crates/botloftd/tests/questions.rs b/crates/botloftd/tests/questions.rs new file mode 100644 index 00000000..369554a6 --- /dev/null +++ b/crates/botloftd/tests/questions.rs @@ -0,0 +1,206 @@ +//! Questions to the owner (spec 23): `ask_owner` returns at once, the +//! question waits in the box and the chat, and the owner's answer reaches +//! the bot as a message that quotes it. + +mod common; + +use common::bots::{ready_bot, text_of}; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +async fn one_bot() -> ( + TestDaemon, + Client, + Value, + botloftd::runtime::fake::FakeProcess, + Mcp, +) { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (bot, process, mcp) = ready_bot(&t, &mut app, &crew, "Scout").await; + (t, app, bot, process, mcp) +} + +async fn open_questions(app: &mut Client) -> Vec { + let list = app.call("questions.list", json!({})).await.expect("list"); + list.as_array().expect("array").clone() +} + +#[tokio::test] +async fn the_bot_asks_without_waiting_and_the_answer_comes_back_as_a_message() { + let (_t, mut app, bot, process, mut mcp) = one_bot().await; + + let asked = mcp + .tool( + "ask_owner", + json!({ "question": "Which **client** first?", "options": ["Acme", "Globex"] }), + ) + .await + .expect("asked"); + let id = asked["question_id"].as_str().expect("id").to_owned(); + assert!(id.starts_with("qst_"), "{id}"); + let note = asked["note"].as_str().expect("note"); + assert!( + note.contains(&format!("Answer to your question {id}")), + "{note}" + ); + + let changed = app.notification("question.changed").await; + assert_eq!(changed["id"], id); + assert_eq!(changed["status"], "open"); + let open = open_questions(&mut app).await; + assert_eq!(open.len(), 1); + assert_eq!(open[0]["botId"], bot["id"]); + assert_eq!(open[0]["options"], json!(["Acme", "Globex"])); + + let history = app + .call("chat.history", json!({ "botId": bot["id"] })) + .await + .expect("history"); + let card = &history[0]; + assert_eq!(card["body"]["kind"], "question"); + assert_eq!(card["body"]["question"]["text"], "Which **client** first?"); + let lines = app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(lines[0]["lastActivity"]["kind"], "question"); + + let answered = app + .call( + "questions.answer", + json!({ "questionId": id, "answer": " Globex " }), + ) + .await + .expect("answered"); + assert_eq!(answered["status"], "answered"); + assert_eq!(answered["answer"], "Globex"); + assert!(open_questions(&mut app).await.is_empty()); + + let lines = process.wait_lines(1).await; + let text = text_of(&lines[0]); + assert_eq!( + text, + format!("Answer to your question {id}: \"Which **client** first?\"\n\nGlobex") + ); + + let history = app + .call("chat.history", json!({ "botId": bot["id"] })) + .await + .expect("history"); + assert_eq!(history[0]["body"]["kind"], "inbound"); + assert_eq!(history[0]["body"]["message"]["questionId"], id); + assert_eq!(history[0]["body"]["message"]["fromKind"], "owner"); + assert_eq!(history[1]["body"]["question"]["status"], "answered"); + + let again = app + .call( + "questions.answer", + json!({ "questionId": id, "answer": "Acme" }), + ) + .await + .expect_err("closed"); + assert_eq!(again.code, -32003); +} + +#[tokio::test] +async fn a_dismissed_question_closes_without_telling_the_bot() { + let (_t, mut app, _bot, process, mut mcp) = one_bot().await; + let asked = mcp + .tool("ask_owner", json!({ "question": "Coffee?" })) + .await + .expect("asked"); + let dismissed = app + .call( + "questions.dismiss", + json!({ "questionId": asked["question_id"] }), + ) + .await + .expect("dismissed"); + assert_eq!(dismissed["status"], "dismissed"); + assert_eq!(dismissed["answer"], Value::Null); + assert!(open_questions(&mut app).await.is_empty()); + let all = app + .call("questions.list", json!({ "status": "dismissed" })) + .await + .expect("list"); + assert_eq!(all.as_array().expect("array").len(), 1); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!(process.input_lines().is_empty(), "the bot is not told"); +} + +#[tokio::test] +async fn bad_questions_come_back_to_the_bot_and_open_ones_are_capped() { + let (_t, mut app, _bot, _process, mut mcp) = one_bot().await; + for (args, says) in [ + (json!({ "question": " " }), "must not be empty"), + (json!({ "question": "x".repeat(2001) }), "at most 2000"), + ( + json!({ "question": "Pick", "options": ["Only one"] }), + "2 to 5", + ), + ( + json!({ "question": "Pick", "options": ["A", "a"] }), + "repeated", + ), + ( + json!({ "question": "Pick", "options": ["A", "B\nC"] }), + "one line", + ), + ( + json!({ "question": "Pick", "extra": 1 }), + "invalid arguments", + ), + ] { + let err = mcp.tool("ask_owner", args).await.expect_err("refused"); + assert!(err.contains(says), "{err}"); + } + assert!(open_questions(&mut app).await.is_empty()); + + for n in 0..5 { + mcp.tool("ask_owner", json!({ "question": format!("Question {n}?") })) + .await + .expect("asked"); + } + let err = mcp + .tool("ask_owner", json!({ "question": "One more?" })) + .await + .expect_err("capped"); + assert!(err.contains("wait for an answer"), "{err}"); + assert_eq!(open_questions(&mut app).await.len(), 5); +} + +#[tokio::test] +async fn questions_of_an_archived_bot_leave_the_box_and_cannot_be_answered() { + let (_t, mut app, bot, _process, mut mcp) = one_bot().await; + let asked = mcp + .tool("ask_owner", json!({ "question": "Still there?" })) + .await + .expect("asked"); + app.call("bots.archive", json!({ "botId": bot["id"] })) + .await + .expect("archived"); + assert!(open_questions(&mut app).await.is_empty()); + let err = app + .call( + "questions.answer", + json!({ "questionId": asked["question_id"], "answer": "Yes" }), + ) + .await + .expect_err("archived"); + assert_eq!(err.code, -32003); + + app.call("bots.delete", json!({ "botId": bot["id"] })) + .await + .expect("deleted"); + let err = app + .call( + "questions.dismiss", + json!({ "questionId": asked["question_id"] }), + ) + .await + .expect_err("gone"); + assert_eq!(err.code, -32002); +} diff --git a/docs/spec.md b/docs/spec.md index 6153fb88..99754e68 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -347,6 +347,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. | `reply` | `text` | texto do bot | | `tool` | `toolUseId`, `name`, `summary`, `explanation`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `explanation` é o que o bot diz que um comando faz (10.1; ausente nas outras ferramentas, quando o bot não disse nada e nos itens antigos); `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) | | `approval` | `approvalId`, `toolName`, `summary`, `explanation`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1); `explanation` como no item `tool`, guardada só no item do chat | +| `question` | `question` (a `Question`, 23.3) | pergunta do bot ao dono (23.4), regravada quando ele responde ou descarta | | `turn` | `durationMs`, `tokens` (8.7; `null` quando o Claude Code não disse e nos itens antigos), `error` | fim de um turno | | `notice` | `level` (`info`, `warning`, `error`), `code` (`signed_out`, `usage_limit`, `turn_failed`, `model_unavailable`, `compacted`, `auto_compacted`, `compact_failed`; ausente em avisos antigos), `text` | avisos do daemon: limite de uso, login, turno com erro, conversa compactada (8.6). O app escreve os avisos com `code` no idioma do dono; `text` fica em inglês para quem não conhece o código e, em `turn_failed` e `compact_failed`, traz o detalhe do erro | @@ -464,7 +465,7 @@ A resposta sai no stdout como `{"type":"control_response","response":{"subtype": ### 9.3 Texto que o bot recebe -A mensagem do **dono** vai como ele escreveu, sem envelope: é o usuário da sessão falando, com a autoridade de quem digita. +A mensagem do **dono** vai como ele escreveu, sem envelope: é o usuário da sessão falando, com a autoridade de quem digita. A resposta a uma pergunta do bot vem depois de uma linha que cita a pergunta (23.4). Mensagens de **outros bots** e **avisos do daemon** levam um envelope em inglês, como as regras geradas (5.1): @@ -530,6 +531,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | | `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) | | `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | +| `ask_owner` | `question` (até 2 000 caracteres), `options?` (2 a 5, até 100 caracteres cada) | `question_id` e o lembrete de que a resposta chega depois, como mensagem; a tool não espera (23.2) | | `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo | | `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono | @@ -638,15 +640,18 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `tasks.list` | `crewId?, status?` | `Task[]` | | `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle | | `screens.list` | seção 22.4 | as telas HTML do bot | +| `questions.list` | `status?` (`open` se ausente) | `Question[]` de bots e crews ativos, da mais nova à mais velha (23.5) | +| `questions.answer` | `questionId, answer` | `Question`; a resposta vai ao bot como message do dono (23.3) | +| `questions.dismiss` | `questionId` | `Question`; fecha sem avisar o bot (23.3) | | `settings.get` | | `Settings`: `startWithWindows`, `keepAwake` e `approvalWaitMinutes`, como estão no `config.toml` (6) | | `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `crew` (nome da equipe), `archived`, `turns` e `tokens` somados (8.7) | | `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | -`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). `lastReplyAt` é quando o bot terminou a última resposta (`null` antes da primeira), lido à parte porque a linha da conversa pode já ser outra coisa (uma ferramenta, a mensagem de outro bot); o app marca a conversa como não lida com ele (15.1). +`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `question`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). `lastReplyAt` é quando o bot terminou a última resposta (`null` antes da primeira), lido à parte porque a linha da conversa pode já ser outra coisa (uma ferramenta, a mensagem de outro bot); o app marca a conversa como não lida com ele (15.1). ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.rules` (10.1), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). +`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.rules` (10.1), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3), e das perguntas `question.changed` (23.5). ### 11.4 Erros @@ -662,7 +667,7 @@ Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-3260 ## 12. Dados (SQLite) -Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`, `temp_store=MEMORY`, cache de 16 MB e `PRAGMA optimize` ao abrir. Com WAL, `NORMAL` não corrompe o banco; uma queda de energia pode perder os últimos commits, mas nenhum commit espera o disco (com `FULL`, cada item de chat pagava um flush no Windows). As consultas quentes passam pelo cache de statements do rusqlite, e toda coluna que aponta para outra tabela tem índice: as tabelas nunca encolhem, e sem índice a exclusão varre a tabela filha uma vez por linha apagada. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`approvals`, `chat_items`, `browser_sites`, `allow_rules`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. +Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`, `temp_store=MEMORY`, cache de 16 MB e `PRAGMA optimize` ao abrir. Com WAL, `NORMAL` não corrompe o banco; uma queda de energia pode perder os últimos commits, mas nenhum commit espera o disco (com `FULL`, cada item de chat pagava um flush no Windows). As consultas quentes passam pelo cache de statements do rusqlite, e toda coluna que aponta para outra tabela tem índice: as tabelas nunca encolhem, e sem índice a exclusão varre a tabela filha uma vez por linha apagada. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`questions`, `approvals`, `chat_items`, `browser_sites`, `allow_rules`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. | Tabela | Colunas principais | |---|---| @@ -678,6 +683,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time | `routines`, `routine_runs` | seção 20.7; `messages` ganha `routine_id` | | `browser_sites` | `bot_id, host, allowed_at`: sites que o dono deixou o bot usar no navegador (21.5) | | `allow_rules` | `id (rul_), bot_id, tool_name, kind, value, created_at`, única por `(bot_id, tool_name, kind, value)`: o que o dono permitiu de vez para o bot (10.1) | +| `questions` | seção 23.7; `messages` ganha `question_id` | Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `chat_items(created_at) WHERE kind = 'turn'` (8.7), `attachments(message_id)`. @@ -983,7 +989,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e ## 18. Fora do MVP (ordem sugerida) 1. Rotinas: horário semanal, intervalo ou cron, com fuso, sobreposição e horários perdidos. Desenho na seção 20. -2. Caixa de perguntas ao owner (bot pergunta, owner responde, resposta volta como mensagem). +2. Caixa de perguntas ao owner (bot pergunta, owner responde, resposta volta como mensagem). Desenho na seção 23. 3. "Permitir sempre" nas aprovações, gravado como regra do bot. Feito (10.1). 4. Busca FTS5 em mensagens e no chat. 5. Histórico de versões das instruções do bot. @@ -1457,3 +1463,80 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com | **T1** Telas no daemon | rascunho a partir do stream, JSON parcial, `/view` com sandbox, `screens.list`, `screen.draft`, regras do bot, testes com `FakeRuntime` | um `Write` de `.html` transmitido pelo `FakeRuntime` aparece como rascunho em `/view` e vira o arquivo depois do resultado | | **T2** Área de design | painel de telas, quadro com zoom, tela em foco, aparelhos, escrita ao vivo, "Ver em telas" no chat, textos nos três idiomas | ver pelo app um bot real escrever uma tela e ela se montar enquanto ele escreve | | **T3** Cursor do bot | script do cursor nos rascunhos, cursor e moldura na prancheta e na tela em foco, a prévia com o mesmo script | ver pelo app um bot real escrever uma tela com o cursor dele seguindo cada parte que nasce | + +## 23. Perguntas ao dono + +Status: **P1 e P2 implementados** (23.9). É o item 2 da seção 18. + +### 23.1 O que é + +Um bot que precisa de uma decisão ou de uma informação do dono pergunta com a tool `ask_owner` e **não espera**: a pergunta vai para a caixa de perguntas do app e para o chat do bot, e o bot segue com o que não depende dela ou termina o turno. Quando o dono responde, a resposta chega ao bot como uma mensagem do dono, num turno novo. + +É diferente de uma aprovação (10.1), que segura a ferramenta até o dono decidir e expira no prazo. Uma pergunta não tem prazo: um bot de rotina (20) pergunta às 3 da manhã, e o dono responde quando acordar. É por isso que ela não usa o caminho das aprovações nem deixa o bot em `needs_approval`. + +### 23.2 A tool + +`ask_owner {question, options?}`: + +- `question`: o que o bot quer saber, para o dono, na língua dele; até 2 000 caracteres, em markdown. +- `options`: de 2 a 5 respostas prontas, cada uma até 100 caracteres, sem repetir. O dono pode escolher uma ou escrever outra coisa. +- **Saída:** `question_id` e o lembrete de que a resposta chega depois, como mensagem que começa com `Answer to your question`, e de que o bot não deve esperar nem adivinhar a resposta: segue com o que não depende dela ou termina o turno. +- **Limite:** até 5 perguntas abertas por bot. A sexta volta como erro ao bot, que deve esperar uma resposta antes de perguntar de novo. Campo inválido também volta como erro, sem pergunta. +- Vale em qualquer modo de permissão, também em `bypass_permissions`: perguntar não faz nada no computador. +- A chamada aparece no chat como o item `tool` de sempre; o app não mostra essa linha, porque o cartão da pergunta (23.4) já diz tudo. + +**Regras do bot** (5.1): quando precisar de uma decisão ou informação do dono para continuar, e principalmente numa rotina ou numa task de outro bot, em que ninguém lê o chat na hora, pergunte com `ask_owner` em vez de só escrever a pergunta na resposta. Uma pergunta por assunto, curta, com `options` quando as respostas possíveis forem poucas. Não espere: siga com o resto ou termine o turno. Nunca peça senha, código ou dado de cartão: no navegador, isso é `browser_ask_owner` (21.10). + +### 23.3 Estados + +`Question`: `id` (`qst_`), `crewId`, `botId`, `text`, `options`, `status`, `answer`, `createdAt` e `answeredAt`. + +| `status` | Quando | +|---|---| +| `open` | o bot perguntou e o dono ainda não respondeu | +| `answered` | o dono respondeu; `answer` guarda a resposta | +| `dismissed` | o dono descartou sem responder | + +- **Responder** (`questions.answer {questionId, answer}`): a resposta é validada como uma mensagem do dono (9.1) e vira, numa transação, a resposta gravada, a pergunta `answered` e uma message do dono para o bot com `question_id`, entregue pelo courier como qualquer outra (bot pausado recebe quando voltar). +- **Descartar** (`questions.dismiss {questionId}`): fecha a pergunta e **não** avisa o bot, para não gastar um turno com isso. Quem quer que o bot saiba responde, mesmo que seja "deixa pra lá". +- Só uma pergunta `open` pode ser respondida ou descartada; outra dá `conflict`. Bot ou crew arquivados também dão `conflict`. +- A conversa normal não fecha a pergunta: o dono pode falar do assunto no chat e depois descartar. + +### 23.4 No chat + +- **Item `question`** (8.2): `{question}`, a `Question` inteira, criado quando o bot pergunta e regravado quando ela muda. A linha da conversa (`lastActivity`) ganha o `kind` `question`, com o texto da pergunta. +- **A resposta** chega como `inbound` (a message do dono, com `questionId`). O bot a lê assim (9.3), sem o envelope `[botloft]`, porque é o dono falando: + +``` +Answer to your question qst_01J9Z...: "" + + +``` + +### 23.5 Caixa de perguntas + +`questions.list {status?}` devolve as perguntas de bots e crews ativos, da mais nova à mais velha; sem `status`, só as `open`. As de bots ou crews arquivados ficam de fora e voltam se eles forem restaurados. `question.changed {question}` avisa o app de toda pergunta nova ou que mudou, para a caixa não depender do chat de cada bot estar carregado. + +### 23.6 App (P2) + +- **Cartão no chat:** " pergunta", o texto da pergunta (markdown, como uma resposta do bot), as opções como botões e um campo para escrever outra resposta, com **Responder** e **Descartar**. Respondida, o cartão mostra a resposta e quando; descartada, "Descartada". Opção escolhida responde na hora, com o texto da opção. +- **Caixa:** "Perguntas", no topo da barra lateral, abaixo de "Equipes", com o número de perguntas abertas na cor de aviso. Abre no meio da janela a lista das perguntas abertas, cada uma com o rosto e o nome do bot, a crew, a hora e o mesmo cartão para responder ali mesmo, e "Abrir conversa" para ir ao chat do bot. Sem perguntas: o mascote e "Nenhuma pergunta esperando você". +- Uma pergunta aberta conta como algo que espera o dono: marca o ícone na barra de tarefas e o ponto do botão da barra lateral escondida (15.1, 15.2). +- Textos nos três idiomas (15.6). + +### 23.7 Dados + +Tabela `questions`: `id, crew_id, bot_id, chat_item_id, text, options (JSON), status, answer, created_at, answered_at`, com índice em `(status, created_at)` e em `bot_id`. `messages` ganha `question_id`. Excluir um bot (7.6) solta `messages.question_id` das perguntas dele e apaga as perguntas, antes dos itens do chat. + +### 23.8 Fora desta etapa + +- Notificação do Windows quando chega uma pergunta. +- Pergunta com prazo, ou que cai sozinha depois de um tempo. +- Responder com anexos. + +### 23.9 Marcos + +| Marco | Entrega | Pronto quando | +|---|---|---| +| **P1** Perguntas no daemon | migration, tool `ask_owner`, item `question`, `questions.list`, `questions.answer`, `questions.dismiss`, `question.changed`, a resposta como message, regras do bot, exclusão, testes com `FakeRuntime` | com `FakeRuntime`, um bot pergunta, a tool volta na hora, o dono responde e o bot recebe a resposta pelo stdin com a pergunta citada | +| **P2** App | cartão no chat, caixa de perguntas na barra lateral, marca de espera, textos nos três idiomas | com o Claude Code real, um bot pergunta, o dono responde pela caixa e o bot continua o trabalho com a resposta |
{words.intro}
{words.emptyTitle}
{words.emptyBody}
+ + + + {q.asks(bot)} +
+ {question.answer} +