From 193154ad2766b26146099291ab8d2df6088b95b7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 10:23:51 -0400 Subject: [PATCH 1/2] refactor(plugins): give plugins an agent entry A plugin can now export `agent = { inject, apply }` for what agents need while they run (agent type runtimes, tools, wakes, Attention sources), separate from its UI `apply`. Both parts start in the same page, under the same plugin identity, each with its own dependencies; the plugin is active once both are and fails if either does. GitHub's watch_pr moves into its agent entry. No behavior changes. This is the first step toward running agents in their own webview, so a hidden or suspended UI page cannot stall them. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/plugin-architecture.md | 7 +++++ src/bundled/github/index.tsx | 7 +++-- src/plugins/api.ts | 7 +++++ src/plugins/modules.ts | 9 ++++-- src/plugins/runtime.test.mjs | 53 ++++++++++++++++++++++++++++++++++++ src/plugins/runtime.ts | 52 +++++++++++++++++++++-------------- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 2b34165515..82dc5c04e5 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -238,6 +238,13 @@ active registered types appear there; plugin activation remains owned by Settings → Plugins → Manage plugins. Provider defaults are versioned device preferences, not agent-record migrations; blank per-agent values inherit them. +Code that agents need while they run, such as an agent type's runtime, tools, +wakes and Attention sources, belongs in the plugin's optional `agent` export: +`export const agent = { inject, apply(ctx) }`, with its own `inject`. `apply` +stays the plugin's UI. Both parts now start in the same page, under the same +plugin identity; the plugin is active once both are, and fails if either does. +This separation lets agents later run apart from the UI page. + A plugin, bundled or installed, can add a tool to every agent session in the app with `ctx.agents.addTool({ name, description, inputSchema, call })`. `call` gets the agent's Buzz client and where it is working, so the tool acts as the diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index 8077fac3cd..9b3d01968a 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -155,8 +155,11 @@ export const apply: PluginModule["apply"] = (ctx) => { : "Unlinked this chat's pull request."; }, }); - // Agents can watch pull requests where the app runs them. - ctx.inject(["agents", "host"], watchPulls); +}; +// Agents can watch pull requests where the app runs them. +export const agent: NonNullable = { + inject: ["agents", "host"], + apply: watchPulls, }; const labels = { repository: "Repository", diff --git a/src/plugins/api.ts b/src/plugins/api.ts index c5df871048..0f2a248a1f 100644 --- a/src/plugins/api.ts +++ b/src/plugins/api.ts @@ -50,6 +50,13 @@ export type PluginModule = { // Cordis starts apply only when these services are available. inject?: Inject; apply: (ctx: Context) => void | Promise; + /** What agents need while they run: agent types' runtimes, tools, wakes and + * Attention sources. It starts with `apply`, under the same plugin identity, + * with its own dependencies, so it can later run apart from the UI page. */ + agent?: Readonly<{ + inject?: Inject; + apply: (ctx: Context) => void | Promise; + }>; }; // Installation identity follows child Cordis scopes so services can own contributions. diff --git a/src/plugins/modules.ts b/src/plugins/modules.ts index 2f7a63eb13..b3363e8ad1 100644 --- a/src/plugins/modules.ts +++ b/src/plugins/modules.ts @@ -30,9 +30,12 @@ export function createModuleLoader( } if (!module || typeof module !== "object") throw new Error("Invalid plugin module"); - if ("apply" in module && typeof module.apply === "function") - return module as PluginModule; - throw new Error("Plugin must export apply(ctx)"); + if (!("apply" in module) || typeof module.apply !== "function") + throw new Error("Plugin must export apply(ctx)"); + const { agent } = module as { agent?: { apply?: unknown } | null }; + if (agent !== undefined && typeof agent?.apply !== "function") + throw new Error("Plugin agent must have apply(ctx)"); + return module as PluginModule; })(); modules.set(key, promise); void promise.catch(() => { diff --git a/src/plugins/runtime.test.mjs b/src/plugins/runtime.test.mjs index cea79e06e9..1a86b7e432 100644 --- a/src/plugins/runtime.test.mjs +++ b/src/plugins/runtime.test.mjs @@ -104,6 +104,59 @@ test("pages wait for dependencies and completion of async activation", async () } }); +test("an agent entry has its own dependencies and the plugin waits for both parts", async () => { + const stopped = []; + const h = harness(async () => ({ + apply(ctx) { + ctx.pages.register({ id: "main", title: "Main", component: Page }); + ctx.effect(() => () => stopped.push("ui")); + }, + agent: { + inject: ["connection"], + apply(ctx) { + assert.equal(ctx.pluginOwner.id, "example.page"); + ctx.effect(() => () => stopped.push("agent")); + }, + }, + })); + try { + h.runtime.reconcile(desired([plugin()])); + await settle(); + assert.equal(h.states["example.page"].status, "starting"); + assert.deepEqual(h.pages.snapshot(), []); + h.root.provide("connection", {}); + await h.wait((s) => s["example.page"]?.status === "active"); + assert.equal(h.pages.snapshot().length, 1); + h.runtime.reconcile([]); + await settle(); + assert.deepEqual(stopped.sort(), ["agent", "ui"]); + } finally { + await h.dispose(); + } +}); + +test("a failed agent entry fails its plugin and stops its other part", async () => { + const h = harness(async () => ({ + apply(ctx) { + ctx.pages.register({ id: "main", title: "Main", component: Page }); + }, + agent: { + apply() { + throw new Error("Agent part failed"); + }, + }, + })); + try { + h.runtime.reconcile(desired([plugin()])); + await h.wait((s) => s["example.page"]?.status === "failed"); + assert.match(h.states["example.page"].error, /Agent part failed/); + await settle(); + assert.deepEqual(h.pages.snapshot(), []); + } finally { + await h.dispose(); + } +}); + test("missing dependencies time out and cannot activate late", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); let starts = 0; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index fb35dfe9ae..304bb5a301 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -14,7 +14,8 @@ type Entry = { plugin: PluginInfo; cancelled: boolean; failed: boolean; - fiber?: Fiber; + /** The plugin's own fiber, then its agent entry's, when it has one. */ + fibers: Fiber[]; unwatch?: () => void; stopped?: Promise; predecessor: Promise; @@ -98,6 +99,7 @@ export class PluginRuntime { plugin, cancelled: false, failed: false, + fibers: [], predecessor: previous ? this.stop(previous) : Promise.resolve(), }; this.entries.set(id, entry); @@ -150,32 +152,40 @@ export class PluginRuntime { revision: entry.plugin.revision, }), }); - const fiber = scope.plugin({ - name: entry.plugin.manifest.id, - inject: module.inject ?? [], - apply: (ctx: Context) => module.apply(ctx), - }); - entry.fiber = fiber; - this.watch(entry, fiber); + const id = entry.plugin.manifest.id; + // Both parts run in this page for now; the plugin is active once both are. + const parts = [ + { name: id, inject: module.inject, apply: module.apply }, + ...(module.agent ? [{ name: `${id}/agent`, ...module.agent }] : []), + ]; + entry.fibers = parts.map(({ name, inject, apply }) => + scope.plugin({ + name, + inject: inject ?? [], + apply: (ctx: Context) => apply(ctx), + }), + ); + this.watch(entry, entry.fibers); } catch (reason) { this.fail(entry, reason); } } - private watch(entry: Entry, fiber: Fiber) { + private watch(entry: Entry, fibers: readonly Fiber[]) { let timer: ReturnType | undefined; const refresh = () => { if (entry.cancelled || entry.failed) return; - if (fiber.state === cordisState.active) { - clearTimeout(timer); - timer = undefined; - this.update(entry, "active", null); - } else if (fiber.state === cordisState.failed) { + const failed = fibers.find((fiber) => fiber.state === cordisState.failed); + if (failed) { // await() retrieves the startup error, but does not wait for missing // dependencies. Only the ACTIVE state means contributions are ready. - void fiber.await().catch((reason) => this.fail(entry, reason)); - } else if (fiber.state === cordisState.disposed) { + void failed.await().catch((reason) => this.fail(entry, reason)); + } else if (fibers.some((fiber) => fiber.state === cordisState.disposed)) { this.fail(entry, new Error("Plugin was disposed")); + } else if (fibers.every((fiber) => fiber.state === cordisState.active)) { + clearTimeout(timer); + timer = undefined; + this.update(entry, "active", null); } else { this.update(entry, "starting", null); timer ??= setTimeout( @@ -186,7 +196,7 @@ export class PluginRuntime { }; const unsubscribe = this.root.on("internal/status", (changed) => { // ctx.plugin() returns a wrapper; events carry the underlying fiber. - if (changed.ctx === fiber.ctx) refresh(); + if (fibers.some((fiber) => changed.ctx === fiber.ctx)) refresh(); }); entry.unwatch = () => { clearTimeout(timer); @@ -202,8 +212,10 @@ export class PluginRuntime { this.update(entry, "failed", String(reason)); // Failure stays visible until an explicit retry. Retain the cleanup barrier // so replacement also waits for disposal started by a timeout or error. - if (entry.fiber) { - entry.stopped ??= entry.fiber.dispose(); + if (entry.fibers.length) { + entry.stopped ??= Promise.all( + entry.fibers.map((fiber) => fiber.dispose()), + ).then(() => {}); void entry.stopped.catch(() => {}); } } @@ -213,7 +225,7 @@ export class PluginRuntime { entry.unwatch?.(); entry.stopped ??= Promise.all([ entry.predecessor, - entry.fiber?.dispose(), + ...entry.fibers.map((fiber) => fiber.dispose()), ]).then(() => {}); return entry.stopped; } From 2838db5e05a3792e7a8d36da10274ea5ff0a38b6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 10:33:59 -0400 Subject: [PATCH 2/2] refactor(agents): split the Claude harness into runtime and UI An agent type can now come in two halves. The plugin's agent entry registers the runtime half with `agents.register`, and may pass `expose`: the handles its own UI reaches of the runtime (stores and async calls). The UI entry supplies icon, Create, tabs and provider dialogs with `agents.present(typeId, (exposed) => ...)`. The service merges both by type key, so readers of `types()` see one type, as before. Provider getters stay live, and a throwing `present` leaves the type's agents running. Claude moves its setup, runtime, process sync and update timer into its agent entry. Its tabs take narrow handles in place of the runtime classes. No behavior changes. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/plugin-architecture.md | 8 ++ src/bundled/agents/AgentsPage.test.tsx | 1 + src/bundled/agents/ProviderDialog.tsx | 2 +- src/bundled/claude-code/index.test.ts | 16 ++-- src/bundled/claude-code/index.ts | 92 ++++++++++++-------- src/bundled/claude-code/tabs.test.tsx | 17 ++-- src/bundled/claude-code/tabs.tsx | 28 ++++-- src/features/agents/plugin-testing.ts | 24 ++++++ src/features/agents/service.test.ts | 63 ++++++++++++++ src/features/agents/service.ts | 115 +++++++++++++++++++++++-- 10 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 src/features/agents/plugin-testing.ts diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 82dc5c04e5..c7d2b32e89 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -245,6 +245,14 @@ stays the plugin's UI. Both parts now start in the same page, under the same plugin identity; the plugin is active once both are, and fails if either does. This separation lets agents later run apart from the UI page. +A harness plugin registers its type from its `agent` entry and presents it +from `apply`: `ctx.agents.present(typeId, (exposed) => ({ icon, Peek, Create, +CreateModel, tabs, provider: { DialogBody, DefaultControl } }))`. The type's +readers see one type with both halves. `exposed` is the type's `expose`: the +handles its UI reaches of the runtime. Each is JSON, a store (`snapshot` and +`subscribe`) or a call returning a promise, so it can later cross to another +page. Claude is the first harness split this way. + A plugin, bundled or installed, can add a tool to every agent session in the app with `ctx.agents.addTool({ name, description, inputSchema, call })`. `call` gets the agent's Buzz client and where it is working, so the tool acts as the diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 8d1da7866f..f4473de98c 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -281,6 +281,7 @@ function setup( session: vi.fn(async () => {}), register: vi.fn(), addTool: vi.fn(), + present: vi.fn(), tools: () => [], create: vi.fn(async () => { // `shown: false`: it was made for a community no longer selected. diff --git a/src/bundled/agents/ProviderDialog.tsx b/src/bundled/agents/ProviderDialog.tsx index 96761eb77c..f480357e24 100644 --- a/src/bundled/agents/ProviderDialog.tsx +++ b/src/bundled/agents/ProviderDialog.tsx @@ -82,7 +82,7 @@ function ManagedProviderDialog({ finalFocus={() => finalFocus.current} > - + {Body && } ); diff --git a/src/bundled/claude-code/index.test.ts b/src/bundled/claude-code/index.test.ts index f11b432574..94f6200eb2 100644 --- a/src/bundled/claude-code/index.test.ts +++ b/src/bundled/claude-code/index.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { afterEach, expect, it, vi } from "vitest"; -import type { AgentType } from "../../features/agents/service"; import { ClaudeRuntime } from "./runtime"; -import { apply } from "./index"; +import * as claude from "./index"; +import { appliedType } from "../../features/agents/plugin-testing"; vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => true })); afterEach(() => vi.restoreAllMocks()); @@ -26,18 +26,12 @@ it("observes saved sessions with the live status of running ones", () => { { key: "room/r", busy: true }, { key: "dm", busy: false }, ]); - let type: AgentType | undefined; - apply({ + const type = appliedType(claude, { pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { request: vi.fn() }, - agents: { - register: (registered: AgentType) => { - type = registered; - }, - relay: () => undefined, - }, + agents: { relay: () => undefined }, effect: vi.fn(), - } as never); + }); expect(type?.sessions?.list(agent)).toEqual([ { channelId: "room", root: "r", at: 3, status: "Working" }, { channelId: "dm", at: 2, status: "Idle" }, diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 83c6dd3709..00ec7e50a0 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -18,11 +18,56 @@ export { TYPE_ID }; /** How often Buzz's copy of Claude looks for a new release. */ const UPDATE_MS = 6 * 60 * 60_000; -export const inject = ["agents", "host"]; +// Only the desktop app can start `claude`, and setup and file uploads run +// `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. +const supported = () => isTauri() && /Mac|Linux/i.test(navigator.platform); + +/** What the type's UI reaches of its runtime. */ +export type ClaudeExposed = Parameters[0]; + +export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { - // Only the desktop app can start `claude`, and setup and file uploads run - // `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. - if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; + if (!supported()) return; + ctx.agents.present(TYPE_ID, (exposed) => { + const { + Create, + CreateModel, + DefaultControl, + ProviderDialogBody, + SettingsTab, + ActivityTab, + } = createTabs(exposed); + return { + icon: ClaudeLogoIcon, + Create, + CreateModel, + provider: { DialogBody: ProviderDialogBody, DefaultControl }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + splitSettings: true, + component: SettingsTab, + }, + { + id: "activity", + title: "Activity", + role: "activity", + component: ActivityTab, + }, + ], + }; + }); +}; + +// The runtime, which runs where the agents do. +export const agent: NonNullable = { + inject: ["agents", "host"], + apply: (ctx) => applyAgent(ctx), +}; +function applyAgent(ctx: Parameters[0]) { + if (!supported()) return; const { agents, host } = ctx; const typeKey = `${ctx.pluginOwner?.id ?? "buzz.claude-code"}/${TYPE_ID}`; const spawn = host.spawn && withScript(host.spawn.bind(host)); @@ -72,27 +117,17 @@ export const apply: PluginModule["apply"] = (ctx) => { }); } - const { - Create, - CreateModel, - DefaultControl, - ProviderDialogBody, - SettingsTab, - ActivityTab, - } = createTabs({ - setup, - defaults: providerDefaults, - runtime, - }); agents.register({ id: TYPE_ID, title: "Claude", - icon: ClaudeLogoIcon, description: "Claude on this computer. Each thread it is mentioned in is one continuing session.", defaults: () => ({ config: DEFAULT_CONFIG }), - Create, - CreateModel, + expose: { + setup, + defaults: providerDefaults, + runtime, + } satisfies ClaudeExposed, adopt: (carry, saved) => ({ ...saved, instructions: carry.instructions ?? saved.instructions, @@ -145,8 +180,6 @@ export const apply: PluginModule["apply"] = (ctx) => { ? () => void setup.run("login") : undefined; }, - DialogBody: ProviderDialogBody, - DefaultControl, }, sessions: { list: (key) => { @@ -169,22 +202,7 @@ export const apply: PluginModule["apply"] = (ctx) => { }, subscribe: (listener) => runtime.subscribe(listener), }, - tabs: [ - { - id: "settings", - title: "Settings", - role: "settings", - splitSettings: true, - component: SettingsTab, - }, - { - id: "activity", - title: "Activity", - role: "activity", - component: ActivityTab, - }, - ], // A mention during the first download waits for it, within its deadline. ...claudeRun(runtime, (signal) => setup.installed(signal)), }); -}; +} diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 4a5ac3469a..7b33b20e9f 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -25,7 +25,8 @@ import { } from "../../features/agents/state"; import type { HostProcessOptions } from "../../features/host/service"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; -import { apply, inject } from "./index"; +import * as claude from "./index"; +import { appliedType } from "../../features/agents/plugin-testing"; import { type Config, DEFAULT_CONFIG } from "./runtime"; import { ClaudeSetup } from "./setup"; import { ClaudeDefaults } from "./defaults"; @@ -126,7 +127,6 @@ function applied({ /** Whether Claude lists its models when asked. */ catalog?: { listed: boolean; ready?: Promise }; } = {}) { - let type: AgentType | undefined; const spawn = vi.fn(async (id: string, options: HostProcessOptions = {}) => { // Claude and its install steps run as steps of claude.sh: its script, $0 // and step come first. @@ -246,16 +246,13 @@ function applied({ }, agents: { storage, - register: (registered: AgentType) => { - type = registered; - }, snapshot: () => ({ status: "ready", agents: [], running: [] }), relay: () => undefined, subscribe: () => () => undefined, }, effect: vi.fn(), }; - apply(ctx as never); + const type = appliedType(claude, ctx); return { type, spawn }; } function installed(options?: Parameters[0]) { @@ -269,8 +266,9 @@ function tab(type: AgentType, index: number) { return component; } function providerSetup(type: AgentType) { - if (!type.provider) throw new Error("no provider contribution"); - return type.provider.DialogBody; + const body = type.provider?.DialogBody; + if (!body) throw new Error("no provider contribution"); + return body; } async function choose(label: string, option: string) { const user = userEvent.setup(); @@ -405,7 +403,8 @@ it("finds the thread's Claude session another build started", async () => { }); it("is an agent type on the desktop app", () => { - expect(inject).toEqual(["agents", "host"]); + expect(claude.inject).toEqual(["agents"]); + expect(claude.agent.inject).toEqual(["agents", "host"]); const { type } = installed(); expect(type).toMatchObject({ id: "claude-code", diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index a727f97dc1..372d72abd4 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -50,6 +50,24 @@ import { import type { ClaudeModel } from "./claude"; import type { ClaudeSetup } from "./setup"; import type { ClaudeDefaults } from "./defaults"; + +// What the tabs reach of the runtime: stores and calls returning promises, +// as an agent type's `expose` allows. +type SetupHandle = Pick< + ClaudeSetup, + | "snapshot" + | "subscribe" + | "check" + | "loadModels" + | "run" + | "answer" + | "cancel" +>; +type DefaultsHandle = Pick; +type ActivityHandle = Pick< + ClaudeRuntime, + "subscribe" | "sessions" | "conversations" | "find" | "transcript" +>; import { TURN_LIMIT } from "./transcript"; const message = (error: unknown) => @@ -139,7 +157,7 @@ function ClaudeSignIn({ setup, disabled, }: { - setup: ClaudeSetup; + setup: SetupHandle; disabled: boolean; }) { const [open, setOpen] = useState(false); @@ -180,7 +198,7 @@ function ClaudeSetupPanel({ disabled = false, variant = "full", }: { - setup: ClaudeSetup; + setup: SetupHandle; disabled?: boolean; variant?: "full" | "compact"; }) { @@ -324,9 +342,9 @@ export function createTabs({ defaults, runtime, }: Readonly<{ - setup: ClaudeSetup; - defaults: ClaudeDefaults; - runtime: ClaudeRuntime; + setup: SetupHandle; + defaults: DefaultsHandle; + runtime: ActivityHandle; }>) { function useSetupCatalog() { const state = useSyncExternalStore( diff --git a/src/features/agents/plugin-testing.ts b/src/features/agents/plugin-testing.ts new file mode 100644 index 0000000000..6de02adcbc --- /dev/null +++ b/src/features/agents/plugin-testing.ts @@ -0,0 +1,24 @@ +import type { PluginModule } from "../../plugins/api"; +import { presented, type AgentPresentation, type AgentType } from "./service"; + +/** Starts a harness plugin's agent entry, then its UI entry, on `ctx`, and + * returns the type it registered as the service shows it, with what it + * presents. Undefined when it registered none. */ +export function appliedType( + module: Pick, + ctx: Readonly<{ agents: object }> & Record, +): AgentType | undefined { + let type: AgentType | undefined; + let present: ((exposed: unknown) => AgentPresentation) | undefined; + Object.assign(ctx.agents, { + register: (registered: AgentType) => { + type = registered; + }, + present: (_id: string, shown: typeof present) => { + present = shown; + }, + }); + void module.agent?.apply(ctx as never); + void module.apply(ctx as never); + return type && present ? presented(type, present(type.expose)) : type; +} diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 3c0f1095d3..4eafa41597 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -263,6 +263,69 @@ beforeEach(() => { }); afterEach(() => vi.useRealTimers()); +it("shows a type with what its plugin presents, from the handles the type exposes", async () => { + const { service, plugin } = await setup(); + const ready = { readiness: "ready" as const }; + let attention: (() => void) | undefined; + const handles = { count: 1 }; + const shown = vi.fn((exposed: typeof handles) => ({ + icon: () => null, + tabs: [{ id: "t", title: "T", component: () => null }], + provider: { DialogBody: () => null }, + // Only what a type shows is taken from `present`. + run: () => {}, + title: "Wrong", + exposed, + })); + plugin.agents.register({ + id: "shown", + title: "Shown", + defaults: () => ({ config: {} }), + expose: handles, + provider: { + snapshot: () => ready, + subscribe: () => () => {}, + check: () => {}, + get attentionAction() { + return attention; + }, + }, + }); + const runtimeOnly = service.types().find((t) => t.key === "example/shown"); + expect(runtimeOnly?.tabs).toBeUndefined(); + plugin.agents.present("shown", shown as never); + const type = service.types().find((t) => t.key === "example/shown"); + expect(shown).toHaveBeenCalledExactlyOnceWith(handles); + expect(type).toMatchObject({ title: "Shown", tabs: [{ id: "t" }] }); + expect(type?.run).toBeUndefined(); + expect(type?.icon).toBeTypeOf("function"); + expect(type?.provider?.DialogBody).toBeTypeOf("function"); + // The runtime's provider stays live, and readers see one stable type. + expect(type?.provider?.attentionAction).toBeUndefined(); + attention = () => {}; + expect(type?.provider?.attentionAction).toBe(attention); + expect(service.types().find((t) => t.key === "example/shown")).toBe(type); + expect(shown).toHaveBeenCalledOnce(); +}); + +it("still runs a type whose present throws", async () => { + const { service, plugin } = await setup(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + plugin.agents.register({ + id: "broken", + title: "Broken", + defaults: () => ({ config: {} }), + }); + plugin.agents.present("broken", () => { + throw new Error("no UI"); + }); + expect(service.types().find((t) => t.key === "example/broken")).toMatchObject( + { title: "Broken" }, + ); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); +}); + it("creates an agent in the community shown, with its type's defaults, and publishes its profile", async () => { const { service, native } = await setup(); const agent = await service.create({ type: "example/echo", name: " Echo " }); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index de4273857f..f0afa801eb 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -601,8 +601,9 @@ export type AgentProvider = Readonly<{ check(): void | Promise; /** Handles the current attention state directly; absent opens DialogBody. */ attentionAction?: (() => void) | undefined; - /** Provider-owned recovery/progress controls and optional saved defaults. */ - DialogBody: ComponentType<{ onClose(): void }>; + /** Provider-owned recovery/progress controls and optional saved defaults, + * from the type's `present`. */ + DialogBody?: ComponentType<{ onClose(): void }>; /** Compact device default picker; persistence remains provider-owned. */ DefaultControl?: ComponentType<{ onConfigure(): void }>; }>; @@ -708,7 +709,46 @@ export type AgentType = { /** Makes the type hosted: its provider runs each agent, so it has no `run`, * `models` or `timeoutMs`, and its agents have no attention. */ remote?: RemoteAgentType; + /** Handles for the type's own UI, given to its plugin's `present`. Each is + * JSON, a store (`snapshot` and `subscribe`) or a call returning a promise, + * so that agents can later run in another page than their UI. */ + expose?: unknown; }; +/** What an agent type shows, from its plugin's UI entry (`present`); the + * rest of the type comes from its agent entry (`register`). */ +export type AgentPresentation = Pick< + AgentType, + "icon" | "Peek" | "Create" | "CreateModel" | "tabs" +> & + Readonly<{ + provider?: Pick; + }>; +const PRESENTED = ["icon", "Peek", "Create", "CreateModel", "tabs"] as const; +/** The type as its readers see it: `type` with what `present` shows. */ +export function presented( + type: AgentType, + shown: AgentPresentation, +): AgentType { + const merged: AgentType = { ...type }; + for (const field of PRESENTED) + if (shown[field] !== undefined) + Object.assign(merged, { [field]: shown[field] }); + if (type.provider) { + // Keep getters, such as `attentionAction`, live. + const provider = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(type.provider), + ) as AgentProvider; + if (shown.provider?.DialogBody) + Object.assign(provider, { DialogBody: shown.provider.DialogBody }); + if (shown.provider?.DefaultControl) + Object.assign(provider, { + DefaultControl: shown.provider.DefaultControl, + }); + merged.provider = provider; + } + return merged; +} /** A hosted type's provider: it holds each agent's key and runs it. The app * calls these in order to create one (`provision`, then `activate`) and owns * the owner's agent list; every step is asked again after a failure, so each @@ -767,6 +807,10 @@ export type RemoteAgentType = Readonly<{ ): Promise; }>; export type RegisteredAgentType = Contribution; +type Presentation = Readonly<{ + id: string; + present(exposed: unknown): AgentPresentation; +}>; export type RetypePreview = Readonly<{ /** Destination settings after applying the target type's migration policy. */ config: unknown; @@ -901,6 +945,12 @@ export type Agents = { configExample?: Readonly>; }>[]; register(type: AgentType): void; + /** Supplies what this plugin's type `typeId` shows, from its UI entry. + * `present` gets the type's `expose`. */ + present( + typeId: string, + present: (exposed: Exposed) => AgentPresentation, + ): void; types(): readonly RegisteredAgentType[]; /** Adds a tool to every agent session in the app, beside the Buzz tools. * The tool acts as the agent, like the rest of a trusted plugin's code @@ -1287,6 +1337,17 @@ export class AgentsService extends Service implements Agents { * shown over the newer one. */ private keyGeneration = 0; private readonly contributions; + private readonly presentations; + /** Each registered type with what it shows, worked out once per pair. */ + private readonly presentedTypes = new WeakMap< + RegisteredAgentType, + Readonly<{ from: Presentation; type: RegisteredAgentType }> + >(); + private merged: Readonly<{ + types: readonly RegisteredAgentType[]; + shown: readonly Contribution[]; + result: readonly RegisteredAgentType[]; + }> = { types: [], shown: [], result: [] }; private readonly addedTools; /** Every added tool's name, its plugin active or still starting. */ private readonly toolNames = new Set(); @@ -1384,6 +1445,7 @@ export class AgentsService extends Service implements Agents { ) { super(ctx, "agents"); this.contributions = createContributions(ctx); + this.presentations = createContributions(ctx); this.addedTools = createContributions(ctx); this.attentionSources = createContributions(ctx); // Each changes the copy at once and logs a failed save itself. @@ -1662,10 +1724,12 @@ export class AgentsService extends Service implements Agents { }); ctx.effect(() => { const stops = [ - this.contributions.subscribe(() => { - this.update(); - this.notify(); - }), + ...[this.contributions, this.presentations].map((registry) => + registry.subscribe(() => { + this.update(); + this.notify(); + }), + ), communities.subscribe(() => { const { viewer } = communities.snapshot(); const kept = this.requests.filter( @@ -1763,7 +1827,44 @@ export class AgentsService extends Service implements Agents { ); this.contributions.register(this.ctx, type as unknown as AgentType); } - types = () => this.contributions.snapshot(); + present( + typeId: string, + present: (exposed: Exposed) => AgentPresentation, + ) { + if (typeof present !== "function") + throw new Error("present needs a function"); + this.presentations.register(this.ctx, { + id: typeId, + present: present as Presentation["present"], + }); + } + types = () => { + const types = this.contributions.snapshot(); + const shown = this.presentations.snapshot(); + if (types === this.merged.types && shown === this.merged.shown) + return this.merged.result; + const result = Object.freeze( + types.map((type) => { + const from = shown.find((entry) => entry.key === type.key); + if (!from) return type; + const known = this.presentedTypes.get(type); + if (known?.from === from) return known.type; + let merged = type; + try { + merged = Object.freeze( + presented(type, from.present(type.expose)), + ) as RegisteredAgentType; + } catch (error) { + // Its agents still run; only what the type shows is missing. + console.error(`Agent type ${type.key} could not be shown`, error); + } + this.presentedTypes.set(type, { from, type: merged }); + return merged; + }), + ); + this.merged = { types, shown, result }; + return result; + }; addTool(tool: AddedTool) { if ( !/^[a-z][a-z0-9_]{0,63}$/.test(tool.name ?? "") ||