fix: open selected channel setup immediately (#113197)

* CLI: enter selected channel setup directly

* fix: keep channel direct entry resolution scoped

* fix: keep exact channel ids ahead of aliases

* test: clarify channel-only guided routing

* fix: align channel direct entry with current main
This commit is contained in:
Jesse Merhi
2026-07-27 03:30:15 +10:00
committed by GitHub
parent cca5b14785
commit 6bbf1dd917
7 changed files with 207 additions and 19 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ If a channel plugin needs to be installed during a flag-driven add command, Open
Both guided setup and flag-driven setup pass through the selected channel's parser, validation, account resolution, config writer, and post-write hooks. Unsupported flags fail with the owning channel's setup error instead of being accepted through a global input bag.
When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel <id>` both preselect that channel without bypassing guidance:
When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel <id>` both open that channel's guided setup immediately. Back returns to the full channel picker:
```bash
openclaw channels add telegram
+3 -1
View File
@@ -33,7 +33,6 @@ function setLookupEntry(
key: string | undefined,
entry: RegisteredChannelPluginEntry,
): void {
// First writer wins so canonical ids keep priority over later aliases.
if (key && !map.has(key)) {
map.set(key, entry);
}
@@ -60,6 +59,9 @@ function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup {
const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "");
setLookupEntry(byKey, id, entry);
setLookupEntry(byId, id, entry);
}
// Canonical ids are registered first so aliases can never shadow them.
for (const entry of entries) {
for (const alias of entry.plugin.meta?.aliases ?? []) {
setLookupEntry(byKey, normalizeOptionalLowercaseString(alias), entry);
}
+14
View File
@@ -94,6 +94,20 @@ describe("channel registry helpers", () => {
expect(normalizeAnyChannelId("qq")).toBe("qqbot");
});
it("prefers an exact channel id over an earlier plugin alias", () => {
const aliasOwner = createRegistryWithRegisteredChannel("alias-owner", ["exact-id"]).channels[0];
const exactOwner = createRegistryWithRegisteredChannel("exact-id").channels[0];
setActivePluginRegistry(
createTestRegistry([
expectDefined(aliasOwner, "alias owner test channel"),
expectDefined(exactOwner, "exact owner test channel"),
]),
);
expect(normalizeAnyChannelId("exact-id")).toBe("exact-id");
expect(normalizeAnyChannelIdLight("exact-id")).toBe("exact-id");
});
it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => {
const startupRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(startupRegistry);
+42
View File
@@ -560,6 +560,48 @@ describe("channelsAddCommand", () => {
expect(setupOptions().finishAfterInitialSelection).toBe(true);
});
it("opens an exact channel id instead of an earlier plugin alias", async () => {
const config: OpenClawConfig = { channels: {} };
const aliasOwner = createChannelTestPluginBase({
id: "alias-owner",
label: "Alias Owner",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alias-owner",
plugin: {
...aliasOwner,
meta: { ...aliasOwner.meta, aliases: ["exact-id"] },
},
source: "test",
},
]),
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
sourceConfig: config,
config,
});
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
{
...createExternalChatCatalogEntry(),
id: "exact-id",
meta: {
...createExternalChatCatalogEntry().meta,
id: "exact-id",
label: "Exact ID",
selectionLabel: "Exact ID",
},
},
]);
await channelsAddCommand({ channel: "exact-id" }, runtime, { hasFlags: false });
expect(setupOptions().initialSelection).toEqual(["exact-id"]);
expect(setupOptions().finishAfterInitialSelection).toBe(true);
});
it("exits quietly when guided channel setup is cancelled", async () => {
const { WizardCancelledError } = await import("../wizard/prompts.js");
configMocks.readConfigFileSnapshot.mockResolvedValue({
+4 -3
View File
@@ -39,12 +39,13 @@ export async function resolveInitialWizardChannel(
installedPlugins: listActiveChannelSetupPlugins(),
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
});
return resolved.entries.find(
(entry) =>
normalizeOptionalLowercaseString(entry.id) === normalized ||
return (
resolved.entries.find((entry) => normalizeOptionalLowercaseString(entry.id) === normalized) ??
resolved.entries.find((entry) =>
(entry.meta.aliases ?? []).some(
(alias) => normalizeOptionalLowercaseString(alias) === normalized,
),
)
)?.id;
}
+124
View File
@@ -830,6 +830,130 @@ describe("setupChannels workspace shadow exclusion", () => {
},
);
it("enters an explicitly targeted channel before the generic setup prompts", async () => {
const promptOrder: string[] = [];
const configureInteractive = vi.fn(async ({ cfg }) => {
promptOrder.push("channel setup");
return {
cfg: {
...cfg,
channels: { ...cfg.channels, "external-chat": { token: "configured" } },
},
accountId: "external-account",
};
});
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard: {
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "external-chat",
configured: false,
statusLines: [],
})),
configure: vi.fn(),
configureInteractive,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
const confirm = vi.fn(async () => {
promptOrder.push("setup confirmation");
return true;
});
const select = vi.fn(async () => {
promptOrder.push("channel picker");
return "__done__";
});
const result = await setupChannels(
{} as OpenClawConfig,
{} as never,
{
confirm,
note: vi.fn(async () => undefined),
select,
} as never,
{
initialSelection: ["external-chat"],
finishAfterInitialSelection: true,
deferStatusUntilSelection: true,
skipDmPolicyPrompt: true,
},
);
expect(promptOrder).toEqual(["channel setup"]);
expect(confirm).not.toHaveBeenCalled();
expect(configureInteractive).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
channels: { "external-chat": { token: "configured" } },
plugins: { entries: { "external-chat": { enabled: true } } },
});
});
it("returns targeted channel setup Back navigation to the channel picker", async () => {
const promptOrder: string[] = [];
const configureInteractive = vi.fn(async ({ prompter }) => {
promptOrder.push("channel setup");
await prompter.text({ message: "External Chat token" });
return {
cfg: {
channels: { "external-chat": { token: "should-not-apply" } },
} as OpenClawConfig,
accountId: "external-account",
};
});
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard: {
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "external-chat",
configured: false,
statusLines: [],
})),
configure: vi.fn(),
configureInteractive,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
const select = vi.fn(async () => {
promptOrder.push("channel picker");
return "__done__";
});
const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig;
const result = await setupChannels(
cfg,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
text: vi.fn(async () => {
throw new WizardNavigationError("back");
}),
} as never,
{
initialSelection: ["external-chat"],
finishAfterInitialSelection: true,
deferStatusUntilSelection: true,
skipDmPolicyPrompt: true,
},
);
expect(promptOrder).toEqual(["channel setup", "channel picker"]);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
message: "Select a channel",
}),
);
expect(result).toEqual(cfg);
});
it("returns custom channel setup to channel selection when its first prompt goes back", async () => {
const configureInteractive = vi.fn(async ({ prompter }) => {
await prompter.text({ message: "Custom channel token" });
+19 -14
View File
@@ -236,12 +236,17 @@ export async function setupChannels(
await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle"));
}
const shouldConfigure = options?.skipConfirm
? true
: await prompter.confirm({
message: t("wizard.channels.setupConfirm"),
initialValue: true,
});
const targetedChannel =
options?.finishAfterInitialSelection && options.initialSelection?.length === 1
? options.initialSelection[0]
: undefined;
const shouldConfigure =
options?.skipConfirm || targetedChannel
? true
: await prompter.confirm({
message: t("wizard.channels.setupConfirm"),
initialValue: true,
});
if (!shouldConfigure) {
return cfg;
}
@@ -268,10 +273,6 @@ export async function setupChannels(
const selection: ChannelChoice[] = [];
let finishSetupRequested = false;
const targetedChannel =
options?.finishAfterInitialSelection && options.initialSelection?.length === 1
? options.initialSelection[0]
: undefined;
const addSelection = (channel: ChannelChoice) => {
if (!selection.includes(channel)) {
selection.push(channel);
@@ -898,9 +899,13 @@ export async function setupChannels(
return "done";
};
if (targetedChannel) {
await handleChannelChoice(targetedChannel);
} else if (options?.quickstartDefaults) {
// Targeted setup finishes after success, but Back must re-enter the shared
// picker instead of ending the wizard.
const targetedSetupReturnedToPicker = targetedChannel
? (await handleChannelChoice(targetedChannel)) === "retry_selection"
: false;
if (!targetedChannel && options?.quickstartDefaults) {
const skipValue = "__skip__" as const;
const quickstartInitialValue = options?.initialSelection?.[0] ?? skipValue;
while (true) {
@@ -931,7 +936,7 @@ export async function setupChannels(
break;
}
}
} else {
} else if (!targetedChannel || targetedSetupReturnedToPicker) {
const doneValue = "__done__" as const;
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
while (true) {