From 6b537059c2408e524d31fa4969b3f099840bd3c6 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 11 Oct 2026 14:16:56 -0700 Subject: [PATCH 1/4] feat(agents2): teams with shared instructions and a roster A team is its own local record (name, instructions, member pubkeys) in buzz.agents2.teams.v1; agents store nothing about their teams. Deleting a team leaves its agents alone, and a deleted agent leaves every team. Each run passes the agent a section per team it is on: the team's name, every member's name and npub (marking the agent itself), and the team's instructions. Claude adds it after the agent's own instructions in the system prompt, so a change resumes idle sessions with the new prompt; Codex sends it with the per-turn instructions. The Agents2 page gets a Teams side panel to create, edit and delete teams. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/agents2/Agents2Page.test.tsx | 55 +++++- src/bundled/agents2/Agents2Page.tsx | 221 ++++++++++++++++++++++- src/bundled/claude-code/prompt.test.ts | 5 + src/bundled/claude-code/prompt.ts | 3 + src/bundled/claude-code/runtime.test.ts | 50 +++++ src/bundled/claude-code/runtime.ts | 7 + src/bundled/codex/prompt.ts | 8 +- src/bundled/codex/runtime.test.ts | 21 ++- src/bundled/codex/runtime.ts | 5 +- src/features/agents2/service.test.ts | 66 +++++++ src/features/agents2/service.ts | 106 +++++++++++ src/features/agents2/store.test.ts | 21 +++ src/features/agents2/store.ts | 45 +++++ 13 files changed, 606 insertions(+), 7 deletions(-) diff --git a/src/bundled/agents2/Agents2Page.test.tsx b/src/bundled/agents2/Agents2Page.test.tsx index 9b98b3030..6d0957de2 100644 --- a/src/bundled/agents2/Agents2Page.test.tsx +++ b/src/bundled/agents2/Agents2Page.test.tsx @@ -10,6 +10,7 @@ import type { Agents2, AgentsSnapshot, RegisteredAgentType, + TeamChange, } from "../../features/agents2/service"; import type { PortableAgent } from "../../features/agents2/native"; import type { RelayData } from "../../features/relay/service"; @@ -66,7 +67,12 @@ const portable: PortableAgent[] = [ ]; function setup(agents: Agent[], { shown = true } = {}) { - let snapshot: AgentsSnapshot = { status: "ready", agents, running: agents }; + let snapshot: AgentsSnapshot = { + status: "ready", + agents, + running: agents, + teams: [], + }; const listeners = new Set<() => void>(); const type = { key: "test/echo", @@ -103,6 +109,22 @@ function setup(agents: Agent[], { shown = true } = {}) { port: vi.fn(async () => created), save: vi.fn(async () => {}), remove: vi.fn(async () => {}), + saveTeam: vi.fn(async (change: TeamChange) => { + const team = { ...change, id: change.id ?? "team-1" }; + snapshot = { + ...snapshot, + teams: [...snapshot.teams.filter((t) => t.id !== team.id), team], + }; + for (const listener of listeners) listener(); + return team; + }), + removeTeam: vi.fn(async (id: string) => { + snapshot = { + ...snapshot, + teams: snapshot.teams.filter((t) => t.id !== id), + }; + for (const listener of listeners) listener(); + }), } satisfies Agents2; const disconnected = { status: "disconnected", generation: 0 }; const relay = { @@ -268,3 +290,34 @@ it("shows why bringing an agent over failed and keeps it listed", async () => { ); expect(screen.getByRole("button", { name: "Bring over Ada" })).toBeEnabled(); }); + +it("makes a team of chosen agents and lists it with its members", async () => { + const user = userEvent.setup(); + const ada = agent("Ada", 1); + const { agents2 } = setup([ada, agent("Bo", 2), agent("Cy", 3)]); + await user.click(screen.getByRole("button", { name: "Teams" })); + expect(screen.getByText("No teams yet.")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "New team" })); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Crew"); + await user.type( + screen.getByRole("textbox", { name: "Instructions" }), + "Ada leads.", + ); + await user.click(screen.getByRole("checkbox", { name: "Ada" })); + await user.click(screen.getByRole("checkbox", { name: "Cy" })); + await user.click(screen.getByRole("button", { name: "Create team" })); + + expect(agents2.saveTeam).toHaveBeenCalledWith({ + name: "Crew", + instructions: "Ada leads.", + members: [ada.pubkey, agent("Cy", 3).pubkey], + }); + const list = screen.getByRole("list", { name: "Teams" }); + expect(within(list).getByText("Crew")).toBeInTheDocument(); + expect(within(list).getByText("Ada, Cy")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Edit Crew" })); + await user.click(screen.getByRole("button", { name: "Delete team" })); + expect(agents2.removeTeam).toHaveBeenCalledWith("team-1"); + expect(screen.getByText("No teams yet.")).toBeInTheDocument(); +}); diff --git a/src/bundled/agents2/Agents2Page.tsx b/src/bundled/agents2/Agents2Page.tsx index aff133cce..e76ce42d4 100644 --- a/src/bundled/agents2/Agents2Page.tsx +++ b/src/bundled/agents2/Agents2Page.tsx @@ -5,7 +5,7 @@ // - Build: the identity rail on the left, the type's tabs and the app's // Attention tab on the right. // New agents are made in the peek slot, so making one never covers the grid, -// and so are agents brought over from an earlier agent system. +// and so are agents brought over from an earlier agent system, and teams. import { useEffect, useRef, @@ -30,6 +30,7 @@ import type { Agent, Agents2, RegisteredAgentType, + Team, } from "../../features/agents2/service"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; @@ -41,6 +42,7 @@ import { } from "../../shared/design-system/icons"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { Button } from "../../shared/design-system/ui/Button"; +import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { EmptyState } from "../../shared/design-system/ui/EmptyState"; import { Field } from "../../shared/design-system/ui/Field"; import { FullPageSurface } from "../../shared/design-system/ui/FullPageSurface"; @@ -48,6 +50,7 @@ import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Input } from "../../shared/design-system/ui/Input"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; import { formatPublicKey, publicKeyLabels, @@ -60,7 +63,7 @@ type Frame = | { view: "browse"; selected?: string; - side?: "creating" | "porting"; + side?: "creating" | "porting" | "teams"; } | { view: "build"; pubkey: string }; @@ -100,6 +103,7 @@ export function Agents2Page({ const build = (pubkey: string) => setFrame({ view: "build", pubkey }); const newAgent = () => setFrame({ view: "browse", side: "creating" }); const bringOver = () => setFrame({ view: "browse", side: "porting" }); + const openTeams = () => setFrame({ view: "browse", side: "teams" }); const body = (() => { if (snapshot.status === "unavailable") @@ -138,6 +142,13 @@ export function Agents2Page({ /> ) : side === "porting" ? ( browse()} /> + ) : side === "teams" ? ( + browse()} + /> ) : selected ? ( Bring over + @@ -633,6 +651,205 @@ function BringOver({ ); } +/** The owner's teams: each a name, instructions and members, given to every + * member's prompt with a roster. Agents record nothing about their teams. */ +function Teams({ + agents2, + agents, + teams, + onClose, +}: { + agents2: Agents2; + agents: readonly Agent[]; + teams: readonly Team[]; + onClose(): void; +}) { + // A team's id while it is edited, or "new" for one being made. + const [editing, setEditing] = useState(); + const team = teams.find((item) => item.id === editing); + if (editing === "new" || team) + return ( + setEditing(undefined)} + /> + ); + const names = new Map(agents.map((agent) => [agent.pubkey, agent.name])); + return ( +
+ Teams} + onClose={onClose} + /> +

