fix(channels): refresh plugin registry after on-demand installs

This commit is contained in:
Vincent Koc
2026-04-25 16:19:11 -07:00
parent 28497515fe
commit d74b6359fd
9 changed files with 100 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ type ResolveInstallableChannelPluginResult = {
plugin?: ChannelPlugin;
catalogEntry?: ChannelPluginCatalogEntry;
configChanged: boolean;
pluginInstalled: boolean;
};
function resolveWorkspaceDir(cfg: OpenClawConfig) {
@@ -197,6 +198,7 @@ export async function resolveInstallableChannelPlugin(params: {
cfg: nextCfg,
catalogEntry,
configChanged: false,
pluginInstalled: false,
};
}
@@ -208,6 +210,7 @@ export async function resolveInstallableChannelPlugin(params: {
plugin: existing,
catalogEntry,
configChanged: false,
pluginInstalled: false,
};
}
@@ -227,6 +230,7 @@ export async function resolveInstallableChannelPlugin(params: {
plugin: scoped,
catalogEntry,
configChanged: false,
pluginInstalled: false,
};
}
@@ -258,6 +262,7 @@ export async function resolveInstallableChannelPlugin(params: {
? { ...catalogEntry, pluginId: installedPluginId }
: catalogEntry,
configChanged: nextCfg !== params.cfg,
pluginInstalled: installResult.installed,
};
}
}
@@ -268,5 +273,6 @@ export async function resolveInstallableChannelPlugin(params: {
plugin: existing,
catalogEntry,
configChanged: false,
pluginInstalled: false,
};
}

View File

@@ -30,6 +30,10 @@ const pluginInstallMocks = vi.hoisted(() => ({
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
}));
const registryRefreshMocks = vi.hoisted(() => ({
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
}));
vi.mock("../channels/plugins/catalog.js", () => ({
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
}));
@@ -50,6 +54,8 @@ vi.mock("../channels/plugins/bundled.js", async () => {
vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks);
vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks);
const runtime = createTestRuntime();
function listConfiguredAccountIds(
@@ -268,6 +274,7 @@ describe("channelsAddCommand", () => {
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry(),
);
registryRefreshMocks.refreshPluginRegistryAfterConfigMutation.mockClear();
setMinimalChannelsAddRegistryForTests();
});
@@ -481,6 +488,16 @@ describe("channelsAddCommand", () => {
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({ installRuntimeDeps: false }),
);
expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
channels: expect.objectContaining({
"external-chat": expect.objectContaining({ enabled: true }),
}),
}),
reason: "source-changed",
}),
);
expectExternalChatEnabledConfigWrite();
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();

View File

