diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index e61c2c72d1..7ac23beea3 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -340,6 +340,8 @@ plugin is replaced, so ask for it when acting rather than keeping it. Like any t Plugins can discover active Attention source registrations through `ctx.agents.attentionProviders()`. Each entry includes its `provider` key and optional `description` and JSON-object `configExample` supplied to `addAttentionSource`. The catalog omits executable callbacks and inactive registrations; discovery tools belong in external plugins. +A source may also `present` its watches on the Agent Attention page. Given a watch and its display name, it returns the watch's kind and, optionally, an icon, a target with a subtitle, an external link, and a details component that replaces the name in the expanded row. When a provider has no `present`, or it throws, Buzz shows the watch by its name. The GitHub plugin presents pull request watches this way, linking to its comment author settings. + Each Attention source `run` receives `spawn(processId, options)` for commands using that watch's owning agent. The host binds agent identity, watch destination and run lifetime; plugins cannot select another agent or obtain the raw authenticated connection. Declare the process in `host.processes` and request `host.mcp`, plus `host.files` for tools reading local paths. Options supply arguments, working directory, environment and output callbacks. Buzz revokes command access on return, failure, cancellation, agent stop, provider unload or community reconnection, and closes the process and its descendants. Successfully emitted output remains queued after a producer returns. The key and MCP connection stay in Buzz; commands retain the app user's operating-system privileges and can use the agent's tools while their run remains active. diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index a81760db83..c530b1c9ce 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -372,6 +372,7 @@ function setup( for (const listener of listeners) listener(); }), attentionProviders: () => [], + presentWatch: () => undefined, addAttentionSource: vi.fn(), storage: localStore(), resumeInterrupted: vi.fn(), diff --git a/src/bundled/github/SoloPull.test.tsx b/src/bundled/github/SoloPull.test.tsx index affaf01888..2009b326e9 100644 --- a/src/bundled/github/SoloPull.test.tsx +++ b/src/bundled/github/SoloPull.test.tsx @@ -183,7 +183,10 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () key: "a".repeat(64), pubkey: "a".repeat(64), } as BuzzClient; - expect(inject).toHaveBeenCalledWith(["agents", "host"], expect.any(Function)); + expect(inject).toHaveBeenCalledWith( + ["agents", "host", "navigation"], + expect.any(Function), + ); expect(tool?.name).toBe("github_link_pr"); await expect( tool?.call(client, { channel: chat }, { url: "https://github.com/block" }), diff --git a/src/bundled/github/attention.test.tsx b/src/bundled/github/attention.test.tsx new file mode 100644 index 0000000000..f65ce02d80 --- /dev/null +++ b/src/bundled/github/attention.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { GitPullRequestIcon } from "../../shared/design-system/icons"; +import { presentPull } from "./attention"; + +afterEach(cleanup); + +const watch = (pr: unknown) => ({ + type: "source" as const, + interest_id: "release", + enabled: true, + provider: "attention/github-pr", + config: { pr }, +}); + +it("names the pull request and opens whose feedback wakes agents", async () => { + const open = vi.fn(); + const shown = presentPull({ open })( + watch("https://github.com/block/buzz-app/pull/1578"), + "buzz-app#1578 Release checks", + ); + expect(shown).toMatchObject({ + type: "GitHub PR", + icon: GitPullRequestIcon, + target: "buzz-app#1578", + subtitle: "Release checks", + link: { + href: "https://github.com/block/buzz-app/pull/1578", + title: "Open on GitHub", + }, + }); + const Details = shown.details as NonNullable; + render(
); + await userEvent.click( + screen.getByRole("button", { name: "Choose who can wake agents" }), + ); + expect(open).toHaveBeenCalledWith({ + version: 1, + kind: "settings", + section: "buzz.github/authors", + }); +}); + +it("leaves a watch without a pull request URL to its name", () => { + expect(presentPull({ open: vi.fn() })(watch(42), "Release")).toEqual({ + type: "GitHub PR", + icon: GitPullRequestIcon, + }); +}); diff --git a/src/bundled/github/attention.tsx b/src/bundled/github/attention.tsx new file mode 100644 index 0000000000..860113aa2a --- /dev/null +++ b/src/bundled/github/attention.tsx @@ -0,0 +1,42 @@ +// How a pull request watch looks in Agent Attention. +import { GitPullRequestIcon } from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; +import type { AttentionSource } from "../../features/agents/attention-sources"; +import type { Navigation } from "../../features/navigation/controller"; +import { pullOf } from "./watch"; + +/** Names the pull request a watch follows, and opens the settings for whose + * feedback wakes agents. */ +export function presentPull( + navigation: Pick, +): NonNullable { + const Authors = () => ( + + ); + return (watch, title) => { + const pull = + typeof watch.config.pr === "string" ? pullOf(watch.config.pr) : undefined; + if (!pull) return { type: "GitHub PR", icon: GitPullRequestIcon }; + return { + type: "GitHub PR", + icon: GitPullRequestIcon, + target: `${pull.name}#${pull.number}`, + subtitle: title.replace(/^(?:PR\s+\d+:?\s*|[^ ]*#\d+\s*)/i, ""), + link: { href: pull.url, title: "Open on GitHub" }, + details: Authors, + }; + }; +} diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index fef6d12efe..009cde0ac9 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -43,6 +43,7 @@ import { stateIcons } from "./pull-state-view"; import { PullLink } from "./PullLink"; import { SoloPullIcon, SoloPullLink, SoloPullMenu } from "./SoloPull"; import { watchPulls } from "./watcher"; +import { presentPull } from "./attention"; import { SubmitReview } from "./SubmitReview"; import { AuthorSettings } from "./AuthorSettings"; @@ -184,7 +185,9 @@ export const apply: PluginModule["apply"] = (ctx) => { }); addPullStateTool(ctx.agents, states); // Agents can watch pull requests where the app runs them. - ctx.inject(["agents", "host"], (inner) => watchPulls(inner, states, gh)); + ctx.inject(["agents", "host", "navigation"], (scope) => + watchPulls(scope, states, gh, presentPull(scope.navigation)), + ); // Whose comments and reviews wake them. ctx.inject(["settingsCards"], (scope) => { scope.settingsCards.register({ diff --git a/src/bundled/github/watcher.test.ts b/src/bundled/github/watcher.test.ts index cdae4e96e2..9fe467323a 100644 --- a/src/bundled/github/watcher.test.ts +++ b/src/bundled/github/watcher.test.ts @@ -144,7 +144,10 @@ const asks = (app: ReturnType, login: string) => ([query]) => membershipRead(query) && query.includes(`login:"${login}"`), ).length; -function setup(initial: Pull | null = answer()) { +function setup( + initial: Pull | null = answer(), + present?: AttentionSource["present"], +) { let gh = initial; let texts = true; let members: readonly string[] | null = []; @@ -286,7 +289,7 @@ function setup(initial: Pull | null = answer()) { }, effect: (fn: () => () => void) => disposers.push(fn()), } as unknown as Context; - watchPulls(ctx, states, github as unknown as GitHubCli); + watchPulls(ctx, states, github as unknown as GitHubCli, present); const tool = tools[0] as AddedTool; const call = ( args: Record, @@ -355,10 +358,15 @@ describe("github_watch_pr", () => { }); afterEach(() => vi.useRealTimers()); - it("registers PR watching as an Attention source", () => { - const app = setup(); + it("registers PR watching as an Attention source with its presenter", () => { + const present = vi.fn(); + const app = setup(answer(), present); expect(app.addAttentionSource).toHaveBeenCalledWith( - expect.objectContaining({ id: "github-pr", run: expect.any(Function) }), + expect.objectContaining({ + id: "github-pr", + run: expect.any(Function), + present, + }), ); app.dispose(); }); diff --git a/src/bundled/github/watcher.ts b/src/bundled/github/watcher.ts index b49b28d117..7ce30462ef 100644 --- a/src/bundled/github/watcher.ts +++ b/src/bundled/github/watcher.ts @@ -3,6 +3,7 @@ // and delivery. import type { Context } from "@deepseek-ai/cordis"; import { callAttentionTool } from "../../buzz-mcp/attention"; +import type { AttentionSource } from "../../features/agents/attention-sources"; import type { GitHubCli } from "./gh"; import type { Feedback, PullStates } from "./pull-states"; import { @@ -91,6 +92,7 @@ export function watchPulls( ctx: Context, states: PullStates, gh: GitHubCli | undefined, + present?: AttentionSource["present"], ) { localStorage.removeItem(RETIRED_STORAGE_KEY); const cursors = load(); @@ -227,6 +229,7 @@ export function watchPulls( id: "github-pr", description: "Follow GitHub pull request feedback and CI", configExample: { pr: "https://github.com/OWNER/REPO/pull/NUMBER" }, + ...(present && { present }), async run({ id, agent, watch: source, signal, emit }) { const pull = typeof source.config.pr === "string" diff --git a/src/bundled/watching/Tab.test.tsx b/src/bundled/watching/Tab.test.tsx index 4721c05153..51f948c7dc 100644 --- a/src/bundled/watching/Tab.test.tsx +++ b/src/bundled/watching/Tab.test.tsx @@ -112,6 +112,7 @@ it("shows one agent's watches, reacts to changes and surfaces save errors", asyn const ctx = { react: React, agents: { + presentWatch: () => undefined, snapshot: () => snapshot, subscribe: (listener: () => void) => { changed = listener; @@ -271,6 +272,7 @@ it.each(["source", "event", "timer"] as const)( const ctx = { react: React, agents: { + presentWatch: () => undefined, snapshot: () => snapshot, subscribe: (listener: () => void) => { changed = listener; @@ -399,7 +401,12 @@ it("keeps subscription controls disabled and the tab open while saving, and pres const save = vi.fn(() => held); const ctx = { react: React, - agents: { snapshot: () => snapshot, subscribe: () => () => {}, save }, + agents: { + presentWatch: () => undefined, + snapshot: () => snapshot, + subscribe: () => () => {}, + save, + }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; const report = vi.fn(); @@ -737,6 +744,19 @@ it.each([false, true])( const ctx = { react: React, agents: { + presentWatch: (watch: { provider: string }) => + watch.provider === "attention/github-pr" + ? { + type: "GitHub PR", + target: "buzz-app#1578", + subtitle: "Release checks", + link: { + href: "https://github.com/block/buzz-app/pull/1578", + title: "Open on GitHub", + }, + details: () => React.createElement("p", null, "Who wakes it"), + } + : undefined, snapshot: () => snapshot, subscribe: (listener: () => void) => { changed = listener; @@ -778,6 +798,7 @@ it.each([false, true])( ); expect(screen.getByRole("heading", { name: "Recent events" })).toBeTruthy(); expect(screen.getByText("CI completed: all checks passed")).toBeTruthy(); + expect(screen.getByText("Who wakes it")).toBeTruthy(); fireEvent.click( screen.getByRole("button", { name: "Details for Discussion" }), ); @@ -861,7 +882,12 @@ it("keeps overview and destination navigation in the selected community", async const open = vi.fn(), save = vi.fn(); const ctx = { - agents: { snapshot: () => snapshot, subscribe: () => () => {}, save }, + agents: { + presentWatch: () => undefined, + snapshot: () => snapshot, + subscribe: () => () => {}, + save, + }, relay: { snapshot: () => connection, subscribe: (listener: () => void) => { diff --git a/src/bundled/watching/Tab.tsx b/src/bundled/watching/Tab.tsx index b55ef57e5b..257d90bfa0 100644 --- a/src/bundled/watching/Tab.tsx +++ b/src/bundled/watching/Tab.tsx @@ -8,7 +8,6 @@ import { LockIcon, HashIcon, ClockIcon, - GitPullRequestIcon, TerminalWindowIcon, CodeIcon, ArrowSquareOutIcon, @@ -273,36 +272,28 @@ export function createTab({ h("h4", { className: "text-label-sm text-subtle" }, label), content, ); + const shown = + watch.type === "source" + ? agents.presentWatch(watch, label.text) + : undefined; const type = watch.type === "event" ? "Messages" : watch.type === "timer" ? "Schedule" - : watch.provider === "attention/github-pr" - ? "GitHub PR" - : watch.provider === "attention/command" - ? "Command" - : "Function"; + : watch.provider === "attention/command" + ? "Command" + : (shown?.type ?? "Function"); const TypeIcon = - type === "Messages" + watch.type === "event" ? ChatCircleIcon - : type === "Schedule" + : watch.type === "timer" ? ClockIcon - : type === "GitHub PR" - ? GitPullRequestIcon - : type === "Command" - ? TerminalWindowIcon - : CodeIcon; - const pr = - watch.type === "source" && - watch.provider === "attention/github-pr" && - typeof watch.config.pr === "string" - ? /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)$/.exec( - watch.config.pr, - ) - : null; - const target = pr - ? `${pr[1]?.split("/")[1]}#${pr[2]}` + : watch.provider === "attention/command" + ? TerminalWindowIcon + : (shown?.icon ?? CodeIcon); + const target = shown?.target + ? shown.target : watch.type === "event" ? watch.channels === "all" ? "All joined channels" @@ -402,11 +393,11 @@ export function createTab({ ) : target, ), - pr && + shown?.subtitle && h( "span", { className: "attention-subtitle text-body-sm text-subtle" }, - label.text.replace(/^(?:PR\s+\d+:?\s*|[^ ]*#\d+\s*)/i, ""), + shown.subtitle, ), ), h( @@ -427,7 +418,7 @@ export function createTab({ h( "div", { className: "attention-actions" }, - pr && + shown?.link && h(IconButton, { size: "sm", variant: "ghost", @@ -436,18 +427,14 @@ export function createTab({ render: (props) => ( ), icon: h(ArrowSquareOutIcon, { size: 16, "aria-hidden": true }), "aria-label": `Open ${target}`, - title: "Open on GitHub", + title: shown.link.title, }), h(Switch, { label: `Watch ${label.text}`, @@ -512,8 +499,9 @@ export function createTab({ ), ), ) - : !pr && - h( + : shown?.details + ? h(shown.details) + : h( "p", { className: "text-body-sm text-secondary" }, watch.type === "source" @@ -522,7 +510,7 @@ export function createTab({ ? `New messages in ${watch.channels === "all" ? "all joined channels" : watch.channels.map(name).join(", ")}${watch.classifier ? ", filtered for relevance" : ""}.` : `${description.title}. ${watch.prompt}`, ), - !pr && + !shown?.details && label.url && h( "a", diff --git a/src/features/agents/attention-sources.ts b/src/features/agents/attention-sources.ts index 00955816cb..e65854f8f0 100644 --- a/src/features/agents/attention-sources.ts +++ b/src/features/agents/attention-sources.ts @@ -1,14 +1,31 @@ // Lifecycle for plugin producers. The host owns registration, cancellation and delivery; // plugins own execution. A resolved run is terminal until its watch is edited. +import type { ComponentType } from "react"; +import type { DefinedIcon } from "../../shared/design-system/icons"; import type { HostProcess, HostProcessOptions } from "../host/service"; import type { Agent } from "./service"; import type { AttentionObject, SourceWatch } from "./attention"; +/** How Agent Attention shows a provider's watch, in place of its name. */ +export type SourcePresentation = Readonly<{ + /** The kind of watch, such as "GitHub PR". */ + type: string; + icon?: DefinedIcon; + /** What it watches, shown beside its kind, with `subtitle` after it. */ + target?: string; + subtitle?: string; + /** Opens what it watches. */ + link?: Readonly<{ href: string; title: string }>; + /** Replaces the name in its details. */ + details?: ComponentType; +}>; export type AttentionSource = Readonly<{ id: string; /** Discovery metadata for tools that configure this provider. */ description?: string; configExample?: Readonly>; + /** `title` is the watch's display name. */ + present?(watch: SourceWatch, title: string): SourcePresentation; run( input: Readonly<{ /** Stable Attention object slug, unique within the agent. */ diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 96cf48c53a..f428b73c1b 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11175,6 +11175,7 @@ it("rejects invalid source discovery metadata", async () => { { configExample: [] }, { configExample: { value: 1n } }, { configExample: circular }, + { present: "GitHub PR" }, ]) expect(() => plugin.agents.addAttentionSource({ @@ -11770,6 +11771,41 @@ it("registers Buzz-owned command sources with saved provider keys", async () => ); }); +it("presents a watch through its provider, and plainly when that fails", async () => { + const { service, plugin } = await setup(); + const watch = { + type: "source" as const, + interest_id: "release", + enabled: true, + provider: "attention/github-pr", + config: {}, + }; + const present = vi.fn((_watch: unknown, title: string) => ({ + type: `GitHub PR ${title}`, + })); + plugin + .extend({ pluginOwner: { id: "buzz.github", revision: "bundled" } }) + .agents.addAttentionSource({ + id: "github-pr", + present, + run: async () => {}, + }); + expect(service.presentWatch(watch, "Release")).toEqual({ + type: "GitHub PR Release", + }); + expect(present).toHaveBeenCalledWith(watch, "Release"); + expect( + service.presentWatch({ ...watch, provider: "example/other" }, "Release"), + ).toBeUndefined(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + present.mockImplementation(() => { + throw new Error("broken"); + }); + expect(service.presentWatch(watch, "Release")).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); +}); + it.each(["command"])( "starts a saved attention/%s watch after built-in registration", async (id) => { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 875f6579a2..5a28e4eb2b 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -996,6 +996,11 @@ export type Agents = { configExample?: Readonly>; builtIn?: boolean; }>[]; + /** How the watch's provider shows it, when the provider presents its watches. */ + presentWatch( + watch: import("./attention").SourceWatch, + title: string, + ): import("./attention-sources").SourcePresentation | undefined; register(type: AgentType): void; types(): readonly RegisteredAgentType[]; /** Adds a tab to the settings of every agent running on this device, @@ -1999,6 +2004,7 @@ export class AgentsService extends Service implements Agents { !plugin || !/^[a-z0-9][a-z0-9._-]*$/.test(source.id) || typeof source.run !== "function" || + (source.present !== undefined && typeof source.present !== "function") || (source.description !== undefined && (typeof source.description !== "string" || !source.description.trim())) || @@ -2176,6 +2182,21 @@ export class AgentsService extends Service implements Agents { : { configExample: JSON.parse(JSON.stringify(configExample)) }), }; }); + presentWatch = (watch: import("./attention").SourceWatch, title: string) => { + const source = this.attentionSources + .snapshot() + .find((source) => this.attentionProvider(source) === watch.provider); + try { + return source?.present?.(watch, title); + } catch (error) { + // One plugin's mistake leaves its watches plain, not the page broken. + console.warn( + `Attention source ${watch.provider} failed to present`, + error, + ); + return undefined; + } + }; private history( key: AgentKey, object: AttentionObject, diff --git a/tests/browser/agent-attention.spec.mjs b/tests/browser/agent-attention.spec.mjs index 0086818923..fcf1728bca 100644 --- a/tests/browser/agent-attention.spec.mjs +++ b/tests/browser/agent-attention.spec.mjs @@ -57,6 +57,14 @@ test("The Watching tab fits both themes and sizes and manages existing watches b await expect( page.getByRole("link", { name: "Open project#42" }), ).toHaveAttribute("href", "https://github.com/example/project/pull/42"); + await page + .getByRole("button", { name: "Choose who can wake agents" }) + .click(); + expect(await page.evaluate(() => window.attentionFixture.opened)).toEqual({ + version: 1, + kind: "settings", + section: "buzz.github/authors", + }); }); // Browser focus after removal, including async writes, cannot be proved by jsdom. diff --git a/tests/fixtures/agent-attention.tsx b/tests/fixtures/agent-attention.tsx index 181c352d71..10a71da733 100644 --- a/tests/fixtures/agent-attention.tsx +++ b/tests/fixtures/agent-attention.tsx @@ -1,6 +1,7 @@ // The production tab and shared controls, with only the host data boundary replaced. import { createRoot } from "react-dom/client"; import type { Context } from "../../src/plugins/author"; +import { presentPull } from "../../src/bundled/github/attention"; import { createTab } from "../../src/bundled/watching/Tab"; import "../../src/shared/styles/globals.css"; @@ -88,6 +89,7 @@ const connection = { let saveGate: Promise | undefined; const controls = { saveStarted: false, + opened: undefined as unknown, releaseSave: () => {}, holdSave() { controls.saveStarted = false; @@ -102,8 +104,21 @@ declare global { } } window.attentionFixture = controls; +const navigation = { + open: async (target: unknown) => { + controls.opened = target; + }, +}; +const presentGitHub = presentPull(navigation); const Tab = createTab({ agents: { + presentWatch: ( + watch: Parameters[0], + title: string, + ) => + watch.provider === "attention/github-pr" + ? presentGitHub(watch, title) + : undefined, snapshot: () => snapshot, subscribe: (listener: () => void) => { listeners.add(listener); @@ -148,7 +163,7 @@ const Tab = createTab({ }, }, relay: { snapshot: () => connection, subscribe: () => () => {} }, - navigation: { open: async () => {} }, + navigation, } as unknown as Context); const root = document.getElementById("root"); if (!root) throw new Error("Missing fixture root");