+ Each member is told who its teammates are, and follows the team's + instructions after its own. +

+ {teams.length ? ( +
    + {teams.map((item) => ( +
  • + + {item.name} + + {item.members + .map((pubkey) => names.get(pubkey)) + .filter(Boolean) + .join(", ") || "No members"} + + + +
  • + ))} +
+ ) : ( +

No teams yet.

+ )} + +
+ ); +} + +function TeamEditor({ + agents2, + agents, + team, + onDone, +}: { + agents2: Agents2; + agents: readonly Agent[]; + team?: Team; + onDone(): void; +}) { + const [name, setName] = useState(team?.name ?? ""); + const [instructions, setInstructions] = useState(team?.instructions ?? ""); + const [members, setMembers] = useState( + team?.members ?? [], + ); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const act = async (work: () => Promise) => { + if (pending) return; + setPending(true); + setError(""); + try { + await work(); + onDone(); + } catch (reason) { + setError(message(reason)); + setPending(false); + } + }; + const save = () => + act(() => + agents2.saveTeam({ + ...(team ? { id: team.id } : {}), + name: name.trim(), + instructions, + members, + }), + ); + return ( +
{ + event.preventDefault(); + void save(); + }} + onKeyDown={(event) => { + if (event.key === "Escape" && !pending) onDone(); + }} + > + + {team ? "Edit team" : "New team"} + + } + onClose={onDone} + /> + + setName(event.target.value)} + /> + + +