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( +
+ +
, +);