diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 2b34165515..c7d2b32e89 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -238,6 +238,21 @@ 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 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/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/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 ?? "") || 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; }