Skip to content

Commit ff2d461

Browse files
authored
Merge pull request #72 from httpsphl/feat/schedule-routine
feat(routines): bots ask for routines with schedule_routine
2 parents 4191fe1 + d6f48af commit ff2d461

24 files changed

Lines changed: 1005 additions & 20 deletions

File tree

‎app/src/features/chat/ApprovalCard.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Button } from "../../ui/Button";
99
import { Details } from "../../ui/Details";
1010
import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
1111
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
12+
import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard";
1213
import { commandOf, isCommand } from "./command";
1314
import { PLAN_TOOL, PlanCard } from "./PlanCard";
1415
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
@@ -66,6 +67,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
6667
return <PlanCard approval={approval} bot={bot} />;
6768
case SUGGEST_TOOL:
6869
return <SuggestionCard approval={approval} bot={bot} />;
70+
case ROUTINE_TOOL:
71+
return <RoutineRequestCard approval={approval} bot={bot} />;
6972
case SITE_TOOL:
7073
return <SiteCard approval={approval} bot={bot} />;
7174
case HELP_TOOL:

‎app/src/features/chat/ToolLines.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// and the output (spec 15.3).
44

55
import {
6+
AlarmClock,
67
Check,
78
ChevronRight,
89
FilePen,
@@ -51,6 +52,7 @@ const ICONS: Record<string, LucideIcon> = {
5152
Agent: Users,
5253
send_message: Send,
5354
suggest_bot: UserPlus,
55+
schedule_routine: AlarmClock,
5456
browser_ask_owner: Hand,
5557
};
5658

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// A bot asking for a routine (spec 20.12), for itself or for another bot
2+
// of its crew. The owner can change the name, what to do and when before
3+
// creating it, or say no with a note for the bot. A refusal the daemon
4+
// explains with a reason is worded here. Once answered it shrinks to one
5+
// line.
6+
7+
import { AlarmClock, Ban, Check, ChevronRight, TimerOff } from "lucide-react";
8+
import { useId, useState } from "react";
9+
import { useT } from "../../i18n";
10+
import { errorText } from "../../lib/api";
11+
import { type ApprovalItem, type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
12+
import { RpcError } from "../../lib/rpc";
13+
import { useApi, useApp } from "../../store/context";
14+
import { Button } from "../../ui/Button";
15+
import { TextArea, TextField } from "../../ui/Field";
16+
import { describeSchedule, systemZone } from "./describe";
17+
import { formOf, type RoutineFields, type RoutineForm, scheduleOf } from "./form";
18+
import { MoreOptions } from "./MoreOptions";
19+
import { ScheduleFields } from "./ScheduleFields";
20+
21+
export const ROUTINE_TOOL = "mcp__botloft__schedule_routine";
22+
23+
/** The routine asked for, and the bot it is for by handle. */
24+
interface Asked {
25+
routine: RoutineFields;
26+
bot?: string;
27+
}
28+
29+
/** The routine in the request's input; defaults for what is cut short. */
30+
export function askedOf(input: string): Asked {
31+
const blank: RoutineFields = {
32+
name: "",
33+
prompt: "",
34+
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "09:00" },
35+
timezone: systemZone(),
36+
overlap: "skip",
37+
missed: "run_once",
38+
};
39+
try {
40+
const { bot, ...fields } = JSON.parse(input) as Partial<RoutineFields> & { bot?: string };
41+
return { routine: { ...blank, ...fields }, ...(bot && { bot }) };
42+
} catch {
43+
return { routine: { ...blank, prompt: input } };
44+
}
45+
}
46+
47+
function formFields(form: RoutineForm): RoutineFields {
48+
return {
49+
name: form.name,
50+
prompt: form.prompt,
51+
schedule: scheduleOf(form),
52+
timezone: form.timezone,
53+
overlap: form.overlap,
54+
missed: form.missed,
55+
};
56+
}
57+
58+
/** The bot the routine is for: the one that asked, or one of its crew. */
59+
function useRunner(bot: Bot, handle: string | undefined): Bot {
60+
const bots = useApp((state) => state.bots);
61+
if (!handle) {
62+
return bot;
63+
}
64+
const found = Object.values(bots).find(
65+
(other) => other.crewId === bot.crewId && other.handle === handle,
66+
);
67+
return found ?? { ...bot, name: `@${handle}` };
68+
}
69+
70+
export function RoutineRequestCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
71+
const t = useT();
72+
const r = t.chat.routineRequest;
73+
const d = t.routines.dialog;
74+
const api = useApi();
75+
const asked = askedOf(approval.input);
76+
const runner = useRunner(bot, asked.bot);
77+
const [form, setForm] = useState<RoutineForm>(() => formOf(asked.routine));
78+
const [note, setNote] = useState("");
79+
const [busy, setBusy] = useState(false);
80+
const [error, setError] = useState<string | null>(null);
81+
const noteId = useId();
82+
if (approval.status !== "pending") {
83+
return <Answered approval={approval} />;
84+
}
85+
const change = (patch: Partial<RoutineForm>) => setForm((current) => ({ ...current, ...patch }));
86+
87+
const answer = async (allow: boolean) => {
88+
setBusy(true);
89+
setError(null);
90+
const edited = formFields(form);
91+
const changed = JSON.stringify(edited) !== JSON.stringify(formFields(formOf(asked.routine)));
92+
const trimmed = note.trim();
93+
try {
94+
await api.call("approvals.answer", {
95+
approvalId: approval.approvalId,
96+
allow,
97+
...(!allow && trimmed ? { note: trimmed } : {}),
98+
...(allow && changed ? { input: JSON.stringify(edited) } : {}),
99+
});
100+
} catch (failure) {
101+
const reason = failure instanceof RpcError ? failure.reason : undefined;
102+
const worded =
103+
reason && reason in d.reasons ? d.reasons[reason as keyof typeof d.reasons] : null;
104+
setError(worded ?? `${allow ? r.createFailed : r.declineFailed}: ${errorText(failure)}`);
105+
}
106+
setBusy(false);
107+
};
108+
109+
const title = asked.bot ? r.titleFor(bot.name, runner.name) : r.title(bot.name);
110+
return (
111+
<section
112+
aria-label={title}
113+
className="my-1 overflow-hidden rounded-2xl border border-line bg-panel shadow-sm animate-attention"
114+
>
115+
<p className="flex items-center gap-2.5 border-line border-b px-4 py-3 font-semibold text-sm">
116+
<span className="grid h-7 w-7 place-items-center rounded-full bg-accent/12 text-accent">
117+
<AlarmClock aria-hidden size={15} />
118+
</span>
119+
{title}
120+
</p>
121+
<div className="flex flex-col gap-4 px-4 py-3">
122+
<TextField
123+
label={d.name}
124+
value={form.name}
125+
max={80}
126+
onChange={(event) => change({ name: event.target.value })}
127+
/>
128+
<TextArea
129+
label={d.prompt(runner.name)}
130+
value={form.prompt}
131+
max={FIELD_LIMITS.message}
132+
rows={4}
133+
onChange={(event) => change({ prompt: event.target.value })}
134+
/>
135+
<ScheduleFields form={form} change={change} />
136+
<MoreOptions form={form} change={change} />
137+
<p className="text-muted text-xs">{r.explain(runner.name)}</p>
138+
{error && (
139+
<p role="alert" className="text-danger text-sm">
140+
{error}
141+
</p>
142+
)}
143+
</div>
144+
<div className="border-line border-t bg-canvas/40 px-4 py-3">
145+
<label htmlFor={noteId} className="sr-only">
146+
{r.noteLabel(bot.name)}
147+
</label>
148+
<textarea
149+
id={noteId}
150+
rows={2}
151+
value={note}
152+
onChange={(event) => setNote(event.target.value)}
153+
placeholder={r.notePlaceholder(bot.name)}
154+
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"
155+
/>
156+
<div className="mt-2.5 flex flex-wrap gap-2">
157+
<Button
158+
variant="primary"
159+
icon={Check}
160+
disabled={busy || !form.name.trim() || !form.prompt.trim()}
161+
onClick={() => answer(true)}
162+
>
163+
{r.create}
164+
</Button>
165+
<Button icon={Ban} disabled={busy} onClick={() => answer(false)}>
166+
{r.decline}
167+
</Button>
168+
</div>
169+
</div>
170+
</section>
171+
);
172+
}
173+
174+
/** One line once answered; what was asked stays a click away. */
175+
function Answered({ approval }: { approval: ApprovalItem }) {
176+
const t = useT();
177+
const r = t.chat.routineRequest;
178+
const { routine } = askedOf(approval.input);
179+
const name = routine.name || t.routines.dialog.name;
180+
const [Icon, text, tone] =
181+
approval.status === "allowed"
182+
? [Check, r.created(name), "text-ok"]
183+
: approval.status === "denied"
184+
? [Ban, r.declined(name), "text-danger"]
185+
: [TimerOff, r.expired(name), "text-quiet"];
186+
return (
187+
<details className="group">
188+
<summary className="flex min-w-0 cursor-default items-center gap-2 rounded-lg px-1.5 py-1 text-sm hover:bg-sunken">
189+
<Icon aria-hidden size={14} className={`shrink-0 ${tone}`} />
190+
<span className="shrink-0 font-medium">{text}</span>
191+
{approval.note && (
192+
<span className="truncate text-muted text-xs">{`“${approval.note}”`}</span>
193+
)}
194+
<ChevronRight
195+
aria-hidden
196+
size={13}
197+
className="shrink-0 text-muted transition-transform group-open:rotate-90"
198+
/>
199+
</summary>
200+
<dl
201+
className="mt-1.5 ml-6 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-xl border border-line bg-panel px-4 py-3 text-sm"
202+
data-selectable
203+
>
204+
<dt className="text-muted">{t.routines.dialog.when}</dt>
205+
<dd>{describeSchedule(routine.schedule, t.routines.when)}</dd>
206+
<dt className="text-muted">{t.routines.dialog.name}</dt>
207+
<dd>{routine.name}</dd>
208+
<dd className="col-span-2 whitespace-pre-wrap">{routine.prompt}</dd>
209+
</dl>
210+
</details>
211+
);
212+
}

