From 5e38d7622c491540ff0bdf076f31999fe2a1f794 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 12:45:37 -0400 Subject: [PATCH] feat(agents): forward hosted agent types the screens' page registers Builderlab registers its hosted type whole from its UI entry: its provider steps use the sign-in that lives there. Once agents run in another page, the screens' page lists such a type with the service and keeps what it shows. The service adds a stand-in whose provider steps and readiness checks are answered back in the screens' page, with each step's abort carried across, and the type's readiness pushed as data when it changes. A reloaded service is told the types again; a step left unanswered by a screens' page that reloaded ends with an error. A type that runs its agents in the screens' page, or has parts only an agent entry can give, is refused there with an error that names the need for an agent entry. Nothing uses this yet. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/hosted-port.ts | 341 ++++++++++++++++++++++++++++ src/features/agents/port.test.ts | 152 ++++++++++++- src/features/agents/port.ts | 92 +++++++- src/features/agents/service.test.ts | 115 +++++++++- 4 files changed, 675 insertions(+), 25 deletions(-) create mode 100644 src/features/agents/hosted-port.ts diff --git a/src/features/agents/hosted-port.ts b/src/features/agents/hosted-port.ts new file mode 100644 index 0000000000..5c378cc95e --- /dev/null +++ b/src/features/agents/hosted-port.ts @@ -0,0 +1,341 @@ +// Hosted agent types a plugin registers whole from the screens' page, for +// agents the service runs in another page. Builderlab does: its provider +// steps use the sign-in that lives in its UI. The screens' page lists such a +// type with the service and keeps what it shows; the service adds a stand-in +// whose provider steps, and readiness checks, are answered back in the +// screens' page. The type's readiness crosses as data each time it changes. +import type { ToolOwner } from "./tool-port"; +import type { + AgentPresentation, + AgentProviderSnapshot, + AgentType, + RemoteAgentType, +} from "./service"; + +const STEPS = [ + "provision", + "activate", + "profile", + "interrupt", + "remove", +] as const satisfies readonly (keyof RemoteAgentType)[]; +type Step = (typeof STEPS)[number]; +/** A provider step, or one of the type's readiness calls. */ +export type HostedMethod = Step | "check" | "attention"; + +/** What of a hosted type the service needs, as data. */ +export type HostedListing = Readonly<{ + id: string; + title: string; + description?: string; + migrationWarning?: string; + /** Its `defaults()`, which a hosted type's are as plain data. */ + defaults: ReturnType; + /** The provider steps it has. */ + steps: readonly Step[]; + provider?: Readonly<{ snapshot: AgentProviderSnapshot; attention: boolean }>; + owner: ToolOwner; +}>; +export type HostedCall = Readonly<{ + id: number; + /** The type's key, `pluginId/id`. */ + type: string; + method: HostedMethod; + /** The step's input without its signal, which `abort` stands in for. */ + input?: Readonly>; +}>; +type Outcome = { value?: unknown } | { error: string }; + +/** What a hosted type shows, which stays in the screens' page. */ +const SHOWN = [ + "icon", + "Peek", + "Create", + "CreateModel", + "tabs", + "defaults", + "importShared", + "adopt", + "carry", + "describeMigration", + "migrationWarning", + "summary", +] as const satisfies readonly (keyof AgentPresentation)[]; +/** Neither crosses nor is shown: a type with any of them needs an agent entry. */ +const STAYS = [ + "run", + "runOnce", + "models", + "usage", + "sessions", + "sessionControls", + "syncedSettings", + "resumes", + "timeoutMs", + "expose", +] as const satisfies readonly (keyof AgentType)[]; + +const keyOf = (owner: ToolOwner, id: string) => `${owner.id}/${id}`; +const messageOf = (error: unknown) => + error instanceof Error ? error.message : String(error); + +/** The service's side: each listed type is added with `register` until it is + * withdrawn, and its calls are forwarded with `post`; `abort` tells the + * screens' page that a call's signal aborted. */ +export function serveHosted( + register: (type: AgentType, owner: ToolOwner) => () => void, + post: (call: HostedCall) => void, + abort: (id: number) => void, +) { + const added = new Map< + string, + Readonly<{ + remove(): void; + provide(snapshot: AgentProviderSnapshot, attention: boolean): void; + }> + >(); + const waiting = new Map< + number, + Readonly<{ + type: string; + resolve(value: unknown): void; + reject(error: Error): void; + }> + >(); + let next = 0; + /** Ends the calls of types `gone` picks, which will not be answered. */ + const abandon = (gone: (type: string) => boolean, why: string) => { + for (const pending of waiting.values()) + if (gone(pending.type)) pending.reject(new Error(why)); + }; + const forward = async ( + type: string, + method: HostedMethod, + input?: Readonly> & { signal?: AbortSignal }, + ) => { + const id = ++next; + const { signal, ...rest } = input ?? {}; + const aborted = () => abort(id); + try { + return await new Promise((resolve, reject) => { + waiting.set(id, { type, resolve, reject }); + post({ id, type, method, ...(input && { input: rest }) }); + if (signal?.aborted) aborted(); + else signal?.addEventListener("abort", aborted, { once: true }); + }); + } finally { + signal?.removeEventListener("abort", aborted); + waiting.delete(id); + } + }; + return { + list(listing: HostedListing) { + const key = keyOf(listing.owner, listing.id); + // Listed again: the page that registered it reloaded, or registered + // it anew. + abandon( + (type) => type === key, + `The page that registers agent type ${key} reloaded`, + ); + added.get(key)?.remove(); + added.delete(key); + const step = + (name: Step) => + (input: Readonly> & { signal: AbortSignal }) => + forward(key, name, input); + let readiness = listing.provider; + const listeners = new Set<() => void>(); + const type: AgentType = { + id: listing.id, + title: listing.title, + ...(listing.description !== undefined && { + description: listing.description, + }), + ...(listing.migrationWarning !== undefined && { + migrationWarning: listing.migrationWarning, + }), + defaults: () => listing.defaults, + // Each step resolves with what the screens' page's step did. + remote: Object.freeze({ + provision: step("provision"), + activate: step("activate"), + profile: step("profile"), + remove: step("remove"), + ...(listing.steps.includes("interrupt") && { + interrupt: step("interrupt"), + }), + }) as unknown as RemoteAgentType, + ...(readiness && { + provider: Object.freeze({ + snapshot: () => readiness?.snapshot ?? { readiness: "checking" }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + check: () => forward(key, "check") as Promise, + get attentionAction() { + return readiness?.attention + ? () => void forward(key, "attention").catch(() => {}) + : undefined; + }, + }), + }), + }; + try { + added.set(key, { + remove: register(type, listing.owner), + provide(snapshot, attention) { + readiness = { snapshot, attention }; + for (const listener of [...listeners]) listener(); + }, + }); + } catch (error) { + console.error(`Agent type ${key} was not added`, error); + } + }, + withdraw(key: string) { + added.get(key)?.remove(); + added.delete(key); + }, + provide(key: string, snapshot: AgentProviderSnapshot, attention: boolean) { + added.get(key)?.provide(snapshot, attention); + }, + settle(id: number, outcome: Outcome) { + const pending = waiting.get(id); + if (!pending) return; + if ("error" in outcome) pending.reject(new Error(outcome.error)); + else pending.resolve(outcome.value); + }, + stop() { + for (const { remove } of added.values()) remove(); + added.clear(); + abandon(() => true, "The agents connection is closed."); + }, + }; +} + +/** The screens' page's side: it lists each hosted type a plugin registers + * here with `list`, shows the rest of it with `present`, and answers the + * service's calls of it with `settle`. */ +export function hostedClient( + send: Readonly<{ + list(listing: HostedListing): void; + withdraw(key: string): void; + provide( + key: string, + snapshot: AgentProviderSnapshot, + attention: boolean, + ): void; + settle(id: number, outcome: Outcome): void; + }>, + present: (key: string, present: () => AgentPresentation) => () => void, +) { + const types = new Map< + string, + Readonly<{ type: AgentType; listing: () => HostedListing }> + >(); + const signals = new Map(); + return { + /** Lists `owner`'s hosted `type` with the service, and shows it here, + * until the returned withdraw. A type that runs its agents in this page + * cannot be registered here: it needs an agent entry. */ + register(type: AgentType, owner: ToolOwner) { + const key = keyOf(owner, type.id); + if (!type.remote) + throw new Error( + `Agent type ${key} runs its agents in the page that shows them, which Buzz no longer does. Its plugin needs an agent entry.`, + ); + const stays = STAYS.filter((field) => type[field] !== undefined); + if (stays.length) + throw new Error( + `Agent type ${key} has ${stays.join(", ")}, which only an agent entry can give. Its plugin needs an agent entry.`, + ); + const { provider, remote } = type; + const listing = (): HostedListing => ({ + id: type.id, + title: type.title, + ...(type.description !== undefined && { + description: type.description, + }), + ...(type.migrationWarning !== undefined && { + migrationWarning: type.migrationWarning, + }), + defaults: type.defaults(), + steps: STEPS.filter((step) => typeof remote[step] === "function"), + ...(provider && { + provider: { + snapshot: provider.snapshot(), + attention: Boolean(provider.attentionAction), + }, + }), + owner, + }); + const entry = Object.freeze({ type, listing }); + types.set(key, entry); + send.list(listing()); + const stopReadiness = provider?.subscribe(() => { + if (types.get(key) === entry) + send.provide( + key, + provider.snapshot(), + Boolean(provider.attentionAction), + ); + }); + const shown: AgentPresentation = { + ...Object.fromEntries( + SHOWN.flatMap((field) => + type[field] === undefined ? [] : [[field, type[field]]], + ), + ), + ...(provider && { + provider: { + ...(provider.DialogBody && { DialogBody: provider.DialogBody }), + ...(provider.DefaultControl && { + DefaultControl: provider.DefaultControl, + }), + }, + }), + }; + const hide = present(key, () => shown); + return () => { + if (types.get(key) !== entry) return; + types.delete(key); + stopReadiness?.(); + hide(); + send.withdraw(key); + }; + }, + async answer({ id, type: key, method, input }: HostedCall) { + const controller = new AbortController(); + signals.set(id, controller); + try { + const type = types.get(key)?.type; + if (!type) throw new Error(`Agent type ${key} is no longer registered`); + const run = async (): Promise => { + if (method === "check") return type.provider?.check(); + if (method === "attention") return type.provider?.attentionAction?.(); + const step = type.remote?.[method] as + | ((input: unknown) => Promise) + | undefined; + if (!step) throw new Error(`Agent type ${key} has no ${method}`); + return step.call(type.remote, { + ...input, + signal: controller.signal, + }); + }; + send.settle(id, { value: await run() }); + } catch (error) { + send.settle(id, { error: messageOf(error) }); + } finally { + signals.delete(id); + } + }, + abort: (id: number) => signals.get(id)?.abort(), + /** The service's page reloaded: it lists them again. */ + relist() { + for (const { listing } of types.values()) send.list(listing()); + }, + }; +} diff --git a/src/features/agents/port.test.ts b/src/features/agents/port.test.ts index 6d74dcfcbc..a667cf5917 100644 --- a/src/features/agents/port.test.ts +++ b/src/features/agents/port.test.ts @@ -5,7 +5,12 @@ import type { Events, Frame } from "./event-port"; import { eventPort } from "./event-port"; import { keyedRead } from "./handles"; import { connectAgents, serveAgents, shared } from "./port"; -import type { Agents, AgentsSnapshot } from "./service"; +import type { + Agents, + AgentsSnapshot, + AgentType, + RemoteAgentType, +} from "./service"; it("keeps each part of the prior value that the next one equals", () => { const a = Object.freeze({ key: "a", name: "A", config: { x: 1 } }); @@ -155,11 +160,9 @@ it("carries a tool's bytes as JSON, and lists the tools again with a reloaded se }, { id: "example", revision: "1" }, ); - const first = serveAgents( - agents, - eventPort("agents", "main", events), + const first = serveAgents(agents, eventPort("agents", "main", events), { addTool, - ); + }); await vi.waitFor(() => expect(added.has("files_copy")).toBe(true)); const upload = vi.fn(async (data: Uint8Array) => ({ url: `https://media.example/${data.length}`, @@ -177,11 +180,9 @@ it("carries a tool's bytes as JSON, and lists the tools again with a reloaded se // The agents page reloads: its tools go with it, and come back listed. first(); expect(added.size).toBe(0); - const second = serveAgents( - agents, - eventPort("agents", "main", events), + const second = serveAgents(agents, eventPort("agents", "main", events), { addTool, - ); + }); stops.push(() => { client.close(); second(); @@ -195,9 +196,11 @@ it("ends a tool's call when the screens' page that answers it reloads", async () const stop = serveAgents( fakeAgents() as unknown as Agents, eventPort("agents", "main", events), - (tool) => { - added.set(tool.name, tool); - return () => added.delete(tool.name); + { + addTool: (tool) => { + added.set(tool.name, tool); + return () => added.delete(tool.name); + }, }, ); const tool = { @@ -224,6 +227,131 @@ it("ends a tool's call when the screens' page that answers it reloads", async () await expect(waiting).rejects.toThrow(/reloaded/); }); +const hostedType = (remote: Partial = {}): AgentType => ({ + id: "cloud", + title: "Cloud", + defaults: () => ({ config: { model: "m" } }), + remote: { + provision: async () => ({ pubkey: "p" }), + activate: async () => {}, + profile: async () => {}, + remove: async () => {}, + ...remote, + }, +}); +function hostedTypes() { + const added = new Map(); + return { + added, + register: (type: AgentType, owner: { id: string }) => { + const key = `${owner.id}/${type.id}`; + added.set(key, type); + return () => added.delete(key); + }, + }; +} +const owner = { id: "example", revision: "1" }; + +it("carries a hosted type's step and its abort, and refuses a type that runs its agents in the screens' page", async () => { + const events = jsonEvents(); + const { added, register } = hostedTypes(); + const stop = serveAgents( + fakeAgents() as unknown as Agents, + eventPort("agents", "main", events), + { register }, + ); + const client = connectAgents(eventPort("main", "agents", events)); + stops.push(() => { + client.close(); + stop(); + }); + let aborted: AbortSignal | undefined; + client.register( + hostedType({ + interrupt: ({ signal }) => + new Promise((_, reject) => { + aborted = signal; + signal.addEventListener("abort", () => + reject(new Error("Stopped waiting")), + ); + }), + }), + owner, + ); + await vi.waitFor(() => expect(added.has("example/cloud")).toBe(true)); + const stand = added.get("example/cloud"); + expect(stand?.defaults()).toEqual({ config: { model: "m" } }); + await expect( + stand?.remote?.provision({ + name: "Sky", + community: "wss://c", + signal: new AbortController().signal, + }), + ).resolves.toEqual({ pubkey: "p" }); + const controller = new AbortController(); + const stopping = stand?.remote?.interrupt?.({ + pubkey: "p", + community: "wss://c", + channelId: "c", + threadRootId: "r", + signal: controller.signal, + }); + await vi.waitFor(() => expect(aborted).toBeDefined()); + controller.abort(); + await expect(stopping).rejects.toThrow("Stopped waiting"); + expect(aborted?.aborted).toBe(true); + // A type that runs its agents here has nowhere to run them. + expect(() => + client.register( + { id: "echo", title: "Echo", defaults: () => ({ config: {} }) }, + owner, + ), + ).toThrow(/needs an agent entry/); + expect(() => + client.register( + { ...hostedType(), id: "busy", usage: async () => [] }, + owner, + ), + ).toThrow(/usage, which only an agent entry/); +}); + +it("ends a hosted type's step when the screens' page reloads, and lists it again with a reloaded service", async () => { + const events = jsonEvents(); + const { added, register } = hostedTypes(); + const first = serveAgents( + fakeAgents() as unknown as Agents, + eventPort("agents", "main", events), + { register }, + ); + const type = hostedType({ remove: () => new Promise(() => {}) }); + const before = connectAgents(eventPort("main", "agents", events)); + before.register(type, owner); + await vi.waitFor(() => expect(added.has("example/cloud")).toBe(true)); + const removing = added.get("example/cloud")?.remote?.remove({ + pubkey: "p", + community: "wss://c", + signal: new AbortController().signal, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + before.close(); + const after = connectAgents(eventPort("main", "agents", events)); + after.register(type, owner); + await expect(removing).rejects.toThrow(/reloaded/); + // The agents page reloads: its stand-in goes with it, and comes back. + first(); + expect(added.size).toBe(0); + const second = serveAgents( + fakeAgents() as unknown as Agents, + eventPort("agents", "main", events), + { register }, + ); + stops.push(() => { + after.close(); + second(); + }); + await vi.waitFor(() => expect(added.has("example/cloud")).toBe(true)); +}); + it("reads as loading until the service's first push, then as the service", async () => { const client = connected(fakeAgents()); expect(client.snapshot().status).toBe("loading"); diff --git a/src/features/agents/port.ts b/src/features/agents/port.ts index dd84e2bea8..93c1fa2018 100644 --- a/src/features/agents/port.ts +++ b/src/features/agents/port.ts @@ -4,9 +4,11 @@ import { type Agent, type AgentCreationRequest, + type AgentProviderSnapshot, type Agents, type AgentsClient, type AgentsSnapshot, + type AgentType, type AgentUpdateRequest, retypePreview, type SessionPlace, @@ -14,6 +16,12 @@ import { } from "./service"; import type { AddedTool } from "../../buzz-mcp/session-tools"; import type { Reply } from "../../buzz-mcp/tools"; +import { + type HostedCall, + hostedClient, + type HostedListing, + serveHosted, +} from "./hosted-port"; import { serveTools, type ToolCall, @@ -80,6 +88,8 @@ type State = Readonly<{ type ToClient = | Readonly<{ kind: "state"; state: State }> | Readonly<{ kind: "tool"; call: ToolCall }> + | Readonly<{ kind: "hosted"; call: HostedCall }> + | Readonly<{ kind: "hosted-abort"; id: number }> | Readonly<{ kind: "result"; id: number; value: unknown }> | Readonly<{ kind: "error"; id: number; message: string }>; type ToService = @@ -103,6 +113,19 @@ type ToService = client: number; method: string; args: unknown[]; + }> + | Readonly<{ kind: "list-type"; listing: HostedListing }> + | Readonly<{ kind: "withdraw-type"; key: string }> + | Readonly<{ + kind: "provide"; + key: string; + snapshot: AgentProviderSnapshot; + attention: boolean; + }> + | Readonly<{ + kind: "settle-type"; + id: number; + outcome: { value?: unknown } | { error: string }; }>; /** A `MessagePort`, or a link to a page in another webview. It delivers each @@ -121,6 +144,9 @@ export type AgentsPortClient = AgentsClient & /** Adds `owner`'s `tool` for the service's agents, answered in this * page, until the returned withdraw. */ addTool(tool: AddedTool, owner: ToolOwner): () => void; + /** Registers `owner`'s hosted `type` for the service, its provider steps + * answered in this page, until the returned withdraw. */ + register(type: AgentType, owner: ToolOwner): () => void; }>; const placeKey = (key: string, { channelId, root }: SessionPlace) => @@ -170,20 +196,32 @@ function stateOf( /** Serves `agents` on `port` until the returned stop. It pushes state before * each answer, so a call's result never arrives before what it changed. A - * tool the screens' page lists is added with `addTool` until it withdraws - * it; without `addTool`, its tools are not added. */ + * tool or hosted type the screens' page lists is added, as its plugin's, + * with `plugins.addTool` or `plugins.register` until it withdraws it; + * without them, it is not added. */ export function serveAgents( agents: Agents, port: AgentsPort, - addTool?: (tool: AddedTool, owner: ToolOwner) => () => void, + plugins: Readonly<{ + addTool?: (tool: AddedTool, owner: ToolOwner) => () => void; + register?: (type: AgentType, owner: ToolOwner) => () => void; + }> = {}, ) { const types = serveTypes(agents); const instance = crypto.randomUUID(); - const tools = addTool - ? serveTools(addTool, (call) => + const tools = plugins.addTool + ? serveTools(plugins.addTool, (call) => port.postMessage({ kind: "tool", call } satisfies ToClient), ) : undefined; + const hosted = plugins.register + ? serveHosted( + plugins.register, + (call) => port.postMessage({ kind: "hosted", call } satisfies ToClient), + (id) => + port.postMessage({ kind: "hosted-abort", id } satisfies ToClient), + ) + : undefined; const places = new Map(); // The types' own stores, which change without the service's notice. const stores = new Map<(listener: () => void) => () => void, () => void>(); @@ -250,6 +288,22 @@ export function serveAgents( tools?.settle(data.id, data.outcome); return; } + if (data.kind === "list-type") { + hosted?.list(data.listing); + return; + } + if (data.kind === "withdraw-type") { + hosted?.withdraw(data.key); + return; + } + if (data.kind === "provide") { + hosted?.provide(data.key, data.snapshot, data.attention); + return; + } + if (data.kind === "settle-type") { + hosted?.settle(data.id, data.outcome); + return; + } if (data.kind === "client") { const { client, method, args } = data; answer(data.id, () => { @@ -287,6 +341,7 @@ export function serveAgents( return () => { unsubscribe(); tools?.stop(); + hosted?.stop(); for (const stop of stores.values()) stop(); stores.clear(); port.onmessage = null; @@ -384,6 +439,7 @@ export function connectAgents(port: AgentsPort) { // A new serving, as when its page reloaded: tell it again. asked.clear(); tools.relist(); + hosted.relist(); } if (state !== prior) for (const listener of listeners) listener(); return; @@ -392,6 +448,14 @@ export function connectAgents(port: AgentsPort) { void tools.answer(data.call); return; } + if (data.kind === "hosted") { + void hosted.answer(data.call); + return; + } + if (data.kind === "hosted-abort") { + hosted.abort(data.id); + return; + } const pending = waiting.get(data.id); if (!pending) return; waiting.delete(data.id); @@ -487,6 +551,23 @@ export function connectAgents(port: AgentsPort) { } }), }); + const hosted = hostedClient( + { + list: (listing) => { + if (!closed) post({ kind: "list-type", listing }); + }, + withdraw: (key) => { + if (!closed) post({ kind: "withdraw-type", key }); + }, + provide: (key, snapshot, attention) => { + if (!closed) post({ kind: "provide", key, snapshot, attention }); + }, + settle: (id, outcome) => { + if (!closed) post({ kind: "settle-type", id, outcome }); + }, + }, + types.present, + ); const client = { ...calls, ...sends, @@ -495,6 +576,7 @@ export function connectAgents(port: AgentsPort) { types: types.types, present: types.present, addTool: tools.add, + register: hosted.register, find: (pubkey: string) => state.snapshot.agents.find((agent) => agent.pubkey === pubkey), classifier: () => state.snapshot.classifier, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 2c4aee3426..8e0fdda072 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -24,6 +24,7 @@ import type { AgentIdentity } from "./native"; import { type Agent, AgentsService, + type AgentProviderSnapshot, type AgentType, type Carry, type ConfigBinding, @@ -7443,14 +7444,16 @@ it("adds plugins' tools to every session, each name once", async () => { it("answers a tool the screens' page adds there, calling the agent's client back", async () => { const { service, ctx } = await setup(); const { port1, port2 } = new MessageChannel(); - const stop = serveAgents(service, port1, (tool, owner) => { - const fiber = ctx - .extend({ pluginOwner: Object.freeze({ ...owner }) }) - .plugin({ - inject: ["agents"], - apply: (plugin: Context) => plugin.agents.addTool(tool), - }); - return () => void fiber.dispose(); + const stop = serveAgents(service, port1, { + addTool: (tool, owner) => { + const fiber = ctx + .extend({ pluginOwner: Object.freeze({ ...owner }) }) + .plugin({ + inject: ["agents"], + apply: (plugin: Context) => plugin.agents.addTool(tool), + }); + return () => void fiber.dispose(); + }, }); const client = connectAgents(port2); const withdraw = client.addTool( @@ -7493,6 +7496,102 @@ it("answers a tool the screens' page adds there, calling the agent's client back stop(); }); +it("creates and removes a hosted agent whose type the screens' page registers, answering its steps there", async () => { + const { service, ctx } = await setup(); + const { port1, port2 } = new MessageChannel(); + const stop = serveAgents(service, port1, { + register: (type, owner) => { + const fiber = ctx + .extend({ pluginOwner: Object.freeze({ ...owner }) }) + .plugin({ + inject: ["agents"], + apply: (plugin: Context) => plugin.agents.register(type), + }); + return () => void fiber.dispose(); + }, + }); + const client = connectAgents(port2); + const { type, remote } = cloud(); + let readiness: AgentProviderSnapshot = { + readiness: "attention", + attentionLabel: "Log in", + }; + const readinessListeners = new Set<() => void>(); + const Create = () => null; + const DialogBody = () => null; + const withdraw = client.register( + { + ...type, + Create, + provider: { + snapshot: () => readiness, + subscribe: (listener) => { + readinessListeners.add(listener); + return () => readinessListeners.delete(listener); + }, + check: () => {}, + DialogBody, + }, + }, + { id: "example", revision: "1" }, + ); + await vi.waitFor(() => + expect( + service.types().find((item) => item.key === "example/cloud"), + ).toBeDefined(), + ); + // What it shows stays in the screens' page; its readiness crosses. + await vi.waitFor(() => + expect( + client + .types() + .find((item) => item.key === "example/cloud") + ?.provider?.snapshot(), + ).toEqual(readiness), + ); + const shown = client.types().find((item) => item.key === "example/cloud"); + expect(shown?.Create).toBe(Create); + expect(shown?.provider?.DialogBody).toBe(DialogBody); + expect(shown?.remote).toBeTruthy(); + readiness = { readiness: "ready" }; + for (const listener of readinessListeners) listener(); + await vi.waitFor(() => + expect( + service + .types() + .find((item) => item.key === "example/cloud") + ?.provider?.snapshot(), + ).toEqual({ readiness: "ready" }), + ); + await expect( + client.create({ type: "example/cloud", name: "Sky" }), + ).resolves.toMatchObject({ pubkey: hostedKey, type: "example/cloud" }); + expect(remote.provision).toHaveBeenCalledWith({ + name: "Sky", + community: "wss://relay.example.test", + signal: expect.any(AbortSignal), + }); + expect(remote.activate).toHaveBeenCalledWith( + expect.objectContaining({ + pubkey: hostedKey, + auth: expect.arrayContaining(["auth", viewer]), + }), + ); + await client.remove(hostedKey); + expect(remote.remove).toHaveBeenCalledWith( + expect.objectContaining({ pubkey: hostedKey }), + ); + expect(service.find(hostedKey)).toBeUndefined(); + withdraw(); + await vi.waitFor(() => + expect( + service.types().find((item) => item.key === "example/cloud"), + ).toBeUndefined(), + ); + client.close(); + stop(); +}); + it("reserves a tool name while its plugin is still starting", async () => { const ctx = new Context(); ctx.provide("pluginStatus", {