Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 115 additions & 5 deletions src/bundled/bestie/BestieTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import type { Agent, Agents } from "../../features/agents/service";
import type { Agent, AgentChange, Agents } from "../../features/agents/service";
import type { RelayData } from "../../features/relay/service";
import { writeView } from "../../shared/view-state";
import { bestieTab } from "./BestieTab";
import { memory, withFeature } from "./features";
import { validateObject } from "../../features/agents/attention";
import { checkinHourly, memory, withFeature } from "./features";

afterEach(() => {
cleanup();
Expand All @@ -17,6 +18,7 @@ afterEach(() => {
const OWNER = "f".repeat(64);
const SCOPE = `https://relay.example:${OWNER}`;
const NO_CHANNELS = { channels: [] };
const DM = "0f0f0f0f-0000-4000-8000-000000000001";
const member = (name: string, digit: string): Agent =>
({
key: digit.repeat(64),
Expand All @@ -26,7 +28,8 @@ const member = (name: string, digit: string): Agent =>
owner: OWNER,
relay: "wss://relay.example",
place: "here",
}) as Agent;
attention: {},
}) as unknown as Agent;
const ada = member("Ada", "a");
const bea = member("Bea", "b");

Expand Down Expand Up @@ -66,6 +69,7 @@ function community(saved: string | undefined, agents = [ada, bea]) {
scope: SCOPE,
session: {
viewer: OWNER,
directMessages: { open: vi.fn(async () => DM) },
channels: {
list: () => NO_CHANNELS,
subscribeList: () => () => {},
Expand Down Expand Up @@ -280,8 +284,114 @@ it("offers no switch while the agent's instructions can't be read here", () => {
/>,
);
expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull();
// Each setting's row says why, check-ins too.
expect(
screen.getByText("This agent's instructions could not be read"),
).toBeVisible();
screen.getAllByText("This agent's instructions could not be read"),
).toHaveLength(2);
expect(save).not.toHaveBeenCalled();
});

it("checks in every hour only while memory is on, with a timer in the owner's DM", async () => {
const user = userEvent.setup();
const { relay } = community(ada.pubkey);
const { agents, save } = host();
const Tab = bestieTab(relay, agents).component;
const base = { instructions: "head-1" };
const remembering = withFeature("Be brief.", memory, true);
const off = {
...ada,
config: { instructions: "Be brief." },
configBase: base,
};
const { rerender } = render(<Tab agent={off as Agent} />);
const hourly = () => screen.getByRole("switch", { name: "Every hour" });
// Memory is off, so check-ins can't be turned on.
expect(hourly()).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Turn on Remembers you first.")).toBeVisible();

const on = { ...off, config: { instructions: remembering } } as Agent;
rerender(<Tab agent={on} />);
expect(hourly()).not.toBeChecked();
await user.click(hourly());
// The timer first, then the instructions that say what it is for.
expect(save).toHaveBeenCalledTimes(2);
const [[, timers], [, instructions]] = save.mock.calls as [
[string, AgentChange],
[string, AgentChange],
];
expect(instructions).toEqual({
config: { instructions: withFeature(remembering, checkinHourly, true) },
base,
});
// Turning it on lets the agent's Attention run.
expect(timers.attentionEnabled).toBe(true);
expect(timers.attention?.["watch/bestie-hourly-check-in"]).toMatchObject({
type: "timer",
interest_id: "bestie-hourly-check-in",
interval_secs: 3600,
max_occurrences: null,
expires_at: null,
channel: DM,
enabled: true,
});
// The objects are ones Attention accepts.
for (const [slug, value] of Object.entries(timers.attention ?? {}))
expect(validateObject(slug, value)).toBeUndefined();

// On means the section and the timer are both there.
const checking = {
...on,
config: { instructions: withFeature(remembering, checkinHourly, true) },
attention: {
"interest/bestie-hourly-check-in": {},
"watch/bestie-hourly-check-in": {},
},
} as unknown as Agent;
rerender(<Tab agent={checking} />);
expect(hourly()).toBeChecked();
// With Attention not running here, as while Watching is off, it says so.
expect(screen.getByText(/^Paused: watches aren't running/)).toBeVisible();
rerender(<Tab agent={{ ...checking, attentionEnabled: true } as Agent} />);
expect(screen.queryByText(/^Paused/)).toBeNull();
rerender(<Tab agent={{ ...checking, attention: {} } as Agent} />);
expect(hourly()).not.toBeChecked();
rerender(<Tab agent={checking} />);

const removed = {
attention: {
"interest/bestie-hourly-check-in": null,
"watch/bestie-hourly-check-in": null,
},
};
// Memory off takes check-ins with it, and leaves Attention's switch alone.
save.mockClear();
await user.click(screen.getByRole("switch", { name: "Remembers you" }));
expect(save.mock.calls).toEqual([
[ada.key, removed],
[ada.key, { config: { instructions: "Be brief." }, base }],
]);

// Check-ins off removes only their section and timer.
save.mockClear();
await user.click(hourly());
expect(save.mock.calls).toEqual([
[ada.key, removed],
[ada.key, { config: { instructions: remembering.trimEnd() }, base }],
]);

// Left on after memory went some other way, it can still be turned off.
rerender(
<Tab
agent={
{
...checking,
config: {
instructions: withFeature("Be brief.", checkinHourly, true),
},
} as Agent
}
/>,
);
expect(hourly()).toBeChecked();
expect(hourly()).not.toHaveAttribute("aria-disabled", "true");
});
131 changes: 117 additions & 14 deletions src/bundled/bestie/BestieTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ import {
restoredFeature,
withFeature,
} from "./features";
import {
type AttentionValue,
interestSlug,
watchSlug,
} from "../../features/agents/attention";
import type { RelaySession } from "../../features/relay/session";
import { PrivateChannels } from "./PrivateChannels";

type TabAgents = Pick<Agents, "save" | "types">;
Expand Down Expand Up @@ -121,6 +127,8 @@ function BestieTab({
}) {
const { session, scope } = useRelayConnection(relay);
const bestie = useBestieChoice(session, scope ?? "");
// One save at a time: each switch saves from the instructions it shows.
const [saving, setSaving] = useState(false);
const others = bestie.choices.selectable.filter(
(choice) => choice.pubkey !== agent.pubkey,
);
Expand Down Expand Up @@ -158,39 +166,67 @@ function BestieTab({
</SettingsGroup>
<InlineHeader title="Settings" />
<SettingsGroup>
{FEATURES.map((feature) => (
{FEATURES.filter((feature) => !feature.timer).map((feature) => (
<FeatureSwitch
key={feature.id}
agents={agents}
agent={agent}
session={session}
feature={feature}
saving={saving}
setSaving={setSaving}
/>
))}
</SettingsGroup>
<InlineHeader title="Check-ins" />
<SettingsGroup>
{FEATURES.filter((feature) => feature.timer).map((feature) => (
<FeatureSwitch
key={feature.id}
agents={agents}
agent={agent}
session={session}
feature={feature}
saving={saving}
setSaving={setSaving}
/>
))}
<PreferenceRow
title="Check-ins"
subtitle="Every hour. Not set up yet."
/>
</SettingsGroup>
<PrivateChannels session={session} bestie={agent.pubkey} />
{bestie.view}
</div>
);
}

/** A feature's switch: on while the agent's instructions hold its section.
* Saving revises the synced instructions the tab shows, as their editor does.
* A section edited by hand where it starts or ends shows that instead, with
* Restore when what is left of its markers shows where it was. */
/** Whether `agent` has `feature` on: its section, and its timer if it has
* one. Half of one, such as a timer deleted in Watching, reads as off. */
function featureOn(agent: Agent, instructions: string, feature: Feature) {
return (
hasFeature(instructions, feature) &&
(!feature.timer || !!agent.attention[watchSlug(feature.timer.id)])
);
}

/** A feature's switch. Saving revises the synced instructions the tab shows,
* as their editor does, with the feature's timer in the same change. Off also
* turns off the features that require this one. A section edited by hand
* where it starts or ends shows that instead, with Restore when what is left
* of its markers shows where it was. */
function FeatureSwitch({
agents,
agent,
session,
feature,
saving,
setSaving,
}: {
agents: TabAgents;
agent: Agent;
session: RelaySession;
feature: Feature;
saving: boolean;
setSaving(saving: boolean): void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
// A type without `adopt` has no instructions, as `updateKind` says.
if (!agents.types().find(({ key }) => key === agent.type)?.adopt)
Expand All @@ -209,6 +245,9 @@ function FeatureSwitch({
const config = (agent.config ?? {}) as { instructions?: unknown };
const instructions =
typeof config.instructions === "string" ? config.instructions : "";
const required = FEATURES.find(({ id }) => id === feature.requires);
const blocked = !!required && !featureOn(agent, instructions, required);
const checked = featureOn(agent, instructions, feature);
const run = async (task: () => Promise<void>) => {
setSaving(true);
setError(undefined);
Expand All @@ -232,7 +271,62 @@ function FeatureSwitch({
throw new Error(
`${feature.title} was edited in Instructions; restore it first.`,
);
await saveInstructions(withFeature(instructions, feature, on));
const off = on
? []
: [
feature,
...FEATURES.filter(({ requires }) => requires === feature.id),
];
const attention: Record<string, AttentionValue | null> = {};
for (const { timer } of off)
for (const slug of timer
? [interestSlug(timer.id), watchSlug(timer.id)]
: [])
if (agent.attention[slug]) attention[slug] = null;
const timer = on && feature.timer;
if (timer) {
// The owner's 1:1 DM with the agent: opening it is idempotent.
const channel = await session.directMessages.open(
[agent.pubkey],
AbortSignal.timeout(30_000),
);
attention[interestSlug(timer.id)] = {
type: "interest",
instructions: timer.interest,
};
attention[watchSlug(timer.id)] = {
type: "timer",
interest_id: timer.id,
prompt: timer.prompt,
enabled: true,
interval_secs: timer.everySecs,
armed_at: Math.floor(Date.now() / 1000),
max_occurrences: null,
expires_at: null,
channel,
};
}
// Attention first: if the instructions then fail to save, the half left
// reads as off, and the switch can finish either way.
if (Object.keys(attention).length)
await agents.save(agent.key, {
attention,
// Its timer runs only while the agent's Attention is on. Off leaves
// it on: the agent's other watches may need it.
...(timer && { attentionEnabled: true }),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 — Do not leave an enabled timer after the instruction save fails. This first save commits the hourly timer and enables the agent’s Attention before the following config save. A rejected/conflicting instruction revision leaves those effects in place, but featureOn reports unchecked because the Hourly upkeep section is absent. The scheduler (runner.ts:2012–2074) admits the saved timer independently of that section, so, with Watching on and destination membership confirmed, the failed enable can keep consuming tokens hourly. Clicking the unchecked switch retries On; it does not remove the live timer. Preserve the intended timer-first ordering by keeping staged work disabled until instructions are confirmed, or compensate the owned timer on failure and expose explicit partial-state cleanup. Do not enable unrelated watches until the operation succeeds. Cover instruction rejection and both retry/cancel recovery; reporting unchecked alone is not a safe recovery state.

await agents.save(agent.key, {
config: {
...config,
instructions: on
? withFeature(instructions, feature, true)
: off.reduce(
(text, item) => withFeature(text, item, false),
instructions,
),
},
...(agent.configBase && { base: agent.configBase }),
});
});
const alert = error && (
<p role="alert" className="buzz-field-error">
Expand Down Expand Up @@ -270,9 +364,18 @@ function FeatureSwitch({
<>
<SwitchPreferenceRow
label={feature.title}
description={feature.description}
checked={hasFeature(instructions, feature)}
disabled={saving}
description={
blocked && required && !checked
? `Turn on ${required.title} first.`
: // Attention reads off while no plugin shows it, as when the
// Watching plugin is off, or while the agent's switch is off.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 — Reflect a timer paused individually in Attention. After a successful setup, pause bestie-hourly-check-in using the existing Attention tab. That writes the timer’s value.enabled = false while leaving its object and agent.attentionEnabled present/true (watching/Tab.tsx:439–452). Here featureOn checks only existence, and this paused branch checks only agent-wide Attention, so Bestie still shows Every hour checked with the normal hourly-running description although the scheduler skips the timer. Include the individual timer’s enabled state in the status and give usable rearm guidance. Preserve a way to remove a paused timer; simply treating it as absent can hide Off. Add coverage for pausing in Attention and returning to Bestie.

checked && feature.timer && !agent.attentionEnabled
? "Paused: watches aren't running on this device. Turn on the Watching plugin and its Run watches switch."
: feature.description
}
checked={checked}
// A check-in left on without memory can still be turned off.
disabled={saving || (blocked && !checked)}
onCheckedChange={(on) => void save(on)}
/>
{alert}
Expand Down
16 changes: 14 additions & 2 deletions src/bundled/bestie/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,20 @@ all my private channels** add it to the private channels you are in, most
recently active first; each channel's members see that you added it. Each
action has a row of its own, so in a narrow panel its button wraps under it.
Public channels
need nothing: agents read them already. Check-ins and adding Bestie to new
channels you create are not set up yet.
need nothing: agents read them already. Adding Bestie to new channels you
create is not set up yet.

**Check-ins → Every hour** needs Remembers you, and turning memory off turns it
off too. On adds the `buzz:bestie/checkin.hourly` Hourly upkeep section to the
instructions and an hourly Attention timer, `watch/bestie-hourly-check-in`,
that wakes Bestie in your DM with it; it also turns on the agent's Attention,
which the Watching tab shows. Off removes the section and the timer and leaves
Attention on, since the agent's other watches may need it. The timer runs while
Buzz is open and the Watching plugin is on: this plugin doesn't call
`controlAttention()`, so turning Watching off still turns Attention off for
every agent. While the timer can't run, the row says it is paused. After a
sleep it runs once, not once for each missed hour. Each
check-in is a feature in `features.ts`, so another kind is one more entry.

---

Expand Down
Loading
Loading