// Verifies runtime channel pinning for plugin activation. import { afterEach, describe, expect, it } from "vitest"; import { loadChannelOutboundAdapter } from "../channels/plugins/outbound/load.js"; import { getChannelPlugin } from "../channels/plugins/registry.js"; import { emitAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { isPluginRegistryRetired } from "./registry-lifecycle.js"; import { getActivePluginChannelRegistryVersion, getActivePluginRegistryVersion, getActivePluginChannelRegistry, pinActivePluginChannelRegistry, releasePinnedPluginChannelRegistry, requireActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "./runtime.js"; function createRegistryWithChannel(pluginId = "demo-channel") { const registry = createEmptyPluginRegistry(); const plugin = { id: pluginId, meta: {} } as never; registry.channels = [{ plugin }] as never; return { registry, plugin }; } function createChannelRegistryPair(pluginId = "demo-channel") { return { first: createRegistryWithChannel(pluginId), second: createRegistryWithChannel(pluginId), }; } function createRegistrySet() { return { startup: createEmptyPluginRegistry(), replacement: createEmptyPluginRegistry(), unrelated: createEmptyPluginRegistry(), }; } function expectActiveChannelRegistry(registry: ReturnType) { expect(getActivePluginChannelRegistry()).toBe(registry); } function expectPinnedChannelRegistry( startupRegistry: ReturnType, replacementRegistry: ReturnType, ) { setActivePluginRegistry(startupRegistry); pinActivePluginChannelRegistry(startupRegistry); setActivePluginRegistry(replacementRegistry); expectActiveChannelRegistry(startupRegistry); } function expectResetClearsPinnedChannelRegistry(params: { startupRegistry: ReturnType; freshRegistry: ReturnType; }) { setActivePluginRegistry(params.startupRegistry); pinActivePluginChannelRegistry(params.startupRegistry); resetPluginRuntimeStateForTest(); setActivePluginRegistry(params.freshRegistry); expectActiveChannelRegistry(params.freshRegistry); } function expectChannelRegistrySwap(params: { startupRegistry: ReturnType; replacementRegistry: ReturnType; pin?: boolean; releaseRegistry?: ReturnType; expectedDuringSwap: ReturnType; expectedAfterRelease: ReturnType; }) { setActivePluginRegistry(params.startupRegistry); if (params.pin) { pinActivePluginChannelRegistry(params.startupRegistry); } setActivePluginRegistry(params.replacementRegistry); expectActiveChannelRegistry(params.expectedDuringSwap); if (params.pin && params.releaseRegistry) { releasePinnedPluginChannelRegistry(params.releaseRegistry); } expectActiveChannelRegistry(params.expectedAfterRelease); } describe("channel registry pinning", () => { afterEach(() => { resetAgentEventsForTest(); resetPluginRuntimeStateForTest(); }); it("returns the active registry when not pinned", () => { const registry = createEmptyPluginRegistry(); setActivePluginRegistry(registry); expectActiveChannelRegistry(registry); }); it("preserves pinned channel registry across setActivePluginRegistry calls", () => { const { registry: startup } = createRegistryWithChannel(); // A subsequent registry swap (e.g., config-schema load) must not evict channels. const replacement = createEmptyPluginRegistry(); expectPinnedChannelRegistry(startup, replacement); expect(getActivePluginChannelRegistry()!.channels).toHaveLength(1); }); it("keeps pinned channel registries live until they are released", () => { const { registry: startup } = createRegistryWithChannel(); const replacement = createEmptyPluginRegistry(); setActivePluginRegistry(startup); pinActivePluginChannelRegistry(startup); setActivePluginRegistry(replacement); expect(getActivePluginChannelRegistry()).toBe(startup); expect(isPluginRegistryRetired(startup)).toBe(false); releasePinnedPluginChannelRegistry(startup); expect(getActivePluginChannelRegistry()).toBe(replacement); expect(isPluginRegistryRetired(startup)).toBe(true); }); it("falls back to the active channel registry when the pinned registry is empty", () => { const startup = createEmptyPluginRegistry(); const { registry: replacement } = createRegistryWithChannel("replacement-channel"); setActivePluginRegistry(startup); pinActivePluginChannelRegistry(startup); const channelVersionBeforeSwap = getActivePluginChannelRegistryVersion(); setActivePluginRegistry(replacement); expectActiveChannelRegistry(replacement); expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersionBeforeSwap); }); it("re-pin invalidates cached channel lookups", () => { const { first, second } = createChannelRegistryPair(); const { registry: setup, plugin: setupPlugin } = first; setActivePluginRegistry(setup); pinActivePluginChannelRegistry(setup); expect(getChannelPlugin("demo-channel")).toBe(setupPlugin); const { registry: full, plugin: fullPlugin } = second; setActivePluginRegistry(full); expect(getChannelPlugin("demo-channel")).toBe(setupPlugin); const activeVersionBeforeRepin = getActivePluginRegistryVersion(); const channelVersionBeforeRepin = getActivePluginChannelRegistryVersion(); pinActivePluginChannelRegistry(full); expect(getActivePluginRegistryVersion()).toBe(activeVersionBeforeRepin); expect(getActivePluginChannelRegistryVersion()).toBe(channelVersionBeforeRepin + 1); expect(getChannelPlugin("demo-channel")).toBe(fullPlugin); }); it.each([ { name: "updates channel registry on swap when not pinned", pin: false, releasePinnedRegistry: false, expectDuringPin: false, expectAfterSwap: "second", }, { name: "release restores live-tracking behavior", pin: true, releasePinnedRegistry: true, expectDuringPin: true, expectAfterSwap: "second", }, { name: "release is a no-op when the pinned registry does not match", pin: true, releasePinnedRegistry: false, expectDuringPin: true, expectAfterSwap: "first", }, ] as const)("$name", ({ pin, releasePinnedRegistry, expectDuringPin, expectAfterSwap }) => { const { startup, replacement, unrelated } = createRegistrySet(); expectChannelRegistrySwap({ startupRegistry: startup, replacementRegistry: replacement, ...(pin ? { pin: true } : {}), ...(pin ? { releaseRegistry: releasePinnedRegistry ? startup : unrelated } : {}), expectedDuringSwap: expectDuringPin ? startup : replacement, expectedAfterRelease: expectAfterSwap === "second" ? replacement : startup, }); }); it("requireActivePluginChannelRegistry creates a registry when none exists", () => { resetPluginRuntimeStateForTest(); const registry = requireActivePluginChannelRegistry(); expect(registry).toEqual(createEmptyPluginRegistry()); }); it("resetPluginRuntimeStateForTest clears channel pin", () => { const { startup, replacement: fresh } = createRegistrySet(); expectResetClearsPinnedChannelRegistry({ startupRegistry: startup, freshRegistry: fresh, }); }); it("loadChannelOutboundAdapter resolves from pinned registry after active registry replacement", async () => { const outboundAdapter = { send: async () => ({ messageId: "1" }) }; const startup = createEmptyPluginRegistry(); startup.channels = [ { pluginId: "telegram", plugin: { id: "telegram", meta: {}, outbound: outboundAdapter }, source: "test", }, ] as never; setActivePluginRegistry(startup); pinActivePluginChannelRegistry(startup); // Simulate a post-boot registry replacement (e.g. config-schema load, plugin status query). const replacement = createEmptyPluginRegistry(); setActivePluginRegistry(replacement); // The outbound loader must still find the telegram adapter from the pinned registry. const adapter = await loadChannelOutboundAdapter("telegram"); expect(adapter).toBe(outboundAdapter); }); it("loadChannelOutboundAdapter falls back to active registry when pinned setup entry cannot send", async () => { const outboundAdapter = { sendText: async () => ({ messageId: "1" }) }; const startup = createEmptyPluginRegistry(); startup.channels = [ { pluginId: "discord", plugin: { id: "discord", meta: {} }, source: "setup", }, ] as never; const replacement = createEmptyPluginRegistry(); replacement.channels = [ { pluginId: "discord", plugin: { id: "discord", meta: {}, outbound: outboundAdapter }, source: "runtime", }, ] as never; setActivePluginRegistry(startup); pinActivePluginChannelRegistry(startup); setActivePluginRegistry(replacement); const adapter = await loadChannelOutboundAdapter("discord"); expect(adapter).toBe(outboundAdapter); }); it("keeps pinned channel registry agent-event subscriptions live after active registry replacement", () => { const observed: string[] = []; const startup = createEmptyPluginRegistry(); startup.agentEventSubscriptions = [ { pluginId: "startup-plugin", pluginName: "Startup Plugin", source: "test", subscription: { id: "startup-subscription", handle: (event) => { observed.push(`startup:${event.stream}`); }, }, }, ]; const replacement = createEmptyPluginRegistry(); replacement.agentEventSubscriptions = [ { pluginId: "replacement-plugin", pluginName: "Replacement Plugin", source: "test", subscription: { id: "replacement-subscription", handle: (event) => { observed.push(`replacement:${event.stream}`); }, }, }, ]; setActivePluginRegistry(startup); pinActivePluginChannelRegistry(startup); setActivePluginRegistry(replacement); emitAgentEvent({ runId: "run-pinned-agent-events", stream: "approval", data: { state: "queued" }, }); expect(observed.toSorted()).toEqual(["replacement:approval", "startup:approval"]); }); it("dedupes the agent-event bridge across multiple runtime module instances", async () => { const observed: string[] = []; const runtimeA = await import(new URL("./runtime.ts?runtimeA", import.meta.url).href); const runtimeB = await import(new URL("./runtime.ts?runtimeB", import.meta.url).href); const startup = createEmptyPluginRegistry(); startup.agentEventSubscriptions = [ { pluginId: "startup-plugin", pluginName: "Startup Plugin", source: "test", subscription: { id: "startup-subscription", handle: (event) => { observed.push(`startup:${event.stream}`); }, }, }, ]; const replacement = createEmptyPluginRegistry(); replacement.agentEventSubscriptions = [ { pluginId: "replacement-plugin", pluginName: "Replacement Plugin", source: "test", subscription: { id: "replacement-subscription", handle: (event) => { observed.push(`replacement:${event.stream}`); }, }, }, ]; runtimeA.setActivePluginRegistry(startup); runtimeA.pinActivePluginChannelRegistry(startup); runtimeB.setActivePluginRegistry(replacement); emitAgentEvent({ runId: "run-cross-module-pinned-agent-events", stream: "approval", data: { state: "queued" }, }); expect(observed.toSorted()).toEqual(["replacement:approval", "startup:approval"]); }); });