‎app/src/features/routines/form.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,14 @@ export function blankForm(): RoutineForm {
4545
};
4646
}
4747

48+
/** What the form is filled from: a routine, or one a bot asks for. */
49+
export type RoutineFields = Pick<
50+
Routine,
51+
"name" | "prompt" | "schedule" | "timezone" | "overlap" | "missed"
52+
>;
53+
4854
/** The form as it opens for an existing routine. */
49-
export function formOf(routine: Routine): RoutineForm {
55+
export function formOf(routine: RoutineFields): RoutineForm {
5056
const form: RoutineForm = {
5157
...blankForm(),
5258
name: routine.name,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { ROUTINE_TOOL } from "../../lib/fakeChat";
5+
import { crewOpened, openBot, renderApp } from "../../test/app";
6+
7+
afterEach(cleanup);
8+
9+
const INBOX = {
10+
name: "Morning inbox",
11+
prompt: "Read the new mail and summarize what is urgent.",
12+
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "08:00" },
13+
timezone: "America/Sao_Paulo",
14+
};
15+
16+
/** A crew with its chief and a bot "Mail", open on the chief's chat. */
17+
async function openChief() {
18+
const fake = new FakeBotloft();
19+
const crew = await fake.call("crews.create", {
20+
name: "Office",
21+
lead: { name: "Chief", role: "Leads the crew", instructions: "Run the office" },
22+
});
23+
const chiefId = crew.leadBotId as string;
24+
const mail = await fake.call("bots.create", {
25+
crewId: crew.id,
26+
name: "Mail",
27+
role: "Reads the inbox",
28+
instructions: "",
29+
});
30+
fake.setBotState(chiefId, "idle");
31+
renderApp(fake);
32+
await crewOpened("Office");
33+
openBot("Chief");
34+
await screen.findByRole("list", { name: "Messages" });
35+
return { fake, chiefId, mail };
36+
}
37+
38+
function ask(fake: FakeBotloft, botId: string, input: object, title: string) {
39+
act(() => {
40+
fake.chat.ask(botId, ROUTINE_TOOL, "Morning inbox", JSON.stringify(input));
41+
});
42+
return screen.getByRole("region", { name: title });
43+
}
44+
45+
const answer = (fake: FakeBotloft) =>
46+
fake.calls.find((call) => call.method === "approvals.answer")?.params;
47+
48+
const routines = (fake: FakeBotloft) => [...fake.routines.routines.values()];
49+
50+
describe("a bot asking for a routine", () => {
51+
test("the owner creates it as asked", async () => {
52+
const { fake, chiefId } = await openChief();
53+
const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
54+
expect((within(card).getByLabelText("Name") as HTMLInputElement).value).toBe("Morning inbox");
55+
expect(within(card).getByRole("radio", { name: "Weekdays" })).toHaveProperty("checked", true);
56+
fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));
57+
58+
expect(await screen.findByText("You created the routine Morning inbox")).toBeDefined();
59+
expect(answer(fake)).toEqual({ approvalId: expect.any(String), allow: true });
60+
expect(routines(fake)).toMatchObject([
61+
{ botId: chiefId, name: "Morning inbox", timezone: "America/Sao_Paulo" },
62+
]);
63+
});
64+
65+
test("the owner can change when it runs before creating it", async () => {
66+
const { fake, chiefId } = await openChief();
67+
const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
68+
fireEvent.change(within(card).getByLabelText("Name"), { target: { value: "Inbox at nine" } });
69+
fireEvent.click(within(card).getByRole("radio", { name: "Every day" }));
70+
fireEvent.change(within(card).getByLabelText("At"), { target: { value: "09:00" } });
71+
fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));
72+
73+
expect(await screen.findByText("You created the routine Inbox at nine")).toBeDefined();
74+
const sent = JSON.parse(String((answer(fake) as { input?: string } | undefined)?.input));
75+
expect(sent).toMatchObject({
76+
name: "Inbox at nine",
77+
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5, 6, 7], time: "09:00" },
78+
});
79+
expect(routines(fake)[0]?.schedule).toEqual(sent.schedule);
80+
});
81+
82+
test("a routine for another bot of the crew goes to that bot", async () => {
83+
const { fake, chiefId, mail } = await openChief();
84+
const card = ask(
85+
fake,
86+
chiefId,
87+
{ ...INBOX, bot: mail.handle },
88+
"Chief wants to set up a routine for Mail",
89+
);
90+
expect(within(card).getByLabelText("What should Mail do?")).toBeDefined();
91+
fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));
92+
93+
await screen.findByText("You created the routine Morning inbox");
94+
expect(routines(fake)).toMatchObject([{ botId: mail.id }]);
95+
});
96+
97+
test("a no goes back with the owner's note and creates nothing", async () => {
98+
const { fake, chiefId } = await openChief();
99+
const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
100+
fireEvent.change(within(card).getByPlaceholderText(/If you say no, tell Chief why/), {
101+
target: { value: "I check it myself" },
102+
});
103+
fireEvent.click(within(card).getByRole("button", { name: "Not now" }));
104+
105+
expect(await screen.findByText("You said no to the routine Morning inbox")).toBeDefined();
106+
expect(answer(fake)).toMatchObject({ allow: false, note: "I check it myself" });
107+
expect(routines(fake)).toEqual([]);
108+
});
109+
});

