diff --git a/docs/agents.md b/docs/agents.md index 6d8704279..5f90e3a38 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -679,9 +679,10 @@ in its home record and applies everywhere. agent's memory in that community stay, so they come back if it is added again; its attention there does not. - **Teams** work like agents: a team shows in every community where it has a - member (or in all of them while it has none), but has one home community. A - local team is shared by every community; a member's roster lists only teammates - in the same community, and the editor keeps members that are only elsewhere. + member (or in all of them while it has none), and a synced team always shows + in its one home community. A local team is shared by every community; a + member's roster lists only teammates in the same community, and the editor + keeps members that are only elsewhere. Where the community's relay supports config channels, a new team, or one imported from old Buzz, is synced: its NIP-AP config channel (name and instructions) is created only in the community it was made in, and its members @@ -695,6 +696,17 @@ in its home record and applies everywhere. one at a time per team, and report anything left part way. Existing local teams are not migrated. - **Hosted agents** stay in one community, as their provider holds the key. + Only a synced team homed there can have them as members, so the team editor + offers them only for such a team, or for a new one that will sync there. One + picked in a community Save doesn't put the team in, as when another is + selected before a new team's first Save, is left off unless the team already + has it. One picked where Save puts the team stays, shown checked, while that + community's agent list is read again, but Save refuses it until the list is + read, and refuses it once the list is read without it. Unchecking it leaves + it off. They do not get its instructions, but rosters name them. + Until the community's agent list is read, which tells hosted agents from + ones on other devices, the team's roster there names each member not on this + device by npub alone. `removeRelayAgent` applies the same steps to a relay-only agent: archive when consent exists, delete the owner's kind 30177 record (NIP-09), then remove it from diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index cb29da7b9..2425091f8 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -165,6 +165,7 @@ function setup( navigator?: Navigation; communities?: CommunityReader; }, + teams = [] as AgentsSnapshot["teams"], } = {}, ) { let snapshot: AgentsSnapshot = { @@ -173,7 +174,7 @@ function setup( agents: saved, running: saved, interrupted: [], - teams: [], + teams, }; const listeners = new Set<() => void>(); const echoProvider = provider("ready", ({ onClose }) => ( @@ -356,6 +357,12 @@ function setup( for (const listener of listeners) listener(); return team; }), + // The selected community, where each hosted agent was picked, syncs teams + // and is every synced team's home. + teamHere: vi.fn( + ({ id }: Pick, _community?: string) => + !id || !!snapshot.teams.find((team) => team.id === id)?.revision, + ), core: vi.fn(async () => undefined), removeTeam: vi.fn(async (id: string) => { snapshot = { @@ -397,7 +404,11 @@ function setup( snapshot = { ...snapshot, agents: next }; for (const listener of listeners) listener(); }; - return { agents, echoProvider, types, show, relay, ...view }; + const change = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) listener(); + }; + return { agents, echoProvider, types, show, change, relay, ...view }; } it("opens settings for a selected agent beside the grid and follows the arrow keys", async () => { @@ -2460,6 +2471,255 @@ it("makes a team of chosen agents and lists it with its members", async () => { expect(screen.queryByRole("list", { name: "Teams" })).not.toBeInTheDocument(); }); +it("offers hosted agents as members, warning that they may not get the instructions", async () => { + const user = userEvent.setup(); + setup([ + agent("Ada", 1), + { ...agent("Sky", 2), place: "hosted" }, + { ...agent("Kai", 5), place: "hosted" }, + { ...agent("Bo", 3), place: "hosted", unfinished: true }, + { ...agent("Cy", 4), place: "elsewhere" }, + ]); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + await user.click(screen.getByRole("button", { name: "New team" })); + await user.click( + await screen.findByRole("menuitem", { name: "Create a new team" }), + ); + const members = ["Ada", "Sky", "Bo", "Cy"].map((name) => + screen.queryByRole("checkbox", { name }), + ); + expect(members.map(Boolean)).toEqual([true, true, false, false]); + const instructions = screen.getByRole("textbox", { name: "Instructions" }); + const local = "Every member follows these after its own instructions."; + await user.click(screen.getByRole("checkbox", { name: "Ada" })); + expect(instructions).toHaveAccessibleDescription(local); + await user.click(screen.getByRole("checkbox", { name: "Sky" })); + expect(instructions).toHaveAccessibleDescription( + "Agents on your devices follow these after their own instructions. Agents run by their provider (Sky) will not get these team instructions.", + ); + await user.click(screen.getByRole("checkbox", { name: "Kai" })); + expect(instructions).toHaveAccessibleDescription( + /\(Sky and Kai\) will not get these team instructions\.$/, + ); + await user.click(screen.getByRole("checkbox", { name: "Sky" })); + await user.click(screen.getByRole("checkbox", { name: "Kai" })); + expect(instructions).toHaveAccessibleDescription(local); +}); + +it("offers hosted agents only to a team Save puts in this community", async () => { + const user = userEvent.setup(); + const ada = agent("Ada", 1); + const synced = (id: string, name: string) => ({ + id, + name, + instructions: "", + members: [ada.pubkey], + revision: "1", + }); + const { agents } = setup([ada, { ...agent("Sky", 2), place: "hosted" }], { + teams: [ + { id: "crew", name: "Crew", instructions: "", members: [ada.pubkey] }, + synced("synced", "Synced"), + synced("away", "Away"), + ], + }); + // Away's home is another community, and a new team made here is local. + agents.teamHere.mockImplementation(({ id }) => id === "synced"); + const sky = () => screen.queryByRole("checkbox", { name: "Sky" }); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + for (const name of ["Crew", "Away", "Synced"]) { + await user.click(screen.getByRole("button", { name })); + expect(screen.getByRole("checkbox", { name: "Ada" })).toBeChecked(); + expect(!!sky()).toBe(name === "Synced"); + await user.keyboard("{Escape}"); + } + await user.click(screen.getByRole("button", { name: "New team" })); + await user.click( + await screen.findByRole("menuitem", { name: "Create a new team" }), + ); + expect(agents.teamHere).toHaveBeenCalledWith({ draft: expect.any(String) }); + expect(screen.getByRole("checkbox", { name: "Ada" })).toBeInTheDocument(); + expect(sky()).not.toBeInTheDocument(); +}); + +it("leaves a hosted agent picked in another community off a new team, keeping the rest", async () => { + const user = userEvent.setup(); + const ada = agent("Ada", 1); + const sky: Agent = { ...agent("Sky", 2), place: "hosted" }; + const { agents, show } = setup([ada, sky], { + teams: [ + { + id: "sky", + name: "Sky crew", + instructions: "", + members: [sky.pubkey], + revision: "1", + }, + ], + }); + // Ada runs on this device, in the first community only. + agents.memberships.mockImplementation((pubkey) => + pubkey === ada.pubkey + ? [{ key: ada.key, origin: "https://relay.example", connected: true }] + : [], + ); + // A new team syncs in the selected community. + let selected = ada.relay; + agents.teamHere.mockImplementation( + ({ id }, community = selected) => !!id || community === selected, + ); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + await user.click(screen.getByRole("button", { name: "New team" })); + await user.click( + await screen.findByRole("menuitem", { name: "Create a new team" }), + ); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Crew"); + await user.click(screen.getByRole("checkbox", { name: "Ada" })); + await user.click(screen.getByRole("checkbox", { name: "Sky" })); + // Another community that syncs teams, with its own hosted agent. + const other = "wss://other.example"; + act(() => { + selected = other; + show([ + { ...agent("Bo", 3), relay: other }, + { ...agent("Kai", 5), place: "hosted", relay: other }, + ]); + }); + expect(screen.getByRole("form", { name: "New team" })).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: "Sky" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Kai" })).not.toBeChecked(); + expect( + screen.getByText("+1 in your other communities, who stay on the team."), + ).toBeInTheDocument(); + const instructions = screen.getByRole("textbox", { name: "Instructions" }); + expect(instructions).toHaveAccessibleDescription( + "Every member follows these after its own instructions.", + ); + await user.click(screen.getByRole("button", { name: "Create team" })); + expect(agents.saveTeam).toHaveBeenLastCalledWith({ + draft: expect.any(String), + name: "Crew", + instructions: "", + members: [ada.pubkey], + }); + // A hosted member the team already has stays, though not listed here. + await user.click(screen.getByRole("button", { name: "Sky crew" })); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(agents.saveTeam).toHaveBeenLastCalledWith( + expect.objectContaining({ id: "sky", members: [sky.pubkey] }), + ); +}); + +it("keeps a hosted agent picked for a new team while its community lists agents again", async () => { + const user = userEvent.setup(); + const ada = agent("Ada", 1); + const sky: Agent = { ...agent("Sky", 2), place: "hosted" }; + const { agents, show } = setup([ada, sky]); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + await user.click(screen.getByRole("button", { name: "New team" })); + await user.click( + await screen.findByRole("menuitem", { name: "Create a new team" }), + ); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Crew"); + await user.click(screen.getByRole("checkbox", { name: "Ada" })); + await user.click(screen.getByRole("checkbox", { name: "Sky" })); + // The community reconnects and still syncs teams, but its hosted agents + // are unlisted until their records are read again, so Save refuses Sky, + // which still shows checked. + act(() => show([ada])); + expect(screen.getByRole("checkbox", { name: "Sky" })).toBeChecked(); + const refusal = "Can't tell yet whether Sky is in relay.example"; + agents.saveTeam.mockRejectedValueOnce(new Error(refusal)); + await user.click(screen.getByRole("button", { name: "Create team" })); + const form = screen.getByRole("form", { name: "New team" }); + expect(await within(form).findByRole("alert")).toHaveTextContent(refusal); + const draft = agents.saveTeam.mock.calls.at(-1)?.[0].draft; + expect(agents.saveTeam).toHaveBeenLastCalledWith({ + draft: expect.any(String), + name: "Crew", + instructions: "", + members: [ada.pubkey, sky.pubkey], + }); + // Once they are, Sky is listed again, and Create keeps the draft. + act(() => show([ada, sky])); + expect(screen.getAllByRole("checkbox", { name: "Sky" })).toHaveLength(1); + expect(screen.getByRole("checkbox", { name: "Sky" })).toBeChecked(); + await user.click(screen.getByRole("button", { name: "Create team" })); + expect(agents.saveTeam).toHaveBeenLastCalledWith({ + draft, + name: "Crew", + instructions: "", + members: [ada.pubkey, sky.pubkey], + }); +}); + +it("lets a hosted agent picked for a new team be unchecked once it is no longer listed", async () => { + const user = userEvent.setup(); + const ada = agent("Ada", 1); + const sky: Agent = { ...agent("Sky", 2), place: "hosted" }; + const { agents, show } = setup([ada, sky]); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + await user.click(screen.getByRole("button", { name: "New team" })); + await user.click( + await screen.findByRole("menuitem", { name: "Create a new team" }), + ); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Crew"); + await user.click(screen.getByRole("checkbox", { name: "Ada" })); + await user.click(screen.getByRole("checkbox", { name: "Sky" })); + // Sky is deleted from another device, and the community's agent list is + // read without it, so Save refuses it. It still shows, named in the warning. + act(() => show([ada])); + expect( + screen.getByRole("textbox", { name: "Instructions" }), + ).toHaveAccessibleDescription( + /\(Sky\) will not get these team instructions\.$/, + ); + const refusal = "Add Sky to relay.example first"; + agents.saveTeam.mockRejectedValueOnce(new Error(refusal)); + await user.click(screen.getByRole("button", { name: "Create team" })); + const form = screen.getByRole("form", { name: "New team" }); + expect(await within(form).findByRole("alert")).toHaveTextContent(refusal); + const draft = agents.saveTeam.mock.calls.at(-1)?.[0].draft; + // Unchecking it keeps the draft, and Create leaves it off. + await user.click(screen.getByRole("checkbox", { name: "Sky" })); + expect(screen.getByRole("checkbox", { name: "Sky" })).not.toBeChecked(); + await user.click(screen.getByRole("button", { name: "Create team" })); + expect(agents.saveTeam).toHaveBeenLastCalledWith({ + draft, + name: "Crew", + instructions: "", + members: [ada.pubkey], + }); +}); + +it("offers an open new team hosted agents once its community is known to sync teams", async () => { + const user = userEvent.setup(); + const { agents, change } = setup([ + agent("Ada", 1), + { ...agent("Sky", 2), place: "hosted" }, + ]); + // A new team syncs here once the community's config channels are up. + agents.teamHere.mockImplementation( + ({ id }) => !id && !!agents.snapshot().syncsTeams, + ); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + await user.click(screen.getByRole("button", { name: "New team" })); + await user.click( + await screen.findByRole("menuitem", { name: "Create a new team" }), + ); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Crew"); + await user.click(screen.getByRole("checkbox", { name: "Ada" })); + expect( + screen.queryByRole("checkbox", { name: "Sky" }), + ).not.toBeInTheDocument(); + act(() => change({ syncsTeams: true })); + expect(screen.getByRole("checkbox", { name: "Sky" })).not.toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Ada" })).toBeChecked(); + expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Crew"); +}); + it("closes the team editor when leaving Your teams", async () => { const user = userEvent.setup(); const { agents } = setup([agent("Ada", 1)]); @@ -2525,6 +2785,72 @@ it("explains an unavailable harness without claiming the key is on another devic ).not.toBeInTheDocument(); }); +it("shows a synced team only where its members are, after switching communities", async () => { + const user = userEvent.setup(); + const sky: Agent = { ...agent("Sky", 2), place: "hosted" }; + const { agents, show } = setup([agent("Ada", 1), sky], { + teams: [ + { + id: "sky", + name: "Sky crew", + instructions: "", + members: [sky.pubkey], + revision: "1", + }, + { + id: "empty", + name: "Empty", + instructions: "", + members: [], + revision: "1", + }, + { + id: "stale", + name: "Stale", + instructions: "", + members: ["9".repeat(64)], + }, + ], + }); + const listed = () => + within(screen.getByRole("list", { name: "Teams" })) + .getAllByRole("button") + .map((card) => card.getAttribute("aria-label")); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + expect(listed()).toEqual(["Sky crew", "Empty", "Stale"]); + await user.click(screen.getByRole("button", { name: "Sky crew" })); + expect( + screen.getByRole("form", { name: "Edit Sky crew" }), + ).toBeInTheDocument(); + // Bo's community is no synced team's home. + agents.teamHere.mockReturnValue(false); + act(() => show([agent("Bo", 3)])); + expect( + screen.queryByRole("form", { name: "Edit Sky crew" }), + ).not.toBeInTheDocument(); + expect(listed()).toEqual(["Empty", "Stale"]); +}); + +it("keeps a synced team in its home while none of its members is listed", async () => { + const user = userEvent.setup(); + const { agents } = setup([agent("Ada", 1)], { + teams: [ + { + id: "gone", + name: "Gone", + instructions: "", + members: ["9".repeat(64)], + revision: "1", + }, + ], + }); + await user.click(screen.getByRole("tab", { name: "Your teams" })); + expect(agents.teamHere).toHaveBeenCalledWith({ id: "gone" }); + await user.click(screen.getByRole("button", { name: "Gone" })); + await user.click(screen.getByRole("button", { name: "Delete team" })); + expect(agents.removeTeam).toHaveBeenCalledWith("gone"); +}); + it("shows an agent on another device read-only, and offers only Finish setup and Delete for an unfinished hosted one", async () => { const user = userEvent.setup(); const far: Agent = { diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 157564811..4860d445e 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -79,6 +79,7 @@ import { PlusIcon, RobotIcon, StarFilledIcon, + WarningCircleIcon, XIcon, } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; @@ -116,6 +117,10 @@ import { agentProviderRoute, agentProviderTarget, agentRoute } from "./routes"; const message = (error: unknown) => error instanceof Error ? error.message : String(error); const noSubscribe = () => () => {}; +const names = (agents: readonly Agent[]) => + new Intl.ListFormat(undefined, { type: "conjunction" }).format( + agents.map((agent) => agent.name), + ); const importReminderKey = "agent-community-import:dismissed"; /** The type's one-line summary of an agent; its title when it has none. */ @@ -269,12 +274,15 @@ export function AgentsPage({ // Importing gives an agent a key here, so a hosted type cannot take one. const runnable = types.filter((type) => !type.remote); // Teams are shared by every community; this one shows each with a member - // here, or with none in any community. + // here, or with none in any community. A synced team always shows in its + // home, and elsewhere only with a member there or while it has none. const members = new Set(snapshot.agents.map((agent) => agent.pubkey)); const teams = snapshot.teams.filter( (team) => team.members.some((pubkey) => members.has(pubkey)) || - !team.members.some((pubkey) => agents.memberships(pubkey).length), + (team.revision + ? !team.members.length || agents.teamHere({ id: team.id }) + : !team.members.some((pubkey) => agents.memberships(pubkey).length)), ); // Only a team still saved, or one being made, keeps its editor open. const editing = @@ -545,7 +553,9 @@ export function AgentsPage({ ) : ( agent.place === "here")} + saved={snapshot.agents.filter( + (agent) => agent.place !== "elsewhere" && !agent.unfinished, + )} teams={teams} editing={editing} onEdit={setEditingTeam} @@ -2136,12 +2146,45 @@ function TeamEditor({ // What a synced Save revises, and a new team's id for every retry. const [opened] = useState(team); const [draft] = useState(() => crypto.randomUUID()); + const which = opened ? { id: opened.id } : { draft }; + // Each hosted agent picked, as listed then in the only community with it. + const [picked, setPicked] = useState>( + () => new Map(), + ); + // Only a team Save puts in this community can have its hosted agents. + const listed = agents.teamHere(which) + ? saved + : saved.filter((agent) => agent.place === "here"); + // Hosted agents picked where Save puts the team stay choices while not + // listed, as while its agent list is read again or once one is deleted, so + // each can be unchecked. Not one picked in another community, as before + // selecting this one, which can't have it. + const choices = [ + ...listed, + ...[...picked.values()].filter( + (agent) => + agents.teamHere(which, agent.relay) && + !listed.some((item) => item.pubkey === agent.pubkey), + ), + ]; + // What Save gets: members among these choices, ones the team already has, + // and agents here in other communities. + const kept = members.filter( + (pubkey) => + choices.some((agent) => agent.pubkey === pubkey) || + opened?.members.includes(pubkey) || + agents.memberships(pubkey).length > 0, + ); // Members not in this community, which it doesn't list but keeps. const away = members.filter( (pubkey) => - !saved.some((agent) => agent.pubkey === pubkey) && + !choices.some((agent) => agent.pubkey === pubkey) && agents.memberships(pubkey).length > 0, ).length; + // Their provider runs them, so they may not get the team's instructions. + const remote = choices.filter( + (agent) => agent.place === "hosted" && members.includes(agent.pubkey), + ); const act = async (work: () => Promise) => { if (pending) return; setPending(true); @@ -2160,7 +2203,7 @@ function TeamEditor({ ...(opened ? { id: opened.id, opened } : { draft }), name: name.trim(), instructions, - members, + members: kept, }), ); return ( @@ -2193,7 +2236,22 @@ function TeamEditor({ + {remote.length > 0 + ? "Agents on your devices follow these after their own instructions." + : "Every member follows these after its own instructions."} + {remote.length > 0 && ( + <> + {" "} + + + {`Agents run by their provider (${names(remote)}) will not get these team instructions.`} + + + )} + + } >