@@ -19,6 +19,10 @@ const catalogMocks = vi.hoisted(() => ({
listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []),
}));
const registryRefreshMocks = vi.hoisted(() => ({
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
}));
vi.mock("../channels/plugins/catalog.js", async () => {
const actual = await vi.importActual<typeof import("../channels/plugins/catalog.js")>(
"../channels/plugins/catalog.js",
@@ -48,6 +52,8 @@ vi.mock("./channel-setup/plugin-install.js", async () => {
return createMockChannelSetupPluginInstallModule(actual);
});
vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks);
const runtime = createTestRuntime();
describe("channelsRemoveCommand", () => {
@@ -79,6 +85,7 @@ describe("channelsRemoveCommand", () => {
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry(),
);
registryRefreshMocks.refreshPluginRegistryAfterConfigMutation.mockClear();
setActivePluginRegistry(createTestRegistry());
});
@@ -123,6 +130,11 @@ describe("channelsRemoveCommand", () => {
expect.objectContaining({ entry: catalogEntry }),
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(2);
expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({
reason: "source-changed",
}),
);
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.not.objectContaining({
channels: expect.objectContaining({

View File

@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
applyPluginAutoEnable: vi.fn(),
replaceConfigFile: vi.fn(),
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
resolveMessageChannelSelection: vi.fn(),
resolveInstallableChannelPlugin: vi.fn(),
getChannelPlugin: vi.fn(),
@@ -27,6 +28,10 @@ vi.mock("../config/config.js", () => ({
replaceConfigFile: mocks.replaceConfigFile,
}));
vi.mock("../cli/plugins-registry-refresh.js", () => ({
refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation,
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
}));
@@ -54,6 +59,7 @@ describe("channelsResolveCommand", () => {
vi.clearAllMocks();
mocks.loadConfig.mockReturnValue({ channels: {} });
mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" });
mocks.refreshPluginRegistryAfterConfigMutation.mockResolvedValue(undefined);
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
mocks.replaceConfigFile.mockResolvedValue(undefined);
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
@@ -88,6 +94,7 @@ describe("channelsResolveCommand", () => {
cfg: installedCfg,
channelId: "whatsapp",
configChanged: true,
pluginInstalled: true,
plugin: {
id: "whatsapp",
resolver: { resolveTargets },
@@ -112,6 +119,12 @@ describe("channelsResolveCommand", () => {
nextConfig: installedCfg,
baseHash: "config-1",
});
expect(mocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({
config: installedCfg,
reason: "source-changed",
}),
);
expect(resolveTargets).toHaveBeenCalledWith(
expect.objectContaining({
cfg: installedCfg,

View File

@@ -5,6 +5,7 @@ import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/
import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
import {
PLUGIN_INSTALLS_CONFIG_PATH,
@@ -116,6 +117,7 @@ export async function channelsAddCommand(
const cfg = (configSnapshot.sourceConfig ?? configSnapshot.config) as OpenClawConfig;
const baseHash = configSnapshot.hash;
let nextConfig = cfg;
let pluginRegistrySourceChanged = false;
const useWizard = shouldUseWizard(params);
if (useWizard) {
@@ -258,6 +260,13 @@ export async function channelsAddCommand(
? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } }
: {}),
});
if (shouldMovePluginInstalls) {
await refreshPluginRegistryAfterConfigMutation({
config: writtenConfig,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: writtenConfig,
@@ -320,6 +329,7 @@ export async function channelsAddCommand(
if (!result.installed) {
return;
}
pluginRegistrySourceChanged = true;
catalogEntry = {
...catalogEntry,
...(result.pluginId ? { pluginId: result.pluginId } : {}),
@@ -401,6 +411,13 @@ export async function channelsAddCommand(
? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } }
: {}),
});
if (shouldMovePluginInstalls || pluginRegistrySourceChanged) {
await refreshPluginRegistryAfterConfigMutation({
config: writtenConfig,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
runtime.log(`Added ${plugin.meta.label ?? channelLabel(channel)} account "${accountId}".`);
const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten;
if (afterAccountConfigWritten) {

View File

@@ -12,6 +12,7 @@ const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID;
const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
replaceConfigFile: vi.fn(),
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
resolveInstallableChannelPlugin: vi.fn(),
}));
@@ -37,6 +38,10 @@ vi.mock("../../config/config.js", async () => {
};
});
vi.mock("../../cli/plugins-registry-refresh.js", () => ({
refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation,
}));
vi.mock("../channel-setup/channel-plugin-resolution.js", () => ({
resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin,
}));
@@ -203,6 +208,7 @@ describe("channelsCapabilitiesCommand", () => {
channelId: "whatsapp",
plugin,
configChanged: true,
pluginInstalled: true,
});
vi.mocked(listChannelPlugins).mockReturnValue([]);
vi.mocked(getChannelPlugin).mockReturnValue(undefined);
@@ -221,6 +227,11 @@ describe("channelsCapabilitiesCommand", () => {
}),
baseHash: "config-1",
});
expect(mocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({
reason: "source-changed",
}),
);
expect(logs.join("\n")).toContain("Probe: linked");
});
});

View File

@@ -10,6 +10,7 @@ import type {
ChannelCapabilitiesDisplayLine,
ChannelPlugin,
} from "../../channels/plugins/types.public.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import {
readConfigFileSnapshot,
replaceConfigFile,
@@ -269,6 +270,13 @@ export async function channelsCapabilitiesCommand(
? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } }
: {}),
});
if (shouldMovePluginInstalls || resolved.pluginInstalled) {
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
}
return resolved.plugin ? [resolved.plugin] : null;
})();

View File

@@ -4,6 +4,7 @@ import {
listChannelPlugins,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
import {
PLUGIN_INSTALLS_CONFIG_PATH,
@@ -190,6 +191,13 @@ export async function channelsRemoveCommand(
? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } }
: {}),
});
if (shouldMovePluginInstalls || resolvedPluginState?.pluginInstalled) {
await refreshPluginRegistryAfterConfigMutation({
config: next,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
if (useWizard && prompter) {
await prompter.outro(
deleteConfig

View File

@@ -5,6 +5,7 @@ import type {
} from "../../channels/plugins/types.adapters.js";
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js";
import { danger } from "../../globals.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
@@ -158,6 +159,13 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } }
: {}),
});
if (shouldMovePluginInstalls || resolvedExplicit.pluginInstalled) {
await refreshPluginRegistryAfterConfigMutation({
config: cfg,
reason: "source-changed",
logger: { warn: (message) => runtime.log(message) },
});
}
}
const selection = explicitChannel