‎app/src/i18n/en/chat.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,21 @@ export const chat = {
210210
declined: (name: string) => `You said no to ${name}`,
211211
expired: (name: string) => `${name} was not answered in time`,
212212
},
213+
routineRequest: {
214+
title: (bot: string) => `${bot} wants to set up a routine`,
215+
titleFor: (bot: string, runner: string) => `${bot} wants to set up a routine for ${runner}`,
216+
explain: (runner: string) =>
217+
`A routine makes ${runner} work on its own at set times. It shows in the Routines tab, where you can pause or delete it.`,
218+
noteLabel: (bot: string) => `What to tell ${bot} if you say no`,
219+
notePlaceholder: (bot: string) => `If you say no, tell ${bot} why (optional)`,
220+
create: "Create routine",
221+
decline: "Not now",
222+
createFailed: "Could not create the routine",
223+
declineFailed: "Could not send the answer",
224+
created: (name: string) => `You created the routine ${name}`,
225+
declined: (name: string) => `You said no to the routine ${name}`,
226+
expired: (name: string) => `The routine ${name} was not answered in time`,
227+
},
213228
plan: {
214229
ready: (bot: string) => `${bot} made a plan and wants to go ahead`,
215230
noteLabel: (bot: string) => `What ${bot} should change in the plan`,

0 commit comments

Comments
 (0)