diff --git a/docs/solo/README.md b/docs/solo/README.md index f27ebed753..9a5ef1d1ad 100644 --- a/docs/solo/README.md +++ b/docs/solo/README.md @@ -116,12 +116,16 @@ behavior on the label, so a rename touches copy only. recipient tags for both owners and guests. Saved chats offer the chat's people and your available agents; an untagged message never infers an agent recipient. Only available agents can be enrolled through tagging—outside humans must - already have been shared into the chat. Empty new-chat drafts start tagged with the device's default - agent for this account/community. **Agents → right-click → Make default agent** - changes that choice; its card shows **Default agent**. An unavailable choice - falls back to the first managed agent, then the first selectable agent, only - after agent evidence is complete and the requested archive read has settled. - An available explicitly chosen default does not wait for fallback evidence. + already have been shared into the chat. Empty new-chat drafts start tagged with the device's main + agent for this account/community. **Agents → right-click or "..." → Make main + agent** changes that choice; its card shows **Main agent**. The choice stays + until it is changed or the agent is deleted, and it is still tagged when its + runtime is signed out or broken. Without a choice, or after the chosen agent + is deleted, the first managed agent, then the first selectable agent, becomes + the main agent and is saved, only after agent evidence is complete and the + requested archive read has settled. A chosen agent missing for another reason + is stood in for without replacing the choice; the stand-in can be made main. + An available explicit choice does not wait for fallback evidence. Follow-ups keep all exact agents notified by the previous send, just like Team threads, including the shared Remember mentioned agents preference. The account default never replaces recipients in an existing chat. diff --git a/src/bundled/agents/AgentCard.module.css b/src/bundled/agents/AgentCard.module.css index 61eaf5e845..aa60fc3dea 100644 --- a/src/bundled/agents/AgentCard.module.css +++ b/src/bundled/agents/AgentCard.module.css @@ -378,10 +378,12 @@ html[data-keyboard-navigation] .card:focus-visible { } } /* Placeholder treatment until the card's status styling is designed. */ +/* Primary text follows the mode, as the card backdrop does: dark on light + cards and light on dark ones. */ .tag { position: absolute; - inset-block-start: var(--space-3); - inset-inline-start: var(--space-3); - color: var(--text-subtle); - @apply text-caption; + inset-block-start: var(--space-4); + inset-inline-start: var(--space-4); + color: var(--text-primary); + @apply text-label-sm; } diff --git a/src/bundled/agents/AgentCard.tsx b/src/bundled/agents/AgentCard.tsx index e6c87b9377..523282f4da 100644 --- a/src/bundled/agents/AgentCard.tsx +++ b/src/bundled/agents/AgentCard.tsx @@ -78,7 +78,7 @@ export function AgentCard({ menu, ...props }: Omit, "className" | "children"> & { - /** A short status beside the name, such as "Default agent". */ + /** A short status in the corner, such as "Main agent". */ tag?: ReactNode; agent: Portrayed; community?: string | undefined; diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 8e3fbcdbe8..c97a3dcc0f 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -33,6 +33,7 @@ import type { } from "../../features/agents/service"; import type { OldTeam, PortableAgent } from "../../features/agents/native"; import { localStore } from "../../features/agents/state"; +import { setDefaultAgent } from "../../features/solo/default-agent"; import type { RelayData } from "../../features/relay/service"; import type { Navigation } from "../../features/navigation/controller"; import type { PageNavigation } from "../../features/navigation/service"; @@ -2188,7 +2189,7 @@ it("retains the selected profile draft while viewing teams", async () => { expect(agents.save).not.toHaveBeenCalled(); }); -it("announces the default agent and presents its menu status without a fake toggle", async () => { +it("announces the main agent and presents its menu status without a fake toggle", async () => { const ada = agent("Ada", 1); const view = setup([ada]); const choices = { @@ -2196,7 +2197,7 @@ it("announces the default agent and presents its menu status without a fake togg complete: true, pending: false, selectable: [{ pubkey: ada.pubkey, name: ada.name, managed: true }], - archives: { status: "unavailable" }, + archives: { status: "unavailable", archived: [] }, }; const connection = { status: "ready", @@ -2214,13 +2215,89 @@ it("announces the default agent and presents its menu status without a fake togg subscribe: () => () => {}, } as unknown as RelayData; view.rerender(); - const card = screen.getByRole("gridcell", { name: "Ada, Default agent" }); + const card = screen.getByRole("gridcell", { name: "Ada, Main agent" }); + expect(within(card).getByText("Main agent")).toBeVisible(); fireEvent.keyDown(card, { key: "F10", shiftKey: true }); const menu = await screen.findByRole("menu", { name: "Actions for Ada" }); - expect(within(menu).getByText("Default agent")).toBeVisible(); + expect(within(menu).getByText("Main agent")).toBeVisible(); + // Right-click carries the same actions as the card's "..." menu. + expect( + within(menu).getByRole("menuitem", { name: "Copy public key" }), + ).toBeVisible(); expect(within(menu).queryByRole("menuitemcheckbox")).toBeNull(); }); +it("offers Make main agent from the card's actions button", async () => { + const user = userEvent.setup(); + const ada = agent("Ada", 1); + const bea = agent("Bea", 2); + const view = setup([ada, bea]); + const choices = { + status: "ready", + complete: true, + pending: false, + selectable: [ + { pubkey: ada.pubkey, name: ada.name, managed: true }, + { pubkey: bea.pubkey, name: bea.name, managed: true }, + ], + archives: { status: "unavailable", archived: [] }, + }; + const connection = { + status: "ready", + scope: "main-card-button", + session: { + agentChoices: { + snapshot: () => choices, + subscribe: () => () => {}, + retain: () => () => {}, + }, + }, + }; + const relay = { + snapshot: () => connection, + subscribe: () => () => {}, + } as unknown as RelayData; + view.rerender(); + expect( + screen.getByRole("gridcell", { name: "Ada, Main agent" }), + ).toBeVisible(); + const card = screen.getByRole("gridcell", { name: "Bea" }); + await user.click( + within(card).getByRole("button", { name: "Actions for Bea" }), + ); + await user.click( + await screen.findByRole("menuitem", { name: "Make main agent" }), + ); + expect( + await screen.findByRole("gridcell", { name: "Bea, Main agent" }), + ).toBeVisible(); + expect(screen.getByRole("gridcell", { name: "Ada" })).toBeVisible(); + // Right-click opens the same actions, including the main agent choice. + fireEvent.contextMenu(screen.getByRole("gridcell", { name: "Ada" })); + const menu = await screen.findByRole("menu", { name: "Actions for Ada" }); + await user.click( + within(menu).getByRole("menuitem", { name: "Make main agent" }), + ); + expect( + await screen.findByRole("gridcell", { name: "Ada, Main agent" }), + ).toBeVisible(); + expect(screen.getByRole("gridcell", { name: "Bea" })).toBeVisible(); +}); + +it("opens deletion from the card's right-click menu", async () => { + const user = userEvent.setup(); + const { agents } = setup([agent("Ada", 1)]); + fireEvent.contextMenu(screen.getByRole("gridcell", { name: "Ada" })); + const menu = await screen.findByRole("menu", { name: "Actions for Ada" }); + await user.click( + within(menu).getByRole("menuitem", { name: "Delete agent" }), + ); + const dialog = await screen.findByRole("dialog", { name: "Delete Ada?" }); + expect(agents.remove).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole("button", { name: "Delete" })); + expect(agents.remove).toHaveBeenCalledWith(agent("Ada", 1).pubkey); +}); + it("opens a Marketplace tile in the profile pane and returns focus on close and import", async () => { const user = userEvent.setup(); const view = setup([], { @@ -2295,3 +2372,47 @@ it("opens a Marketplace tile in the profile pane and returns focus on close and owner.dispose(); } }); + +it("offers Make main agent on a stand-in for a missing chosen agent", async () => { + const ada = agent("Ada", 1); + const view = setup([ada]); + const choices = { + status: "ready", + complete: true, + pending: false, + selectable: [{ pubkey: ada.pubkey, name: ada.name, managed: true }], + archives: { status: "unavailable", archived: [] }, + }; + const connection = { + status: "ready", + scope: "stand-in", + session: { + agentChoices: { + snapshot: () => choices, + subscribe: () => () => {}, + retain: () => () => {}, + }, + }, + }; + const relay = { + snapshot: () => connection, + subscribe: () => () => {}, + } as unknown as RelayData; + // The chosen agent isn't among the choices, and wasn't deleted. + act(() => { + setDefaultAgent("stand-in", "d".repeat(64)); + }); + view.rerender(); + const card = screen.getByRole("gridcell", { name: "Ada, Main agent" }); + fireEvent.contextMenu(card); + const menu = await screen.findByRole("menu", { name: "Actions for Ada" }); + fireEvent.click( + within(menu).getByRole("menuitem", { name: "Make main agent" }), + ); + fireEvent.contextMenu(card); + expect( + within( + await screen.findByRole("menu", { name: "Actions for Ada" }), + ).queryByRole("menuitem", { name: "Make main agent" }), + ).toBeNull(); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 70fe1e82d3..5366259a17 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -45,6 +45,7 @@ import { useAgentChoices } from "../../features/agents/use-choices"; import type { RelaySession } from "../../features/relay/session"; import { setDefaultAgent, + useChosenAgent, useDefaultAgent, } from "../../features/solo/default-agent"; import type { RelayData } from "../../features/relay/service"; @@ -71,6 +72,7 @@ import { ContextMenuRoot, ContextMenuTrigger, MenuNote, + MenuSeparator, MenuPopup, MenuItem, MenuRoot, @@ -601,6 +603,8 @@ type InventoryProps = { newAgent: ReactNode; /** The agent new Berd messages are tagged for, in this community. */ defaultAgent?: string | undefined; + /** False while another agent stands in for a missing chosen one. */ + defaultPinned?: boolean; onMakeDefault?: ((pubkey: string) => void) | undefined; defaultChoices?: ReadonlySet; }; @@ -612,6 +616,7 @@ function DefaultAgentInventory({ ...props }: InventoryProps & { session: RelaySession; scope: string }) { const current = useDefaultAgent(session, scope); + const chosen = useChosenAgent(scope); const choices = useAgentChoices(session, true, true); const [error, setError] = useState(""); return ( @@ -619,6 +624,7 @@ function DefaultAgentInventory({ agent.pubkey)) } @@ -626,7 +632,7 @@ function DefaultAgentInventory({ setError( setDefaultAgent(scope, pubkey) ? "" - : "The default agent couldn't be saved on this device.", + : "The main agent couldn't be saved on this device.", ); }} /> @@ -635,20 +641,16 @@ function DefaultAgentInventory({ ); } -/** An agent card's right-click quick actions; the context-menu key and - * Shift+F10 open them from the keyboard. */ -function AgentQuickActions({ +/** Right-clicking an agent card opens its actions menu; the context-menu key + * and Shift+F10 open it from the keyboard. */ +function CardContextMenu({ name, card, - isDefault, - available, - onMakeDefault, + children, }: { - available: boolean; name: string; card: ReactElement; - isDefault: boolean; - onMakeDefault(): void; + children: ReactNode; }) { const [open, setOpen] = useState(false); const [anchor, setAnchor] = useState(); @@ -662,7 +664,18 @@ function AgentQuickActions({ > { + if (!event.currentTarget.contains(event.target as Node)) + event.preventBaseUIHandler(); + }} + onTouchStart={(event) => { + if (!event.currentTarget.contains(event.target as Node)) + event.preventBaseUIHandler(); + }} onKeyDown={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; if ( event.key === "ContextMenu" || (event.shiftKey && event.key === "F10") @@ -675,13 +688,7 @@ function AgentQuickActions({ }} /> - {isDefault ? ( - Default agent - ) : ( - - Make default agent - - )} + {children} ); @@ -698,6 +705,7 @@ function Inventory({ onBuild, newAgent, defaultAgent, + defaultPinned, defaultChoices, onMakeDefault, }: InventoryProps) { @@ -759,64 +767,56 @@ function Inventory({ {agents.map((agent, position) => { const type = typeOf(agent); const active = agent.pubkey === selected; - const card = ( - onBuild(agent.pubkey)} - onRemoved={() => onSelect()} - role="gridcell" - aria-label={ - agent.pubkey === defaultAgent - ? `${agent.name}, Default agent` - : agent.name - } - data-selected={active} - aria-selected={active} - tabIndex={active || (index < 0 && position === 0) ? 0 : -1} - data-unavailable={type ? undefined : ""} - onClick={(event) => { - if ( - event.currentTarget.contains(event.target as Node) && - !(event.target as Element).closest( - "[data-card-control], button, input", - ) - ) - onSelect(active ? undefined : agent.pubkey); - }} - onDoubleClick={(event) => { - if ( - event.currentTarget.contains(event.target as Node) && - !(event.target as Element).closest( - "[data-card-control], button, input", - ) - ) - onBuild(agent.pubkey); - }} - agent={agent} - community={agent.relay || undefined} - icon={} - detail={summaryOf(agent, type)} - /> - ); + const isMain = agent.pubkey === defaultAgent; return ( // biome-ignore lint/a11y/useSemanticElements: Layout grid rows group cards without table layout. // biome-ignore lint/a11y/useFocusableInteractive: Roving focus belongs to the gridcell, not its row wrapper.
- {onMakeDefault ? ( - onMakeDefault(agent.pubkey)} - /> - ) : ( - card - )} + onMakeDefault(agent.pubkey), + } + : undefined + } + service={service} + type={type} + onBuild={() => onBuild(agent.pubkey)} + onRemoved={() => onSelect()} + role="gridcell" + aria-label={isMain ? `${agent.name}, Main agent` : agent.name} + data-selected={active} + aria-selected={active} + tabIndex={active || (index < 0 && position === 0) ? 0 : -1} + data-unavailable={type ? undefined : ""} + onClick={(event) => { + if ( + event.currentTarget.contains(event.target as Node) && + !(event.target as Element).closest( + "[data-card-control], button, input", + ) + ) + onSelect(active ? undefined : agent.pubkey); + }} + onDoubleClick={(event) => { + if ( + event.currentTarget.contains(event.target as Node) && + !(event.target as Element).closest( + "[data-card-control], button, input", + ) + ) + onBuild(agent.pubkey); + }} + agent={agent} + community={agent.relay || undefined} + icon={} + detail={summaryOf(agent, type)} + />
); })} @@ -832,6 +832,7 @@ function InventoryCard({ type, onBuild, onRemoved, + main, ...props }: Omit, "agent"> & { agent: Agent; @@ -839,6 +840,10 @@ function InventoryCard({ type: RegisteredAgentType | undefined; onBuild(): void; onRemoved(): void; + /** Whether it is the community's main agent and can be made it. */ + main?: + | { isMain: boolean; pinned: boolean; available: boolean; onMake(): void } + | undefined; }) { const agent = props.agent; const [saving, setSaving] = useState(false); @@ -856,75 +861,94 @@ function InventoryCard({ setSaving(false); } }; - const menu = (remove?: () => void) => ( - - } - /> - } - /> - - {editable && Open settings} - { - void navigator.clipboard - .writeText(agent.pubkey) - .catch((reason) => setError(message(reason))); - }} - > - Copy public key - - {remove && ( - - Delete agent + // The "..." button and right-click share one set of actions. + const actions = (remove?: () => void) => ( + <> + {main && + // A stand-in for a missing chosen agent can be made main for good. + (main.pinned ? ( + Main agent + ) : ( + + Make main agent - )} - - + ))} + {main && } + {editable && Open settings} + { + void navigator.clipboard + .writeText(agent.pubkey) + .catch((reason) => setError(message(reason))); + }} + > + Copy public key + + {remove && ( + + Delete agent + + )} + ); - return ( - - ) - } - > - {(Model || error) && ( -
void) => ( + + } + /> + } + /> + {actions(remove)} + + } > - {Model && ( - void save(config)} - /> - )} - {error && ( - - {error} - + {(Model || error) && ( +
+ {Model && ( + void save(config)} + /> + )} + {error && ( + + {error} + + )} +
)} -
- )} -
+ + } + > + {actions(remove)} + + ); + return agent.place === "elsewhere" ? ( + card() + ) : ( + ); } diff --git a/src/features/agents/choices.ts b/src/features/agents/choices.ts index 694e5189e1..2aff8760d8 100644 --- a/src/features/agents/choices.ts +++ b/src/features/agents/choices.ts @@ -213,10 +213,12 @@ export function createAgentChoices({ if (archive && archives?.snapshot().status === "idle") void archives.ensure(); }, - async refresh(source: SelectionSource = true) { + /** `fresh` rereads inventory even over a read already in flight, as after + * a change that read may predate. */ + async refresh(source: SelectionSource = true, { fresh = false } = {}) { if (signal.aborted) return; await Promise.all([ - usesLegacy(source) ? library.refresh() : undefined, + usesLegacy(source) ? library.refresh({ fresh }) : undefined, usesLocal(source) ? native?.refresh() : undefined, // Archive reads stay lazy; templates always need fresh evidence. source === "templates" || archives?.snapshot().status !== "idle" diff --git a/src/features/agents/library.test.ts b/src/features/agents/library.test.ts index 5481407ca2..a61070fe43 100644 --- a/src/features/agents/library.test.ts +++ b/src/features/agents/library.test.ts @@ -169,3 +169,40 @@ it("every library snapshot carries definition art to identities without their ow undefined, ]); }); + +it("a fresh refresh supersedes a read in flight, and its waiters wait for the new one", async () => { + const reads: ((rows: string[]) => void)[] = []; + const library = createAgentLibrary( + (signal) => + new Promise((resolve) => { + reads.push((rows) => { + if (!signal.aborted) + resolve({ + definitions: [], + identities: rows.map((pubkey) => ({ pubkey, name: pubkey })), + }); + else resolve({ definitions: [], identities: [] }); + }); + }), + ); + let joinedDone = false; + const joined = library.queries.refresh().then(() => { + joinedDone = true; + }); + await Promise.resolve(); + await Promise.resolve(); + const fresh = library.queries.refresh({ fresh: true }); + await Promise.resolve(); + await Promise.resolve(); + // The stale read settles first; it publishes nothing and its waiter waits. + reads[0]?.(["stale"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(joinedDone).toBe(false); + expect(library.queries.snapshot().status).toBe("loading"); + reads[1]?.(["current"]); + await Promise.all([joined, fresh]); + expect(library.queries.snapshot()).toMatchObject({ + status: "ready", + identities: [{ pubkey: "current" }], + }); +}); diff --git a/src/features/agents/library.ts b/src/features/agents/library.ts index e8833dc387..9f8388c89b 100644 --- a/src/features/agents/library.ts +++ b/src/features/agents/library.ts @@ -57,9 +57,13 @@ export function createAgentLibrary( snapshot = Object.freeze(next); for (const listener of listeners) notify(listener); } - function refresh(): Promise { + /** Joins a read in flight, unless `fresh`: then it supersedes that read, + * which may predate a change the caller just made, and that read's waiters + * wait for the new one. */ + function refresh({ fresh = false } = {}): Promise { if (closed || !read) return Promise.resolve(); - if (pending) return pending; + if (pending && !fresh) return pending; + controller?.abort(); const owned = new AbortController(); controller = owned; publish({ ...snapshot, status: "loading" }); @@ -87,7 +91,10 @@ export function createAgentLibrary( controller = undefined; pending = undefined; } - }); + }) + // A superseded read's waiters follow the read that replaced it, so a + // finished refresh always means current inventory. + .then(() => (controller !== owned && pending ? pending : undefined)); return pending; } function clear() { diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index e4f74a4810..8ee02609e6 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -98,6 +98,7 @@ export function fakeRelay(origin = "https://relay.example.test") { const memories = new Map(); const session = { archives, + agentChoices: { refresh: vi.fn(async () => {}) }, agentMemories: { read: vi.fn(async (pubkey: string) => { const body = memories.get(pubkey); diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 2a4b1a4b7d..ec195b5f0f 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8,6 +8,7 @@ import { npubEncode } from "nostr-tools/nip19"; import { bytesToHex } from "nostr-tools/utils"; import { finalizeEvent, generateSecretKey } from "nostr-tools"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { forgetDefaultAgent } from "../solo/default-agent"; import { AgentConfigStore, type Subject } from "./agent-config"; import type { Mode } from "./agent-start"; import { channelTemplates } from "../channel-templates/local"; @@ -72,6 +73,10 @@ const records = vi.hoisted(() => ({ remove: vi.fn(), removeAgent: vi.fn(), })); +vi.mock("../solo/default-agent", async (original) => ({ + ...(await original()), + forgetDefaultAgent: vi.fn(), +})); vi.mock("./relay-removal", () => ({ removeAgentFromChannels: leaveChannels, publishAgentRecord: records.publish, @@ -2522,13 +2527,16 @@ it("stops refreshing core memory for a community once it is left", async () => { }); it("keeps a Delete that fails partway listed and stopped, deletes nothing, and retries nothing", async () => { - const { service, native, archives, recover } = await setup(); + const { service, native, archives, recover, session } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); leaveChannels.mockRejectedValueOnce(new Error("offline")); await expect(service.remove(bot)).rejects.toThrow(UNFINISHED); warn.mockRestore(); expect(service.find(bot)?.deleting).toBe(true); + // Only a finished Delete hands the main agent role on. + expect(forgetDefaultAgent).not.toHaveBeenCalled(); + expect(session.agentChoices.refresh).not.toHaveBeenCalled(); expect(native.remove).not.toHaveBeenCalled(); // Reconnecting retries nothing. recover(); @@ -2550,6 +2558,17 @@ it("keeps a Delete that fails partway listed and stopped, deletes nothing, and r "forget", ]); expect(service.find(bot)).toBeUndefined(); + // Cached choices are reread first, so the deleted agent can't be saved + // again as its own replacement. + expect(session.agentChoices.refresh).toHaveBeenCalledWith(true, { + fresh: true, + }); + expect(forgetDefaultAgent).toHaveBeenCalledWith(bot); + expect( + vi.mocked(session.agentChoices.refresh).mock.invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(forgetDefaultAgent).mock.invocationCallOrder[0] ?? 0, + ); }); it("shows an agent whose settings could not be saved with its type's defaults", async () => { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 9161c8d561..b2241d3717 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -125,6 +125,7 @@ import { import { readInterrupted, writeInterrupted } from "./interrupted"; import { type AddedTool, BUZZ_SESSION } from "../../buzz-mcp/session-tools"; import { nameSoloChat } from "./solo-chat-name"; +import { forgetDefaultAgent } from "../solo/default-agent"; /** Where an agent runs: `here`, with its key on this device; `hosted`, by its * type's provider; or `elsewhere`, on another of the owner's devices. Only an @@ -2545,6 +2546,7 @@ export class AgentsService extends Service implements Agents { await native.forget(pubkey); // Drops its settings. await this.refresh(); + await handOnMainAgent(binding.session, pubkey); } async unarchive(pubkey: string): Promise { @@ -2581,6 +2583,7 @@ export class AgentsService extends Service implements Agents { await this.writeUnfinished( this.unfinished.filter((setup) => setup.pubkey !== pubkey), ); + await handOnMainAgent(binding.session, pubkey); }); } private async exclusive(pubkey: string, step: () => Promise) { @@ -3610,6 +3613,13 @@ async function leaveCommunity( if (state === "not-archived" && (await archives.consent(pubkey, signal))) await archives.request("archive", pubkey, signal); } +/** After a finished Delete, rereads the shared agent choices, superseding any + * read begun before it, so their inventory no longer offers it; then forgets + * it as the main agent so the next available one is saved in its place. */ +async function handOnMainAgent(session: RelaySession, pubkey: string) { + await session.agentChoices.refresh(true, { fresh: true }); + forgetDefaultAgent(pubkey); +} function stillArchived(name: string) { return `${name} is still archived in this community, so it stays out of member lists and mentions there.`; } diff --git a/src/features/solo/default-agent.test.tsx b/src/features/solo/default-agent.test.tsx index 1614499cca..8169ef871b 100644 --- a/src/features/solo/default-agent.test.tsx +++ b/src/features/solo/default-agent.test.tsx @@ -2,8 +2,14 @@ import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; import { afterEach, expect, it } from "vitest"; +import { createAgentChoices } from "../agents/choices"; +import { createAgentLibrary } from "../agents/library"; import type { RelaySession } from "../relay/session"; -import { setDefaultAgent, useDefaultAgent } from "./default-agent"; +import { + forgetDefaultAgent, + setDefaultAgent, + useDefaultAgent, +} from "./default-agent"; afterEach(() => { cleanup(); @@ -17,7 +23,7 @@ it("choosing a default updates new-chat routing only in its account/community sc { pubkey: "a".repeat(64), name: "First", managed: true }, { pubkey: "b".repeat(64), name: "Second", managed: true }, ], - archives: { status: "unavailable" }, + archives: { status: "unavailable", archived: [] }, }; const session = { agentChoices: { @@ -60,7 +66,7 @@ it("waits for complete fallback and settled archive evidence, but honors an avai complete: false, pending: true, selectable: [relay], - archives: { status: "loading" }, + archives: { status: "loading", archived: [] }, }; const listeners = new Set<() => void>(); const session = { @@ -99,7 +105,7 @@ it("waits for complete fallback and settled archive evidence, but honors an avai }); expect(screen.getByLabelText("choice")).toBeEmptyDOMElement(); act(() => { - choices = { ...choices, archives: { status: "ready" } }; + choices = { ...choices, archives: { status: "ready", archived: [] } }; for (const notify of listeners) notify(); }); expect(screen.getByLabelText("choice")).toHaveTextContent("Local"); @@ -113,7 +119,7 @@ it("a requested agent replaces the default, and nothing stands in for it", () => { pubkey: "a".repeat(64), name: "First", managed: true }, { pubkey: "b".repeat(64), name: "Second", managed: true }, ], - archives: { status: "unavailable" }, + archives: { status: "unavailable", archived: [] }, }; const session = { agentChoices: { @@ -139,3 +145,209 @@ it("a requested agent replaces the default, and nothing stands in for it", () => expect(screen.getByLabelText("b").textContent).toBe("Second"); expect(screen.getByLabelText("d").textContent).toBe("none"); }); + +it("keeps the main agent stable: saves the first pick and replaces only a deleted choice", () => { + const first = { pubkey: "a".repeat(64), name: "First", managed: true }; + const second = { pubkey: "b".repeat(64), name: "Second", managed: true }; + let choices = { + status: "ready", + complete: true, + selectable: [first, second], + archives: { status: "ready", archived: [] as string[] }, + }; + const listeners = new Set<() => void>(); + const update = (next: Partial) => { + choices = { ...choices, ...next }; + for (const notify of listeners) notify(); + }; + const session = { + agentChoices: { + snapshot: () => choices, + subscribe: (notify: () => void) => { + listeners.add(notify); + return () => listeners.delete(notify); + }, + retain: () => () => {}, + ensure: () => {}, + }, + } as unknown as RelaySession; + function Selection() { + return ( + + {useDefaultAgent(session, "stable")?.name} + + ); + } + render(); + // The automatic pick is saved, so a different load order doesn't move it. + expect(screen.getByLabelText("main")).toHaveTextContent("First"); + act(() => update({ selectable: [second, first] })); + expect(screen.getByLabelText("main")).toHaveTextContent("First"); + // A chosen agent missing for a while is stood in for, then comes back. + act(() => { + setDefaultAgent("stable", second.pubkey); + }); + act(() => update({ selectable: [first] })); + expect(screen.getByLabelText("main")).toHaveTextContent("First"); + act(() => update({ selectable: [first, second] })); + expect(screen.getByLabelText("main")).toHaveTextContent("Second"); + // Deleting it hands the role on for good. + act(() => + update({ + selectable: [first], + archives: { status: "ready", archived: [second.pubkey] }, + }), + ); + expect(screen.getByLabelText("main")).toHaveTextContent("First"); + act(() => + update({ + selectable: [second, first], + archives: { status: "ready", archived: [] }, + }), + ); + expect(screen.getByLabelText("main")).toHaveTextContent("First"); +}); + +it("a deleted main agent hands the role on for good, even without archive evidence", () => { + const [a, b, c] = ["a", "b", "c"].map((letter) => ({ + pubkey: letter.repeat(64), + name: letter.toUpperCase(), + managed: true, + })); + let choices = { + status: "ready", + complete: true, + selectable: [a, b, c], + archives: { status: "unavailable", archived: [] as string[] }, + }; + const listeners = new Set<() => void>(); + const update = (selectable: typeof choices.selectable) => { + choices = { ...choices, selectable }; + for (const notify of listeners) notify(); + }; + const session = { + agentChoices: { + snapshot: () => choices, + subscribe: (notify: () => void) => { + listeners.add(notify); + return () => listeners.delete(notify); + }, + retain: () => () => {}, + ensure: () => {}, + }, + } as unknown as RelaySession; + function Selection({ scope }: { scope: string }) { + return ( + + {useDefaultAgent(session, scope)?.name} + + ); + } + render( + <> + + + , + ); + act(() => { + setDefaultAgent("one", b?.pubkey ?? ""); + setDefaultAgent("two", c?.pubkey ?? ""); + }); + act(() => { + update([a, c].filter((agent) => agent !== undefined)); + forgetDefaultAgent(b?.pubkey ?? ""); + }); + expect(screen.getByLabelText("one")).toHaveTextContent("A"); + // Another scope's choice is untouched. + expect(screen.getByLabelText("two")).toHaveTextContent("C"); + act(() => update([c, a].filter((agent) => agent !== undefined))); + expect(screen.getByLabelText("one")).toHaveTextContent("A"); +}); + +it("after a finished delete, rereads cached inventory before choosing a replacement", async () => { + const a = { pubkey: "a".repeat(64), name: "A" }; + const b = { pubkey: "b".repeat(64), name: "B" }; + let rows = [b, a]; + const library = createAgentLibrary(async () => ({ + definitions: [], + identities: rows, + })); + const agentChoices = createAgentChoices({ + scope: `https://relay.example.test:${"f".repeat(64)}`, + library: library.queries, + signal: new AbortController().signal, + }); + await library.queries.refresh(); + const session = { agentChoices } as unknown as RelaySession; + function Selection() { + return ( + + {useDefaultAgent(session, "reread")?.name} + + ); + } + render(); + act(() => { + setDefaultAgent("reread", b.pubkey); + }); + expect(screen.getByLabelText("main")).toHaveTextContent("B"); + // B's record is deleted; the cached library still lists it until reread. + rows = [a]; + await act(async () => { + await agentChoices.refresh(); + forgetDefaultAgent(b.pubkey); + }); + expect(screen.getByLabelText("main")).toHaveTextContent("A"); + rows = [{ pubkey: "c".repeat(64), name: "C" }, a]; + await act(() => agentChoices.refresh()); + expect(screen.getByLabelText("main")).toHaveTextContent("A"); +}); + +it("a read begun before the delete can't bring the deleted main agent back", async () => { + const a = { pubkey: "a".repeat(64), name: "A" }; + const b = { pubkey: "b".repeat(64), name: "B" }; + let rows = [b, a]; + let release: (() => void) | undefined; + let hold = false; + const library = createAgentLibrary(async () => { + const captured = rows; + if (hold) await new Promise((resolve) => (release = resolve)); + return { definitions: [], identities: captured }; + }); + const agentChoices = createAgentChoices({ + scope: `https://relay.example.test:${"f".repeat(64)}`, + library: library.queries, + signal: new AbortController().signal, + }); + await library.queries.refresh(); + const session = { agentChoices } as unknown as RelaySession; + function Selection() { + return ( + + {useDefaultAgent(session, "held")?.name} + + ); + } + render(); + act(() => { + setDefaultAgent("held", b.pubkey); + }); + // A read that captured B is still in flight when B is deleted. + hold = true; + const stale = library.queries.refresh(); + await act(async () => {}); + hold = false; + rows = [a]; + await act(async () => { + await agentChoices.refresh(true, { fresh: true }); + forgetDefaultAgent(b.pubkey); + }); + await act(async () => { + release?.(); + await stale; + }); + expect(screen.getByLabelText("main")).toHaveTextContent("A"); + rows = [{ pubkey: "c".repeat(64), name: "C" }, a]; + await act(() => agentChoices.refresh()); + expect(screen.getByLabelText("main")).toHaveTextContent("A"); +}); diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index 823cc26718..509b6aea38 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -1,8 +1,13 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useSyncExternalStore } from "react"; import { useAgentChoices } from "../agents/use-choices"; import type { MentionRecipient } from "../messages/mention-draft"; import type { RelaySession } from "../relay/session"; -import { readView, subscribeView, writeView } from "../../shared/view-state"; +import { + clearViewValue, + readView, + subscribeView, + writeView, +} from "../../shared/view-state"; // Saved on this device for the account and community (the view scope). const KEY = "solo:default-agent"; @@ -12,7 +17,14 @@ export function setDefaultAgent(scope: string, pubkey: string) { return writeView(scope, KEY, /^[0-9a-f]{64}$/.test(pubkey) ? pubkey : null); } -function useChosenAgent(scope: string) { +/** A deleted agent stops being the main agent everywhere it was chosen; the + * next available one takes over and is saved. */ +export function forgetDefaultAgent(pubkey: string) { + clearViewValue(KEY, pubkey); +} + +/** The agent explicitly saved as main, whether or not it can be tagged now. */ +export function useChosenAgent(scope: string) { return useSyncExternalStore( useCallback((notify) => subscribeView(scope, notify), [scope]), () => { @@ -23,11 +35,16 @@ function useChosenAgent(scope: string) { } /** - * The agent new Berd messages are tagged for: the one chosen as default when - * it can still be tagged, otherwise the first agent the viewer runs here, else - * the first agent they can tag. Bestie may own this choice later. A requested - * agent, as when search opens a new chat with it, replaces that choice: it is - * tagged once it can be, and no other agent stands in for it. + * The agent new Berd messages are tagged for. A chosen main agent stays chosen + * for as long as it exists, even when its runtime is signed out or broken, so + * a failure shows on the agent the viewer expects. Without a choice, or once + * the chosen agent is deleted (archived), the first agent the viewer runs + * here, else the first they can tag, becomes the main agent and is saved so + * it doesn't shift between loads. A chosen agent missing for another reason + * is stood in for without replacing the choice. Bestie may own this choice + * later. A requested agent, as when search opens a new chat with it, replaces + * that choice: it is tagged once it can be, and no other agent stands in for + * it. */ export function useDefaultAgent( session: RelaySession, @@ -42,14 +59,21 @@ export function useDefaultAgent( choices.complete && choices.archives.status !== "idle" && choices.archives.status !== "loading"; - const agent = - choices.selectable.find( - (choice) => choice.pubkey === (requested ?? chosen), - ) ?? - (fallbackReady + const picked = choices.selectable.find( + (choice) => choice.pubkey === (requested ?? chosen), + ); + const fallback = + !picked && fallbackReady ? (choices.selectable.find((choice) => choice.managed) ?? choices.selectable[0]) - : undefined); + : undefined; + const replaces = + !!fallback && + (chosen === undefined || choices.archives.archived.includes(chosen)); + useEffect(() => { + if (replaces && fallback) setDefaultAgent(scope, fallback.pubkey); + }, [replaces, fallback, scope]); + const agent = picked ?? fallback; return agent ? { pubkey: agent.pubkey, name: agent.managedName ?? agent.name } : undefined; diff --git a/src/shared/view-state.ts b/src/shared/view-state.ts index cd2d6021c3..6f819fcd2a 100644 --- a/src/shared/view-state.ts +++ b/src/shared/view-state.ts @@ -170,6 +170,37 @@ export function clearViewScope(scope: string) { changed(scope); } +/** Forgets one key in every scope where it holds `value`, as when the thing it + * names is deleted. */ +export function clearViewValue(key: string, value: unknown) { + const revision = JSON.stringify(value); + const scopes: string[] = []; + try { + for (let index = 0; index < localStorage.length; index++) { + const storageKey = localStorage.key(index); + if (!storageKey?.startsWith("buzz-view.v1:")) continue; + let pair: unknown; + try { + pair = JSON.parse(storageKey.slice("buzz-view.v1:".length)); + } catch { + continue; + } + if ( + Array.isArray(pair) && + typeof pair[0] === "string" && + pair[1] === key && + localStorage.getItem(storageKey) === revision + ) + scopes.push(pair[0]); + } + for (const scope of scopes) + localStorage.removeItem(`buzz-view.v1:${JSON.stringify([scope, key])}`); + } catch { + /* Storage unavailable; nothing was saved to forget. */ + } + for (const scope of scopes) changed(scope); +} + /** Recovery callers must confirm cleanup before retiring their durable operation. */ export function clearView(scope: string, ...keys: string[]) { for (const key of keys) {