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", {