From 2372470cd869c8d96cf266bd3dfe68765ecfb513 Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Fri, 30 Oct 2026 14:16:31 -0700 Subject: [PATCH] feat(bestie): stop a former Bestie's check-ins and keep a Former Bestie tab When the saved Bestie is known, any other agent here has its check-ins removed, wherever the change was made; memory and channels stay. A former Bestie with a setting still on gets a Former Bestie tab with the same switches, Remove from all for its channels, and Make my Bestie again. An agent made Bestie on this device starts with Remembers you on. The memory rules date notes by this device's calendar. The reconcile acts only on a Bestie saved to the account, as a device-only choice may be an automatic pick. It leaves the shared instructions of an agent that is also in another community, removing only its timer here; tries each state once per connection; re-runs for changes made while saving; starts each save from the agent as the last one left it; and stops if it is made Bestie again meanwhile. Switches, saves and the reconcile treat a section edited by hand as present, so none adds a second one. A browser check renders the Bestie, edited and Former Bestie tabs at 28rem and 20rem, with default and large text, in Chromium and WebKit. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Riley Crane --- src/app/entity-navigation.test.tsx | 18 +- src/bundled/agents/BestieChoice.tsx | 9 + src/bundled/bestie/BestieTab.test.tsx | 83 ++++- src/bundled/bestie/BestieTab.tsx | 391 ++++++++------------ src/bundled/bestie/PrivateChannels.test.tsx | 36 +- src/bundled/bestie/PrivateChannels.tsx | 174 +++++---- src/bundled/bestie/README.md | 14 +- src/bundled/bestie/features.test.ts | 10 +- src/bundled/bestie/features.ts | 2 +- src/bundled/bestie/former.test.ts | 269 ++++++++++++++ src/bundled/bestie/former.ts | 195 ++++++++++ src/bundled/bestie/index.tsx | 16 +- src/bundled/bestie/save.test.ts | 24 ++ src/bundled/bestie/save.ts | 140 +++++++ src/bundled/bestie/saved.ts | 63 ++++ tests/browser/bestie-tab.spec.mjs | 52 +++ tests/fixtures/bestie-tab.html | 2 + tests/fixtures/bestie-tab.tsx | 131 +++++++ 18 files changed, 1308 insertions(+), 321 deletions(-) create mode 100644 src/bundled/bestie/former.test.ts create mode 100644 src/bundled/bestie/former.ts create mode 100644 src/bundled/bestie/save.test.ts create mode 100644 src/bundled/bestie/save.ts create mode 100644 src/bundled/bestie/saved.ts create mode 100644 tests/browser/bestie-tab.spec.mjs create mode 100644 tests/fixtures/bestie-tab.html create mode 100644 tests/fixtures/bestie-tab.tsx diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index beb99fce8..1f7ff0d81 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -683,7 +683,7 @@ it("lists active primary pages in their sidebar and header placements", async () ); // The Attention tab is on by default, and attention keeps running without it. - // Bestie adds its own tab beside it. + // Bestie adds its own tabs beside it. const providers = () => current.agents.attentionProviders().map(({ provider }) => provider); const tabs = () => @@ -692,14 +692,24 @@ it("lists active primary pages in their sidebar and header placements", async () .map((tab) => tab.key) .sort(); await waitFor(() => - expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.watching/watching"]), + expect(tabs()).toEqual([ + "buzz.bestie/bestie", + "buzz.bestie/former-bestie", + "buzz.watching/watching", + ]), ); await act(() => current.plugins.change("disable", "buzz.watching")); - await waitFor(() => expect(tabs()).toEqual(["buzz.bestie/bestie"])); + await waitFor(() => + expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.bestie/former-bestie"]), + ); expect(providers()).toContain("attention/command"); await act(() => current.plugins.change("enable", "buzz.watching")); await waitFor(() => - expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.watching/watching"]), + expect(tabs()).toEqual([ + "buzz.bestie/bestie", + "buzz.bestie/former-bestie", + "buzz.watching/watching", + ]), ); await act(() => current.plugins.change("disable", "buzz.agents")); diff --git a/src/bundled/agents/BestieChoice.tsx b/src/bundled/agents/BestieChoice.tsx index 484e7bca4..0ccbe85c6 100644 --- a/src/bundled/agents/BestieChoice.tsx +++ b/src/bundled/agents/BestieChoice.tsx @@ -12,6 +12,14 @@ import { Button } from "../../shared/design-system/ui/Button"; type BestieAgent = AgentChoicesSnapshot["selectable"][number]; +const made = new Set<(pubkey: string) => void>(); +/** Hears each agent the viewer makes Bestie on this device, once it is + * saved. A choice synced from elsewhere isn't heard. */ +export function onBestieMade(listener: (pubkey: string) => void) { + made.add(listener); + return () => void made.delete(listener); +} + /** Making an agent Bestie, wherever it is offered: `make` asks before * replacing an explicitly saved Bestie, in the dialog `view` renders. */ export function useBestieChoice(session: RelaySession, scope: string) { @@ -37,6 +45,7 @@ export function useBestieChoice(session: RelaySession, scope: string) { setSavingBestie(true); chooseDefaultAgent(session, scope, pubkey) .then(() => { + for (const listener of made) listener(pubkey); if (choice === latest.current) setReplacement(undefined); }) .catch((failure: Error) => { diff --git a/src/bundled/bestie/BestieTab.test.tsx b/src/bundled/bestie/BestieTab.test.tsx index 1d19a5c29..14359248a 100644 --- a/src/bundled/bestie/BestieTab.test.tsx +++ b/src/bundled/bestie/BestieTab.test.tsx @@ -6,7 +6,8 @@ import { afterEach, expect, it, vi } from "vitest"; 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 { onBestieMade } from "../agents/BestieChoice"; +import { bestieTab, formerBestieTab } from "./BestieTab"; import { validateObject } from "../../features/agents/attention"; import { checkinHourly, memory, withFeature } from "./features"; @@ -246,6 +247,11 @@ it("keeps memory on through the owner's edits, and offers Restore for edited mar rerender(); expect(screen.queryByRole("button", { name: "Restore" })).toBeNull(); expect(screen.getByText(/Fix its start and end lines there/)).toBeVisible(); + // Check-ins wait on memory. + expect(screen.getByRole("switch", { name: "Every hour" })).toHaveAttribute( + "aria-disabled", + "true", + ); }); it("says why a save failed and lets the switch be tried again", async () => { @@ -395,3 +401,78 @@ it("checks in every hour only while memory is on, with a timer in the owner's DM expect(hourly()).toBeChecked(); expect(hourly()).not.toHaveAttribute("aria-disabled", "true"); }); + +it("shows a Former Bestie tab while a former Bestie has a setting on, with a way back", async () => { + const user = userEvent.setup(); + const { relay, mainAgent } = community(bea.pubkey); + const tab = formerBestieTab(relay, host().agents); + const remembering = { + ...ada, + config: { instructions: withFeature("Be brief.", memory, true) }, + } as Agent; + const stop = tab.subscribe?.(() => {}); + // Nobody is former before the account's choice is read. + expect(tab.when?.(remembering)).toBe(false); + await vi.waitFor(() => expect(tab.when?.(remembering)).toBe(true)); + // Not on the Bestie, nor on an agent with nothing on. + expect(tab.when?.(bea)).toBe(false); + expect(tab.when?.(ada)).toBe(false); + + const made = vi.fn(); + const stopMade = onBestieMade(made); + const Tab = tab.component; + render(); + expect( + screen.getByText("Ada is no longer your Bestie. These are still on."), + ).toBeVisible(); + expect(screen.getByRole("switch", { name: "Remembers you" })).toBeChecked(); + const hourly = screen.getByRole("switch", { name: "Every hour" }); + expect(hourly).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("Only your Bestie checks in.")).toBeVisible(); + expect(screen.getByText("Private channels Ada can read")).toBeVisible(); + + await user.click( + screen.getByRole("button", { name: "Make my Bestie again" }), + ); + const confirmation = await screen.findByRole("alertdialog", { + name: "Make Ada your bestie?", + }); + await user.click( + within(confirmation).getByRole("button", { name: "Switch to Ada" }), + ); + expect(mainAgent.write).toHaveBeenCalledWith(ada.pubkey); + // This device made it Bestie, so the plugin hears of it. + await vi.waitFor(() => expect(made).toHaveBeenCalledWith(ada.pubkey)); + await vi.waitFor(() => expect(tab.when?.(remembering)).toBe(false)); + stopMade(); + stop?.(); +}); + +it("lets a former Bestie's leftover check-in be turned off", async () => { + const user = userEvent.setup(); + const { relay } = community(bea.pubkey); + const { agents, save } = host(); + const Tab = formerBestieTab(relay, agents).component; + const checking = { + ...ada, + config: { + instructions: withFeature( + withFeature("Be brief.", memory, true), + checkinHourly, + true, + ), + }, + attention: { + "interest/bestie-hourly-check-in": {}, + "watch/bestie-hourly-check-in": {}, + }, + } as unknown as Agent; + render(); + const hourly = screen.getByRole("switch", { name: "Every hour" }); + expect(hourly).toBeChecked(); + expect(hourly).not.toHaveAttribute("aria-disabled", "true"); + await user.click(hourly); + expect(save).toHaveBeenLastCalledWith(ada.key, { + config: { instructions: withFeature("Be brief.", memory, true) }, + }); +}); diff --git a/src/bundled/bestie/BestieTab.tsx b/src/bundled/bestie/BestieTab.tsx index 3cd6f670e..72ee08593 100644 --- a/src/bundled/bestie/BestieTab.tsx +++ b/src/bundled/bestie/BestieTab.tsx @@ -1,14 +1,8 @@ import { useState } from "react"; -import type { - Agent, - AgentAddedTab, - Agents, -} from "../../features/agents/service"; -import { relayOrigin } from "../../features/communities/destination"; -import { relayPartition } from "../../features/relay/partition"; +import type { Agent, AgentAddedTab } from "../../features/agents/service"; import { useRelayConnection } from "../../features/relay/react"; -import type { RelayData, RelaySnapshot } from "../../features/relay/service"; -import { savedAgentStore } from "../../features/solo/default-agent"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; import { BestieIcon } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; import { InlineHeader } from "../../shared/design-system/ui/Header"; @@ -28,22 +22,25 @@ import { FEATURES, type Feature, featureState, - hasFeature, restoredFeature, - withFeature, } from "./features"; -import { - type AttentionValue, - interestSlug, - watchSlug, -} from "../../features/agents/attention"; -import type { RelaySession } from "../../features/relay/session"; +import { formerWithSettings, subscribePrivateMembers } from "./former"; import { PrivateChannels } from "./PrivateChannels"; - -type TabAgents = Pick; +import { + cannotEdit, + type FeatureAgents, + featureOn, + instructionsOf, + restoreFeature, + saveFeature, +} from "./save"; +import { inCommunity, savedBestie } from "./saved"; /** The Bestie tab, on the agent saved as Bestie in its community. */ -export function bestieTab(relay: RelayData, agents: TabAgents): AgentAddedTab { +export function bestieTab( + relay: RelayData, + agents: FeatureAgents, +): AgentAddedTab { const bestie = savedBestie(relay); return { id: "bestie", @@ -58,72 +55,52 @@ export function bestieTab(relay: RelayData, agents: TabAgents): AgentAddedTab { }; } -/** The saved Bestie in the connected community, across reconnections. */ -function savedBestie(relay: RelayData) { - let current: - | Readonly<{ - snapshot: RelaySnapshot; - store: ReturnType; - }> - | undefined; - const store = () => { - const snapshot = relay.snapshot(); - if ( - current?.snapshot.session !== snapshot.session || - current.snapshot.scope !== snapshot.scope - ) - current = { - snapshot, - store: savedAgentStore(snapshot.session, snapshot.scope ?? ""), - }; - return current.store; - }; +/** The Former Bestie tab, on an agent that was Bestie and still has + * something on. It goes once the agent has nothing left on, and shows only + * against a Bestie saved to the account and read, as check-ins stop. */ +export function formerBestieTab( + relay: RelayData, + agents: FeatureAgents, +): AgentAddedTab { + const bestie = savedBestie(relay); return { - snapshot: () => store().snapshot(), - subscribe(listener: () => void) { - // The saved choice's sources also change for unrelated view state, so - // only a different Bestie, or community, is passed on. - let following = store(); - let last = following.snapshot(); - const changed = () => { - const next = following.snapshot(); - if (next === last) return; - last = next; - listener(); - }; - let stop = following.subscribe(changed); - const stopRelay = relay.subscribe(() => { - if (store() === following) return; - stop(); - following = store(); - last = following.snapshot(); - stop = following.subscribe(changed); - listener(); - }); + id: "former-bestie", + title: "Former Bestie", + when: (agent) => { + const current = bestie.snapshot(); + const snapshot = relay.snapshot(); + return ( + !!current && + !!snapshot.session.mainAgent && + agent.pubkey !== current && + inCommunity(agent, snapshot) && + formerWithSettings(agent, snapshot) + ); + }, + subscribe(listener) { + const stopBestie = bestie.subscribe(listener); + const stopMembers = subscribePrivateMembers(relay, listener); return () => { - stopRelay(); - stop(); + stopBestie(); + stopMembers(); }; }, + component: ({ agent }) => ( + + ), }; } -function inCommunity(agent: Agent, { scope }: RelaySnapshot) { - try { - return relayPartition(relayOrigin(agent.relay), agent.owner) === scope; - } catch { - return false; - } -} - function BestieTab({ relay, agents, agent, + former = false, }: { relay: RelayData; - agents: TabAgents; + agents: FeatureAgents; agent: Agent; + former?: boolean; }) { const { session, scope } = useRelayConnection(relay); const bestie = useBestieChoice(session, scope ?? ""); @@ -132,122 +109,120 @@ function BestieTab({ const others = bestie.choices.selectable.filter( (choice) => choice.pubkey !== agent.pubkey, ); + const name = agent.name || "This agent"; + const switches = (features: readonly Feature[]) => + features.map((feature) => ( + + )); return ( // Its own size container: rows wrap their buttons to the panel's width, // as each agent type's settings form does, and nothing widens the tab.
- } - title="This is your Bestie" - subtitle="It's pinned at the top of Buzz, with your DM as its home." - trailing={ - - Change} - /> - - - Make your Bestie - {others.map((choice) => ( - bestie.make(choice.pubkey)} - > - {bestieName(choice)} - - ))} - - - - } - /> + {former ? ( + } + title={`${name} is no longer your Bestie. These are still on.`} + trailing={ + + } + /> + ) : ( + } + title="This is your Bestie" + subtitle="It's pinned at the top of Buzz, with your DM as its home." + trailing={ + + Change} + /> + + + Make your Bestie + {others.map((choice) => ( + bestie.make(choice.pubkey)} + > + {bestieName(choice)} + + ))} + + + + } + /> + )} - {FEATURES.filter((feature) => !feature.timer).map((feature) => ( - - ))} + {switches(FEATURES.filter((feature) => !feature.timer))} - {FEATURES.filter((feature) => feature.timer).map((feature) => ( - - ))} + {switches(FEATURES.filter((feature) => feature.timer))} - + {bestie.view}
); } -/** 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. */ +/** A feature's switch. Off also turns off the features that require this + * one. On a former Bestie, check-ins can only be turned off. 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, + former, saving, setSaving, }: { - agents: TabAgents; + agents: FeatureAgents; agent: Agent; session: RelaySession; feature: Feature; + former: boolean; saving: boolean; setSaving(saving: boolean): void; }) { const [error, setError] = useState(); - // A type without `adopt` has no instructions, as `updateKind` says. - if (!agents.types().find(({ key }) => key === agent.type)?.adopt) - return ( - - ); - // Without a confirmed copy its instructions aren't shown, so a save here - // would replace them. - if (agent.configProblem) - return ( - - ); - const config = (agent.config ?? {}) as { instructions?: unknown }; - const instructions = - typeof config.instructions === "string" ? config.instructions : ""; + const problem = cannotEdit(agents, agent); + if (problem) + return ; + const instructions = instructionsOf(agent); const required = FEATURES.find(({ id }) => id === feature.requires); - const blocked = !!required && !featureOn(agent, instructions, required); - const checked = featureOn(agent, instructions, feature); + const blocked = !!required && !featureOn(agent, required); + const checked = featureOn(agent, feature); + const formerCheckIn = former && !!feature.timer; const run = async (task: () => Promise) => { setSaving(true); setError(undefined); @@ -259,73 +234,18 @@ function FeatureSwitch({ setSaving(false); } }; - const saveInstructions = (next: string) => - agents.save(agent.key, { - config: { ...config, instructions: next }, - ...(agent.configBase && { base: agent.configBase }), - }); const save = (on: boolean) => run(async () => { - // On never adds a second section beside one edited by hand. - if (on && featureState(instructions, feature) === "edited") - throw new Error( - `${feature.title} was edited in Instructions; restore it first.`, - ); - const off = on - ? [] - : [ - feature, - ...FEATURES.filter(({ requires }) => requires === feature.id), - ]; - const attention: Record = {}; - 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 }), - }); - 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 }), + // The owner's 1:1 DM with the agent: opening it is idempotent. + const channel = + on && feature.timer + ? await session.directMessages.open( + [agent.pubkey], + AbortSignal.timeout(30_000), + ) + : undefined; + await saveFeature(agents, agent, feature, on, { + ...(channel && { channel }), }); }); const alert = error && ( @@ -334,22 +254,25 @@ function FeatureSwitch({

); if (featureState(instructions, feature) === "edited") { - const restored = restoredFeature(instructions, feature); + const restorable = + !formerCheckIn && restoredFeature(instructions, feature) !== undefined; return ( <> void run(() => saveInstructions(restored))} + onClick={() => + void run(() => restoreFeature(agents, agent, feature)) + } > Restore @@ -365,17 +288,19 @@ function FeatureSwitch({ void save(on)} /> {alert} diff --git a/src/bundled/bestie/PrivateChannels.test.tsx b/src/bundled/bestie/PrivateChannels.test.tsx index 5e4f1b049..d5c74b9fe 100644 --- a/src/bundled/bestie/PrivateChannels.test.tsx +++ b/src/bundled/bestie/PrivateChannels.test.tsx @@ -62,7 +62,7 @@ function session(channels: ChannelSummary[], removal = "confirmed") { const add = vi.fn(async (id: string) => { if (id === "id-beta") throw new Error("Not confirmed. Try again."); }); - const run = vi.fn(async () => {}); + const run = vi.fn(async (_channel: string, _change: unknown) => {}); const list = { channels }; const value = { viewer: YOU, @@ -76,8 +76,8 @@ function session(channels: ChannelSummary[], removal = "confirmed") { memberAdministration: { refresh: vi.fn(async () => {}), run, - snapshot: vi.fn(() => - run.mock.calls.length + snapshot: vi.fn((id: string) => + run.mock.calls.some(([removed]) => removed === id) ? { status: "ready", authority: { roles: {} }, @@ -172,3 +172,33 @@ it("says when a removal isn't confirmed, and offers none from a cached roster", "#shared: The change may have taken effect. Refresh to check.", ); }); + +it("lists a former Bestie's channels by name, to remove from one or all after asking", async () => { + const user = userEvent.setup(); + const { value, run } = session([ + channel("alpha", [YOU, BESTIE], { lastActivityAt: 5 }), + channel("beta", [YOU, BESTIE], { lastActivityAt: 9 }), + channel("gamma", [YOU]), + ]); + render(); + expect(screen.getByText("Private channels Ada can read")).toBeVisible(); + // Winding down offers no adds. + expect(screen.queryByRole("button", { name: "Choose…" })).toBeNull(); + expect(screen.queryByRole("switch")).toBeNull(); + expect( + screen.getByRole("button", { name: "Remove Ada from #beta" }), + ).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Remove from all" })); + const ask = await screen.findByRole("alertdialog", { + name: "Remove Ada from 2 private channels?", + }); + expect(run).not.toHaveBeenCalled(); + await user.click( + within(ask).getByRole("button", { name: "Remove from all" }), + ); + await vi.waitFor(() => + expect(run.mock.calls.map(([id]) => id)).toEqual(["id-beta", "id-alpha"]), + ); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/src/bundled/bestie/PrivateChannels.tsx b/src/bundled/bestie/PrivateChannels.tsx index bb3510559..90b276c8a 100644 --- a/src/bundled/bestie/PrivateChannels.tsx +++ b/src/bundled/bestie/PrivateChannels.tsx @@ -65,13 +65,16 @@ async function removeMember( const message = (error: unknown) => error instanceof Error ? error.message : String(error); -/** The private channels Bestie reads because it is a member. */ +/** The private channels Bestie reads because it is a member. For a former + * Bestie, `former` names it, and the list is for taking it out. */ export function PrivateChannels({ session, bestie, + former, }: { session: RelaySession; bestie: string; + former?: string; }) { const list = useChannelList(session.channels); const { joined, others } = privateChannels( @@ -87,7 +90,7 @@ export function PrivateChannels({ Record> > >({}); - /** The channels Add to all asked about. */ + /** The channels Add to all, or Remove from all, asked about. */ const [confirming, setConfirming] = useState(); /** Runs `change` on each channel in turn, as adding several agents does, @@ -118,8 +121,10 @@ export function PrivateChannels({ }; const add = (channels: readonly ChannelSummary[]) => run(channels, true, (id) => session.memberAdditions.add(id, bestie)); - const remove = (channel: ChannelSummary) => - run([channel], false, (id) => removeMember(session, id, bestie)); + const remove = (channels: readonly ChannelSummary[]) => + run(channels, false, (id) => removeMember(session, id, bestie)); + const name = former ?? "Bestie"; + const removable = joined.filter((channel) => !channel.cached); const adding = addable.some((channel) => busy.has(channel.id)); // A failure stops showing once the channel is as the change wanted anyway. const inBestie = new Set(joined.map((channel) => channel.id)); @@ -130,8 +135,12 @@ export function PrivateChannels({ return ( <> {joined.map((channel) => ( @@ -143,75 +152,96 @@ export function PrivateChannels({ variant="outline" loading={busy.has(channel.id)} disabled={channel.cached} - aria-label={`Remove Bestie from #${channel.name}`} - onClick={() => void remove(channel)} + aria-label={`Remove ${name} from #${channel.name}`} + onClick={() => void remove([channel])} > Remove } /> ))} - {!joined.length && ( - - )} - {/* Each action has a row of its own, so its button can wrap under - its title in a narrow panel instead of widening the tab. */} - {addable.length > 0 && ( - <> - ( - - - Choose… - - } - /> - - {addable.map((channel) => ( - void add([channel])} - > - #{channel.name} - - ))} - - - )} - /> + {!joined.length && + (former ? ( + + ) : ( ( - - )} + title="Not in any of your private channels yet" + subtitle="Add Bestie to the ones you want it to catch you up on." /> - + ))} + {/* Each action has a row of its own, so its button can wrap under + its title in a narrow panel instead of widening the tab. */} + {former + ? removable.length > 0 && ( + ( + + )} + /> + ) + : addable.length > 0 && ( + <> + ( + + + Choose… + + } + /> + + {addable.map((channel) => ( + void add([channel])} + > + #{channel.name} + + ))} + + + )} + /> + ( + + )} + /> + + )} + {!former && ( + )} - {problems.length > 0 && (
@@ -224,8 +254,12 @@ export function PrivateChannels({ )} {confirming && ( setConfirming(undefined)} actions={ <> @@ -234,10 +268,10 @@ export function PrivateChannels({ variant="prominent" onClick={() => { setConfirming(undefined); - void add(confirming); + void (former ? remove : add)(confirming); }} > - Add to all + {former ? "Remove from all" : "Add to all"} } diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 61977b455..aca9cb2a4 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -34,9 +34,8 @@ Bestie, each with **Remove**. **Add to a channel** (**Choose…**) and **Add to 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. Adding Bestie to new channels you -create is not set up yet. +Public channels 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 @@ -50,6 +49,15 @@ 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. +**Changing your Bestie.** Only your Bestie checks in: whenever the saved Bestie +is known, on this device or synced from another, any other agent here has its +check-ins removed. Its memory section and channels stay. An agent this device +makes Bestie starts with Remembers you on; a choice synced from elsewhere +changes nothing, so memory turned off stays off. A former Bestie keeps a +**Former Bestie** tab while it has a Bestie section, or, if this device saw it +as Bestie, while it is in one of your private channels. The tab has the same +switches, **Remove from all** for its channels, and **Make my Bestie again**. + --- ## Changing Bestie's instructions diff --git a/src/bundled/bestie/features.test.ts b/src/bundled/bestie/features.test.ts index 55263db3e..0a7135dbe 100644 --- a/src/bundled/bestie/features.test.ts +++ b/src/bundled/bestie/features.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; import { - type Feature, + checkinHourly, featureState, hasFeature, memory, @@ -8,8 +8,6 @@ import { withFeature, } from "./features"; -// Another feature, whose markers are not memory's. -const another: Feature = { ...memory, id: "other", text: "other" }; const OPEN = ""; const CLOSE = ""; @@ -95,7 +93,9 @@ it("shows a section whose markers were edited, and adds no second one", () => { } expect(featureState("Be brief.", memory)).toBe("off"); // Another feature's markers are not this one's. - expect(featureState(withFeature("", another, true), memory)).toBe("off"); + expect(featureState(withFeature("", checkinHourly, true), memory)).toBe( + "off", + ); }); it("restores an edited section only where its markers show it was", () => { @@ -126,7 +126,7 @@ it("restores an edited section only where its markers show it was", () => { memory, ), ).toBeUndefined(); - const other = withFeature("", another, true); + const other = withFeature("", checkinHourly, true); expect( restoredFeature( `${OPEN.replace(" v1", "")}\nrules\n\n${other}\n${CLOSE.replace(" -->", "-->")}`, diff --git a/src/bundled/bestie/features.ts b/src/bundled/bestie/features.ts index 1f3c4361e..7581752b2 100644 --- a/src/bundled/bestie/features.ts +++ b/src/bundled/bestie/features.ts @@ -35,7 +35,7 @@ Your owner turned on "Remembers you": keep memory about them, so your help gets Your memory has these slugs: - core: who you are to this owner and your durable operating rules. Link the slugs below from it, as [[mem/user]]. - mem/user: the owner's facts, preferences, timezone, notification preferences, and an "Open loops" section of things they asked you to follow up on. -- mem/notes/YYYY-MM-DD: sparse dated notes of significant events, each with its source event id. Append only. +- mem/notes/YYYY-MM-DD: sparse dated notes of significant events, each with its source event id, dated by this device's local date. Append only. - mem/memory: curated longer-term knowledge. Replace superseded claims; never duplicate. Rules: diff --git a/src/bundled/bestie/former.test.ts b/src/bundled/bestie/former.test.ts new file mode 100644 index 000000000..d74d07d74 --- /dev/null +++ b/src/bundled/bestie/former.test.ts @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import type { Agent, Agents } from "../../features/agents/service"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import { checkinHourly, memory, withFeature } from "./features"; +import { + formerWithSettings, + rememberNewBestie, + stopFormerCheckIns, +} from "./former"; + +afterEach(() => localStorage.clear()); + +const OWNER = "f".repeat(64); +const SCOPE = `https://relay.example:${OWNER}`; +const DM = "0f0f0f0f-0000-4000-8000-000000000001"; +const TIMER = { + "interest/bestie-hourly-check-in": { type: "interest" }, + "watch/bestie-hourly-check-in": { type: "timer", channel: DM }, +}; +const checkingIn = withFeature( + withFeature("Be brief.\n", memory, true), + checkinHourly, + true, +); +const agent = (digit: string, instructions = "", attention = {}): Agent => + ({ + key: digit.repeat(64), + pubkey: digit.repeat(64), + name: digit, + type: "test/echo", + owner: OWNER, + relay: "wss://relay.example", + place: "here", + attention, + config: { instructions }, + }) as unknown as Agent; +const ada = agent("a", checkingIn, TIMER); +const bea = agent("b"); + +/** A community whose account read of the saved Bestie waits until `settle`. */ +function community(channels: readonly ChannelSummary[] = [], account = true) { + let settle: (pubkey: string | undefined) => void = () => {}; + const read = new Promise((resolve) => { + settle = resolve; + }); + const session = { + viewer: OWNER, + ...(account && { + mainAgent: { read: () => read, write: vi.fn(async () => {}) }, + }), + channels: { + list: () => ({ channels }), + subscribeList: () => () => {}, + ensureList: () => {}, + }, + }; + const snapshot = { status: "ready", scope: SCOPE, session }; + const relay = { + snapshot: () => snapshot, + subscribe: () => () => {}, + } as unknown as RelayData; + return { + relay, + snapshot: snapshot as unknown as RelaySnapshot, + settle: async (pubkey: string | undefined) => { + settle(pubkey); + await read; + await Promise.resolve(); + }, + }; +} + +function host(list: Agent[], running: Agent[] = []) { + let agents = list; + const listeners = new Set<() => void>(); + const save = vi.fn(async (key, change) => { + agents = agents.map((each) => + each.key !== key + ? each + : ({ + ...each, + ...(change.config !== undefined && { config: change.config }), + ...(change.attention && { + attention: Object.fromEntries( + Object.entries({ + ...each.attention, + ...change.attention, + }).filter(([, value]) => value), + ), + }), + } as Agent), + ); + for (const listener of listeners) listener(); + }); + return { + save, + /** The agents' listeners are told, with nothing changed. */ + notify: () => { + for (const listener of listeners) listener(); + }, + agents: { + save, + types: () => [{ key: "test/echo", adopt: () => ({}) }], + snapshot: () => ({ agents, running: [...agents, ...running] }), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + } as unknown as Pick, + }; +} + +it("stops a former Bestie's check-ins once the Bestie is known, and leaves its memory", async () => { + const { relay, settle } = community(); + const { agents, save, notify } = host([ada, bea]); + const stop = stopFormerCheckIns(relay, agents); + // While the saved Bestie is being read, nobody is former. + await Promise.resolve(); + expect(save).not.toHaveBeenCalled(); + await settle(bea.pubkey); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2)); + expect(save.mock.calls).toEqual([ + [ + ada.key, + { + attention: { + "interest/bestie-hourly-check-in": null, + "watch/bestie-hourly-check-in": null, + }, + }, + ], + [ + ada.key, + { config: { instructions: withFeature("Be brief.\n", memory, true) } }, + ], + ]); + // Once stopped there is nothing more to do. + save.mockClear(); + notify(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(save).not.toHaveBeenCalled(); + stop(); +}); + +it("does nothing while the saved Bestie is unknown, or to the Bestie itself", async () => { + const unknown = community(); + const first = host([ada]); + const stopUnknown = stopFormerCheckIns(unknown.relay, first.agents); + // An account with no saved Bestie, or a read that failed, isn't "none". + await unknown.settle(undefined); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(first.save).not.toHaveBeenCalled(); + stopUnknown(); + + const known = community(); + const second = host([ada]); + const stopKnown = stopFormerCheckIns(known.relay, second.agents); + await known.settle(ada.pubkey); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(second.save).not.toHaveBeenCalled(); + stopKnown(); +}); + +it("stops a timer left without its section, and doesn't retry a failed save until something changes", async () => { + const { relay, settle } = community(); + const half = agent("c", "Be brief.", TIMER); + const { agents, save, notify } = host([half]); + save.mockRejectedValueOnce(new Error("offline")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const stop = stopFormerCheckIns(relay, agents); + await settle(bea.pubkey); + await vi.waitFor(() => expect(warn).toHaveBeenCalled()); + expect(save).toHaveBeenCalledTimes(1); + // The same state seen again isn't saved again. + notify(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(save).toHaveBeenCalledTimes(1); + stop(); + warn.mockRestore(); +}); + +it("turns memory on for a local agent made Bestie here, unless it has it", async () => { + const { relay } = community(); + const { agents, save } = host([ada, bea]); + await rememberNewBestie(relay, agents, ada.pubkey); + expect(save).not.toHaveBeenCalled(); + await rememberNewBestie(relay, agents, bea.pubkey); + expect(save).toHaveBeenCalledWith(bea.key, { + config: { instructions: withFeature("", memory, true) }, + }); + // A remote agent, or one in another community, is left alone. + save.mockClear(); + await rememberNewBestie(relay, agents, "9".repeat(64)); + expect(save).not.toHaveBeenCalled(); + // Nor is one whose section was edited by hand: no second section. + const edited = host([ + { + ...bea, + config: { + instructions: withFeature("", memory, true).replace( + "", + "", + ), + }, + } as Agent, + ]); + await rememberNewBestie(relay, edited.agents, bea.pubkey); + expect(edited.save).not.toHaveBeenCalled(); +}); + +it("counts a former Bestie as on while it has a section, or a private channel once seen as Bestie", async () => { + const channel = { + id: "c1", + name: "plans", + channelType: "stream", + private: true, + members: [OWNER, bea.pubkey], + } as unknown as ChannelSummary; + const { relay, snapshot, settle } = community([channel]); + const { agents } = host([ada, bea]); + expect(formerWithSettings(ada, snapshot)).toBe(true); + // Any agent in a private channel isn't a former Bestie... + expect(formerWithSettings(bea, snapshot)).toBe(false); + // ...but one this device saw as Bestie is, once another one is. + const stop = stopFormerCheckIns(relay, agents); + await settle(bea.pubkey); + expect(formerWithSettings(bea, snapshot)).toBe(true); + expect( + formerWithSettings({ ...bea, pubkey: "d".repeat(64) } as Agent, snapshot), + ).toBe(false); + stop(); +}); + +it("leaves a Bestie kept only on this device alone, as an automatic pick looks the same", async () => { + const { relay } = community([], false); + localStorage.setItem( + `buzz-view.v1:${JSON.stringify([SCOPE, "solo:default-agent"])}`, + JSON.stringify(bea.pubkey), + ); + const { agents, save } = host([ada, bea]); + const stop = stopFormerCheckIns(relay, agents); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(save).not.toHaveBeenCalled(); + stop(); +}); + +it("stops only the timer of an agent also in another community, whose instructions it shares", async () => { + const { relay, settle } = community(); + const elsewhere = { ...ada, relay: "wss://other.example" } as Agent; + const { agents, save } = host([ada, bea], [elsewhere]); + const stop = stopFormerCheckIns(relay, agents); + await settle(bea.pubkey); + await vi.waitFor(() => expect(save).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(save.mock.calls).toEqual([ + [ + ada.key, + { + attention: { + "interest/bestie-hourly-check-in": null, + "watch/bestie-hourly-check-in": null, + }, + }, + ], + ]); + stop(); +}); diff --git a/src/bundled/bestie/former.ts b/src/bundled/bestie/former.ts new file mode 100644 index 000000000..50237cf9d --- /dev/null +++ b/src/bundled/bestie/former.ts @@ -0,0 +1,195 @@ +import type { Agent, Agents } from "../../features/agents/service"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { FEATURES, featureState, memory } from "./features"; +import { privateChannels } from "./PrivateChannels"; +import { + cannotEdit, + type FeatureAgents, + featurePresent, + instructionsOf, + saveFeature, +} from "./save"; +import { inCommunity, savedBestie } from "./saved"; +import { readView, writeView } from "../../shared/view-state"; + +type ReconcileAgents = FeatureAgents & Pick; + +const FORMER = "bestie:former"; +/** The agents this device has seen as the community's Bestie, newest last. */ +function seenAsBestie(scope: string) { + const value = readView(scope, FORMER, []); + return Array.isArray(value) + ? value.filter((pubkey): pubkey is string => typeof pubkey === "string") + : []; +} +function rememberBestie(scope: string, pubkey: string) { + const seen = seenAsBestie(scope); + if (!seen.includes(pubkey)) + writeView(scope, FORMER, [...seen, pubkey].slice(-20)); +} + +/** Whether `agent`, which isn't Bestie, was one and still has something on + * that the Former Bestie tab can turn off. A Bestie section in its + * instructions shows it was Bestie, on any device, and is on. Membership of + * one of the viewer's private channels counts only for an agent this device + * saw as Bestie: nothing records which channels Bestie's switches added. */ +export function formerWithSettings( + agent: Agent, + { session, scope }: RelaySnapshot, +) { + const instructions = instructionsOf(agent); + if (FEATURES.some((feature) => featureState(instructions, feature) !== "off")) + return true; + return ( + !!scope && + seenAsBestie(scope).includes(agent.pubkey) && + privateChannels( + session.channels.list().channels, + session.viewer ?? "", + agent.pubkey, + ).joined.length > 0 + ); +} + +/** Calls `listener` when who is in the viewer's private channels changes, + * across reconnections, but not for each new message in them. */ +export function subscribePrivateMembers( + relay: RelayData, + listener: () => void, +) { + const members = (session: RelaySession) => + privateChannels(session.channels.list().channels, session.viewer ?? "", "") + .others.map(({ id, members = [] }) => `${id}:${[...members].sort()}`) + .sort() + .join(" "); + let session = relay.snapshot().session; + let last = members(session); + const changed = () => { + const next = members(session); + if (next === last) return; + last = next; + listener(); + }; + const follow = () => { + session.channels.ensureList(); + return session.channels.subscribeList(changed); + }; + let stop = follow(); + const stopRelay = relay.subscribe(() => { + const next = relay.snapshot().session; + if (next === session) return; + stop(); + session = next; + stop = follow(); + changed(); + }); + return () => { + stopRelay(); + stop(); + }; +} + +/** Stops the check-ins of each agent here that isn't the community's Bestie, + * whenever the saved Bestie or the agents change, wherever the change was + * made. It acts only on a Bestie saved to the account and read: while the + * choice is loading or unknown, or kept only on this device, where an + * automatic pick looks the same, nobody counts as former. An agent also in + * another community, where it may be Bestie, loses only its timer here, as + * its instructions are shared. Memory and channels stay as they are. */ +export function stopFormerCheckIns(relay: RelayData, agents: ReconcileAgents) { + const bestie = savedBestie(relay); + const checkIns = FEATURES.filter(({ timer }) => timer); + /** Agents being saved, and what each was like when last tried, so a state + * is tried once per connection, and a save that left it as it was doesn't + * loop. */ + const saving = new Set(); + const tried = new Map(); + let session = relay.snapshot().session; + const reconcile = () => { + const relaySnapshot = relay.snapshot(); + if (relaySnapshot.session !== session) { + session = relaySnapshot.session; + tried.clear(); + } + const current = session.mainAgent ? bestie.snapshot() : undefined; + if (!current) return; + if (relaySnapshot.scope) rememberBestie(relaySnapshot.scope, current); + const { agents: here, running = [] } = agents.snapshot(); + for (const agent of here) { + const on = checkIns.filter((feature) => featurePresent(agent, feature)); + const seen = JSON.stringify([instructionsOf(agent), agent.attention]); + if ( + !on.length || + agent.pubkey === current || + agent.place !== "here" || + saving.has(agent.key) || + tried.get(agent.key) === seen || + cannotEdit(agents, agent) || + !inCommunity(agent, relaySnapshot) + ) + continue; + const timerOnly = running.some( + (other) => + other.pubkey === agent.pubkey && !inCommunity(other, relaySnapshot), + ); + saving.add(agent.key); + tried.set(agent.key, seen); + // Not while the agents are telling their listeners of a change. + void Promise.resolve() + .then(async () => { + for (const feature of on) { + // Each save starts from the agent as the last one left it, and + // stops if it was made Bestie again meanwhile. + const fresh = agents + .snapshot() + .agents.find((each) => each.key === agent.key); + if (!fresh || bestie.snapshot() === agent.pubkey) return; + await saveFeature(agents, fresh, feature, false, { timerOnly }); + } + }) + .catch((error: unknown) => + console.warn("A former Bestie's check-ins didn't stop", error), + ) + .finally(() => { + saving.delete(agent.key); + // What changed while it saved. + reconcile(); + }); + } + }; + const stopBestie = bestie.subscribe(reconcile); + const stopAgents = agents.subscribe(reconcile); + const stopRelay = relay.subscribe(reconcile); + reconcile(); + return () => { + stopBestie(); + stopAgents(); + stopRelay(); + }; +} + +/** Turns Remembers you on for a local agent this device just made Bestie, + * unless its type can't take it or it already has it. */ +export function rememberNewBestie( + relay: RelayData, + agents: ReconcileAgents, + pubkey: string, +) { + const agent = agents + .snapshot() + .agents.find( + (candidate) => + candidate.pubkey === pubkey && + candidate.place === "here" && + inCommunity(candidate, relay.snapshot()), + ); + if ( + !agent || + cannotEdit(agents, agent) || + // Never a second section, even beside one edited by hand. + featureState(instructionsOf(agent), memory) !== "off" + ) + return Promise.resolve(); + return saveFeature(agents, agent, memory, true); +} diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index 5583497eb..7b2d838d5 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -29,7 +29,9 @@ import { type RemoteBestie, } from "./setup"; import type { RemoteAgentModel } from "../builderlab/agents/client"; -import { bestieTab } from "./BestieTab"; +import { onBestieMade } from "../agents/BestieChoice"; +import { bestieTab, formerBestieTab } from "./BestieTab"; +import { rememberNewBestie, stopFormerCheckIns } from "./former"; export const inject = [ "pages", @@ -68,6 +70,18 @@ export const apply: PluginModule["apply"] = (ctx) => { }); ctx.effect(() => () => listeners.clear()); ctx.agents.registerTab(bestieTab(ctx.relay, ctx.agents)); + ctx.agents.registerTab(formerBestieTab(ctx.relay, ctx.agents)); + // Whoever is Bestie, on any device, only it checks in. + ctx.effect(() => stopFormerCheckIns(ctx.relay, ctx.agents)); + // A Bestie made on this device starts out remembering its owner. One made + // elsewhere keeps what it has, so memory turned off stays off. + ctx.effect(() => + onBestieMade((pubkey) => { + rememberNewBestie(ctx.relay, ctx.agents, pubkey).catch((error) => + console.warn("Bestie's memory wasn't turned on", error), + ); + }), + ); ctx.pages.register({ id: "bestie", title: "Bestie", diff --git a/src/bundled/bestie/save.test.ts b/src/bundled/bestie/save.test.ts new file mode 100644 index 000000000..b41c4ca4a --- /dev/null +++ b/src/bundled/bestie/save.test.ts @@ -0,0 +1,24 @@ +import { expect, it, vi } from "vitest"; +import type { Agent, Agents } from "../../features/agents/service"; +import { checkinHourly, memory, withFeature } from "./features"; +import { saveFeature } from "./save"; + +it("refuses to turn on a section edited by hand, saving nothing", async () => { + const save = vi.fn(async () => {}); + const agents = { save, types: () => [] }; + const instructions = withFeature( + withFeature("Be brief.", memory, true), + checkinHourly, + true, + ).replace("", ""); + const agent = { + key: "a".repeat(64), + attention: {}, + config: { instructions }, + } as unknown as Agent; + await expect( + saveFeature(agents, agent, checkinHourly, true, { channel: "dm" }), + ).rejects.toThrow("restore it first"); + // Its timer would otherwise be saved first, leaving half of it on. + expect(save).not.toHaveBeenCalled(); +}); diff --git a/src/bundled/bestie/save.ts b/src/bundled/bestie/save.ts new file mode 100644 index 000000000..fe08369f4 --- /dev/null +++ b/src/bundled/bestie/save.ts @@ -0,0 +1,140 @@ +import { + type AttentionValue, + interestSlug, + watchSlug, +} from "../../features/agents/attention"; +import type { Agent, Agents } from "../../features/agents/service"; +import { + FEATURES, + type Feature, + featureState, + hasFeature, + restoredFeature, + withFeature, +} from "./features"; + +export type FeatureAgents = Pick; + +/** The agent's instructions, as its synced editor shows them. */ +export function instructionsOf(agent: Agent) { + const { instructions } = (agent.config ?? {}) as { instructions?: unknown }; + return typeof instructions === "string" ? instructions : ""; +} + +/** Why the agent's instructions can't take a feature here, if they can't. */ +export function cannotEdit(agents: FeatureAgents, agent: Agent) { + // A type without `adopt` has no instructions, as `updateKind` says. + if (!agents.types().find(({ key }) => key === agent.type)?.adopt) + return "This agent's type has no instructions to add this to."; + // Without a confirmed copy its instructions aren't shown, so a save here + // would replace them. + return agent.configProblem; +} + +/** 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. */ +export function featureOn(agent: Agent, feature: Feature) { + return ( + hasFeature(instructionsOf(agent), feature) && + (!feature.timer || !!agent.attention[watchSlug(feature.timer.id)]) + ); +} + +/** Whether any of the feature's parts is there: its section, whole or + * edited, or its timer. */ +export function featurePresent(agent: Agent, feature: Feature) { + return ( + featureState(instructionsOf(agent), feature) !== "off" || + (!!feature.timer && !!agent.attention[watchSlug(feature.timer.id)]) + ); +} + +/** Turns `feature` on or off as its switch does, revising the synced + * instructions as their editor would. Off also turns off the features that + * require it. A timer is delivered to `channel`, the owner's DM with the + * agent. With `timerOnly`, the instructions, which each of the agent's + * communities shares, are left as they are. Attention is saved first: if the + * instructions then fail to save, the half left reads as off, and turning it + * either way finishes it. On refuses a section edited by hand: Restore puts + * it back first. */ +export async function saveFeature( + agents: FeatureAgents, + agent: Agent, + feature: Feature, + on: boolean, + { + channel, + timerOnly = false, + }: { channel?: string; timerOnly?: boolean } = {}, +) { + if (on && featureState(instructionsOf(agent), feature) === "edited") + throw new Error( + `${feature.title} was edited in Instructions; restore it first.`, + ); + const off = on + ? [] + : [feature, ...FEATURES.filter(({ requires }) => requires === feature.id)]; + const attention: Record = {}; + 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) { + if (!channel) throw new Error("A check-in needs your DM with the agent."); + 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, + }; + } + 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 }), + }); + if (timerOnly) return; + const instructions = instructionsOf(agent); + const next = on + ? withFeature(instructions, feature, true) + : off.reduce((text, item) => withFeature(text, item, false), instructions); + if (next !== instructions) await saveInstructions(agents, agent, next); +} + +/** Puts back the feature's section edited by hand, as the editor would save + * it. */ +export async function restoreFeature( + agents: FeatureAgents, + agent: Agent, + feature: Feature, +) { + const next = restoredFeature(instructionsOf(agent), feature); + if (next === undefined) + throw new Error(`Fix ${feature.title}'s markers in Instructions.`); + await saveInstructions(agents, agent, next); +} + +/** Saves `instructions` as the next revision of the copy `agent` shows. */ +function saveInstructions( + agents: FeatureAgents, + agent: Agent, + instructions: string, +) { + return agents.save(agent.key, { + config: { ...(agent.config as object), instructions }, + ...(agent.configBase && { base: agent.configBase }), + }); +} diff --git a/src/bundled/bestie/saved.ts b/src/bundled/bestie/saved.ts new file mode 100644 index 000000000..6c4249447 --- /dev/null +++ b/src/bundled/bestie/saved.ts @@ -0,0 +1,63 @@ +import type { Agent } from "../../features/agents/service"; +import { relayOrigin } from "../../features/communities/destination"; +import { relayPartition } from "../../features/relay/partition"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import { savedAgentStore } from "../../features/solo/default-agent"; + +/** The saved Bestie in the connected community, across reconnections. */ +export function savedBestie(relay: RelayData) { + let current: + | Readonly<{ + snapshot: RelaySnapshot; + store: ReturnType; + }> + | undefined; + const store = () => { + const snapshot = relay.snapshot(); + if ( + current?.snapshot.session !== snapshot.session || + current.snapshot.scope !== snapshot.scope + ) + current = { + snapshot, + store: savedAgentStore(snapshot.session, snapshot.scope ?? ""), + }; + return current.store; + }; + return { + snapshot: () => store().snapshot(), + subscribe(listener: () => void) { + // The saved choice's sources also change for unrelated view state, so + // only a different Bestie, or community, is passed on. + let following = store(); + let last = following.snapshot(); + const changed = () => { + const next = following.snapshot(); + if (next === last) return; + last = next; + listener(); + }; + let stop = following.subscribe(changed); + const stopRelay = relay.subscribe(() => { + if (store() === following) return; + stop(); + following = store(); + last = following.snapshot(); + stop = following.subscribe(changed); + listener(); + }); + return () => { + stopRelay(); + stop(); + }; + }, + }; +} + +export function inCommunity(agent: Agent, { scope }: RelaySnapshot) { + try { + return relayPartition(relayOrigin(agent.relay), agent.owner) === scope; + } catch { + return false; + } +} diff --git a/tests/browser/bestie-tab.spec.mjs b/tests/browser/bestie-tab.spec.mjs new file mode 100644 index 000000000..06a605d6d --- /dev/null +++ b/tests/browser/bestie-tab.spec.mjs @@ -0,0 +1,52 @@ +import { test, expect } from "./source-fixture.mjs"; + +// Real layout is outside jsdom: the tab at the Agents side panel's default and +// narrowest widths, with large text, never runs off the panel. +// With a section edited by hand, its row shows Restore instead of a switch. +for (const [variant, query] of [ + ["bestie", ""], + ["former", "?former"], + ["edited", "?edited"], +]) { + test(`The ${variant} Bestie tab fits the side panel at every width and text size`, async ({ + page, + }) => { + await page.goto(`/tests/fixtures/bestie-tab.html${query}`); + if (variant === "edited") + await expect(page.getByRole("button", { name: "Restore" })).toBeVisible(); + const panel = page.getByRole("complementary", { name: "Agent panel" }); + await expect( + page.getByText("Private channels", { exact: false }).first(), + ).toBeVisible(); + for (const width of ["28rem", "20rem"]) { + for (const fontSize of ["16px", "20px"]) { + await page.evaluate( + ([width, fontSize]) => { + document.documentElement.style.setProperty( + "--secondary-panel-width", + width, + ); + document.documentElement.style.fontSize = fontSize; + }, + [width, fontSize], + ); + if (process.env.BESTIE_SHOTS) + await panel.screenshot({ + path: `${process.env.BESTIE_SHOTS}/${variant}-${width}-${fontSize}.png`, + }); + expect( + await panel.evaluate((el) => el.scrollWidth <= el.clientWidth), + `${width} at ${fontSize}`, + ).toBe(true); + for (const button of await panel.getByRole("button").all()) { + const box = await button.boundingBox(); + const bounds = await panel.boundingBox(); + expect( + box && bounds && box.x + box.width <= bounds.x + bounds.width + 0.5, + `${await button.textContent()} at ${width} ${fontSize}`, + ).toBe(true); + } + } + } + }); +} diff --git a/tests/fixtures/bestie-tab.html b/tests/fixtures/bestie-tab.html new file mode 100644 index 000000000..e3ed30ce9 --- /dev/null +++ b/tests/fixtures/bestie-tab.html @@ -0,0 +1,2 @@ + +Bestie tab fixture
diff --git a/tests/fixtures/bestie-tab.tsx b/tests/fixtures/bestie-tab.tsx new file mode 100644 index 000000000..c552f66e5 --- /dev/null +++ b/tests/fixtures/bestie-tab.tsx @@ -0,0 +1,131 @@ +// The production Bestie tabs in the Agents side panel's layout, with only the +// host data boundary replaced. `?former` shows the Former Bestie tab, and +// `?edited` a memory section whose closing marker was edited by hand. +import { createRoot } from "react-dom/client"; +import styles from "../../src/bundled/agents/AgentsPage.module.css"; +import { bestieTab, formerBestieTab } from "../../src/bundled/bestie/BestieTab"; +import { + checkinHourly, + memory, + withFeature, +} from "../../src/bundled/bestie/features"; +import type { Agent } from "../../src/features/agents/service"; +import type { RelayData } from "../../src/features/relay/service"; +import "../../src/shared/styles/globals.css"; + +const OWNER = "f".repeat(64); +const ADA = "a".repeat(64); +const BEA = "b".repeat(64); +const query = new URLSearchParams(location.search); +const former = query.has("former"); +const edited = query.has("edited"); +const channel = (name: string, members: string[]) => ({ + id: `id-${name}`, + name, + channelType: "stream", + private: true, + members, +}); +const list = { + channels: [ + channel("design-reviews-and-weekly-planning-notes", [OWNER, ADA]), + channel("family", [OWNER, ADA]), + channel("leadership-offsite", [OWNER]), + channel("hiring", [OWNER]), + ], +}; +const choice = (pubkey: string, name: string) => ({ + pubkey, + name, + managed: true, +}); +const choices = { + status: "ready", + complete: true, + pending: false, + identities: [choice(ADA, "Ada"), choice(BEA, "Bea")], + selectable: [choice(ADA, "Ada"), choice(BEA, "Bea")], + archives: { status: "unavailable", archived: [] }, +}; +const connection = { + status: "ready", + scope: `https://relay.example:${OWNER}`, + session: { + viewer: OWNER, + outbox: { supports: () => true }, + directMessages: { open: async () => "dm" }, + channels: { + list: () => list, + subscribeList: () => () => {}, + ensureList: () => {}, + }, + memberAdditions: { add: async () => {} }, + memberAdministration: { + refresh: async () => {}, + run: async () => {}, + snapshot: () => ({ status: "ready", authority: { roles: {} } }), + }, + mainAgent: { + read: async () => (former ? BEA : ADA), + write: async () => {}, + }, + agentChoices: { + snapshot: () => choices, + subscribe: () => () => {}, + retain: () => () => {}, + }, + }, +}; +const relay = { + snapshot: () => connection, + subscribe: () => () => {}, +} as unknown as RelayData; +const agents = { + save: async () => {}, + types: () => [{ key: "claude-code", adopt: () => ({}) }], +}; +const agent = { + key: ADA, + pubkey: ADA, + name: "Ada", + type: "claude-code", + owner: OWNER, + relay: "wss://relay.example", + place: "here", + attentionEnabled: true, + attention: { + "interest/bestie-hourly-check-in": { value: { type: "interest" } }, + "watch/bestie-hourly-check-in": { value: { type: "timer" } }, + }, + config: { + instructions: withFeature( + withFeature("Be brief.", memory, true), + checkinHourly, + true, + ).replace( + "", + edited ? " Also answer in French." : "$&", + ), + }, +} as unknown as Agent; +const tab = (former ? formerBestieTab : bestieTab)(relay, agents); +const Tab = tab.component; +const root = document.getElementById("root"); +if (!root) throw new Error("Missing fixture root"); +createRoot(root).render( +
+ +
, +);