fix: make Back work within channel setup (#108007)

* fix: support Back within channel setup

* docs: note channel setup Back navigation

* fix: keep navigation outcome type private

* style: format navigation outcome type

* chore: leave changelog to release prep
This commit is contained in:
Jesse Merhi
2026-07-20 14:34:05 +10:00
committed by GitHub
parent 94a2494570
commit ce9e393196
9 changed files with 1037 additions and 84 deletions
+36
View File
@@ -13,6 +13,7 @@ import { reefSetupWizard } from "./setup.js";
import {
finalizeReefIdentityBinding,
generateAndStoreKeys,
loadKeys,
loadReefIdentityBinding,
reserveReefIdentityBinding,
} from "./state.js";
@@ -170,4 +171,39 @@ describe("Reef setup wizard identity binding", () => {
relayUrl: "https://reefwire.ai",
});
});
it("declares its persistence boundary before writing keys or creating the handle", async () => {
const runtime = installRuntime();
const beforePersistentEffect = vi.fn(async () => {
await expect(loadKeys(runtime)).rejects.toMatchObject({ code: "ENOENT" });
});
vi.spyOn(ReefTransportClient.prototype, "createHandle").mockImplementation(async () => {
expect(beforePersistentEffect).toHaveBeenCalledTimes(1);
return { handle: "molty", key_epoch: 1 };
});
const textAnswers = [
"https://reefwire.ai",
"owner@example.com",
"setup-session",
"molty",
"gpt-5.6-terra",
"REEF_GUARD_OPENAI_KEY",
"reef-v1",
];
const selectAnswers = ["code-only", "openai"];
const prompter = {
note: vi.fn(async () => undefined),
text: vi.fn(async () => textAnswers.shift() ?? ""),
select: vi.fn(async () => selectAnswers.shift()),
};
await reefSetupWizard.configureInteractive({
cfg: {},
prompter: prompter as never,
options: { beforePersistentEffect },
});
expect(beforePersistentEffect).toHaveBeenCalledTimes(1);
await expect(loadKeys(runtime)).resolves.toBeDefined();
});
});
+10 -1
View File
@@ -69,7 +69,15 @@ export const reefSetupWizard = {
};
},
configure: async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }),
configureInteractive: async ({ cfg, prompter }: { cfg: OpenClawConfig; prompter: Prompt }) => {
configureInteractive: async ({
cfg,
prompter,
options,
}: {
cfg: OpenClawConfig;
prompter: Prompt;
options?: { beforePersistentEffect?: () => Promise<void> };
}) => {
const rawRelayUrl = await prompter.text({
message: "Reef relay origin URL",
initialValue: "https://reefwire.ai",
@@ -125,6 +133,7 @@ export const reefSetupWizard = {
);
}
const configuredStateDir = (cfg.channels?.reef as { stateDir?: unknown } | undefined)?.stateDir;
await options?.beforePersistentEffect?.();
const keys = await loadKeys(runtime).catch(async (error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
+34
View File
@@ -478,6 +478,40 @@ describe("channelsAddCommand", () => {
expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("No channel changes made.");
});
it("persists an accepted plugin install after setup returns to an empty selection", async () => {
const config: OpenClawConfig = { channels: {} };
const installedConfig: OpenClawConfig = {
...config,
plugins: {
entries: { "external-chat": { enabled: true } },
installs: {
"external-chat": {
source: "npm",
spec: "@vendor/external-chat@1.0.0",
},
},
},
};
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
sourceConfig: config,
config,
});
channelWizardMocks.setupChannels.mockResolvedValueOnce(installedConfig);
await channelsAddCommand({}, runtime, { hasFlags: false });
expect(
pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls,
).toHaveBeenCalledWith(expect.objectContaining({ nextConfig: installedConfig }));
expect(
pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls,
).toHaveBeenCalledOnce();
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(installedConfig);
expect(channelWizardMocks.prompter.confirm).not.toHaveBeenCalled();
expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("Channels updated.");
});
it("preselects an installable catalog channel in guided setup", async () => {
const config: OpenClawConfig = { channels: {} };
configMocks.readConfigFileSnapshot.mockResolvedValue({
+30 -22
View File
@@ -102,7 +102,36 @@ export async function runChannelsAddWizardFlow(params: ChannelsAddWizardFlowPara
resolvedPlugins.set(channel, plugin);
},
});
const commitWizardConfig = async (config: OpenClawConfig) => {
await params.beforePersistentEffect?.();
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: config,
...(baseHash !== undefined ? { baseHash } : {}),
});
if (committed.movedInstallRecords) {
await refreshPluginRegistryAfterConfigMutation({
config: committed.config,
reason: "source-changed",
installRecords: committed.installRecords,
logger: { warn: (message) => runtime.log(message) },
});
}
await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: committed.config,
runtime,
...(params.beforePersistentEffect
? { beforePersistentEffect: params.beforePersistentEffect }
: {}),
});
return committed.config;
};
if (selection.length === 0) {
if (nextConfig !== cfg) {
await commitWizardConfig(nextConfig);
await prompter.outro("Channels updated.");
return;
}
await prompter.outro("No channel changes made.");
return;
}
@@ -198,28 +227,7 @@ export async function runChannelsAddWizardFlow(params: ChannelsAddWizardFlowPara
}
}
await params.beforePersistentEffect?.();
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig,
...(baseHash !== undefined ? { baseHash } : {}),
});
const writtenConfig = committed.config;
if (committed.movedInstallRecords) {
await refreshPluginRegistryAfterConfigMutation({
config: writtenConfig,
reason: "source-changed",
installRecords: committed.installRecords,
logger: { warn: (message) => runtime.log(message) },
});
}
await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: writtenConfig,
runtime,
...(params.beforePersistentEffect
? { beforePersistentEffect: params.beforePersistentEffect }
: {}),
});
await commitWizardConfig(nextConfig);
params.onConfigured?.(
selection.map((channel) => ({
channel,
+51
View File
@@ -0,0 +1,51 @@
import type { SetupChannelsOptions } from "../channels/plugins/setup-wizard-types.js";
import { ensureChannelSetupPluginInstalled } from "../commands/channel-setup/plugin-install.js";
import { runWizardWithPromptNavigationScope } from "../wizard/navigation-prompter.js";
import type { WizardPrompter } from "../wizard/prompts.js";
type ScopedChannelStepParams<T> = {
prompter: WizardPrompter;
options?: SetupChannelsOptions;
runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise<T>;
onPersistentEffect?: () => void;
};
export async function runScopedChannelStep<T>(params: ScopedChannelStepParams<T>) {
return await runWizardWithPromptNavigationScope(params.prompter, async (scopedPrompter) =>
params.runner(scopedPrompter, {
...params.options,
beforePersistentEffect: async () => {
params.onPersistentEffect?.();
scopedPrompter.disableBackNavigation?.();
await params.options?.beforePersistentEffect?.();
},
}),
);
}
type ChannelPluginInstallParams = Omit<
Parameters<typeof ensureChannelSetupPluginInstalled>[0],
"prompter" | "beforePersistentEffect"
>;
export async function ensureChannelSetupPluginInstalledWithNavigation(params: {
install: ChannelPluginInstallParams;
prompter: WizardPrompter;
options?: SetupChannelsOptions;
}) {
let persistentEffectStarted = false;
const outcome = await runScopedChannelStep({
prompter: params.prompter,
options: params.options,
runner: async (scopedPrompter, scopedOptions) =>
await ensureChannelSetupPluginInstalled({
...params.install,
prompter: scopedPrompter,
beforePersistentEffect: scopedOptions.beforePersistentEffect,
}),
onPersistentEffect: () => {
persistentEffectStarted = true;
},
});
return { ...outcome, persistentEffectStarted };
}
+605 -4
View File
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { WizardCancelledError, WizardNavigationError } from "../wizard/prompts.js";
import {
makeCatalogEntry,
makeChannelSetupEntries,
@@ -217,6 +218,7 @@ describe("setupChannels workspace shadow exclusion", () => {
origin: "bundled",
},
]);
getTrustedChannelPluginCatalogEntry.mockReturnValue(undefined);
getChannelSetupPlugin.mockReturnValue(undefined);
listActiveChannelSetupPlugins.mockReturnValue([]);
listChannelSetupPlugins.mockReturnValue([]);
@@ -703,6 +705,609 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(configure).toHaveBeenCalledTimes(1);
});
it("returns an external install confirmation to channel selection before installing", async () => {
const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
pluginId: "@vendor/external-chat-plugin",
});
resolveChannelSetupEntries.mockReturnValue(
externalChatSetupEntries({
installableCatalogEntries: [installableCatalogEntry],
installableCatalogById: new Map([["external-chat", installableCatalogEntry]]),
}),
);
ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => {
await prompter.confirm({
message: "Install External Chat?",
initialValue: true,
});
return {
cfg,
installed: true,
pluginId: "@vendor/external-chat-plugin",
status: "installed",
};
});
const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const confirm = vi.fn(async () => {
throw new WizardNavigationError("back");
});
const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig;
const result = await setupChannels(
cfg,
{} as never,
{
confirm,
note: vi.fn(async () => undefined),
select,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "Install External Chat?",
navigation: { canGoBack: true, canGoForward: false },
}),
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
expect(result).toEqual(cfg);
});
it.each(["installed-catalog", "trusted-catalog-fallback"] as const)(
"returns the %s reinstall confirmation to channel selection",
async (source) => {
const catalogEntry = makeCatalogEntry("external-chat", "External Chat", {
pluginId: "@vendor/external-chat-plugin",
install: { npmSpec: "@vendor/external-chat-plugin" },
});
resolveChannelSetupEntries.mockReturnValue(
source === "installed-catalog"
? externalChatSetupEntries({
installedCatalogEntries: [catalogEntry],
installedCatalogById: new Map([["external-chat", catalogEntry]]),
})
: externalChatSetupEntries(),
);
if (source === "trusted-catalog-fallback") {
getTrustedChannelPluginCatalogEntry.mockReturnValue(catalogEntry);
}
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry());
ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => {
await prompter.confirm({
message: "Reinstall External Chat?",
initialValue: true,
});
return {
cfg,
installed: true,
pluginId: "@vendor/external-chat-plugin",
status: "installed",
};
});
const confirm = vi.fn(async () => {
throw new WizardNavigationError("back");
});
const cfg = { channels: { "external-chat": { token: "keep" } } } as OpenClawConfig;
const result = await setupChannels(
cfg,
{} as never,
{
confirm,
note: vi.fn(async () => undefined),
select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"),
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "Reinstall External Chat?",
navigation: { canGoBack: true, canGoForward: false },
}),
);
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" });
return {
cfg: {
channels: { "external-chat": { token: "should-not-apply" } },
} as OpenClawConfig,
accountId: "custom-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().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const text = vi.fn(async () => {
throw new WizardNavigationError("back");
});
const result = await setupChannels(
{ channels: { telegram: { botToken: "keep" } } } as OpenClawConfig,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
text,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(configureInteractive).toHaveBeenCalledTimes(1);
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Custom channel token",
navigation: { canGoBack: true, canGoForward: false },
}),
);
expect(select).toHaveBeenCalledTimes(2);
expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } });
});
it("returns declarative channel setup to channel selection when its first prompt goes back", async () => {
const configure = vi.fn(async ({ cfg, prompter }) => {
await prompter.text({ message: "Declarative channel token" });
return {
cfg: {
...cfg,
channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } },
},
accountId: "declarative-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,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const text = vi.fn(async () => {
throw new WizardNavigationError("back");
});
const result = await setupChannels(
{ channels: { telegram: { botToken: "keep" } } } as OpenClawConfig,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
text,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(configure).toHaveBeenCalledTimes(1);
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Declarative channel token",
navigation: { canGoBack: true, canGoForward: false },
}),
);
expect(select).toHaveBeenCalledTimes(2);
expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } });
});
it("returns configured channel actions to selection when their first prompt goes back", async () => {
const configureWhenConfigured = vi.fn(async ({ cfg, prompter }) => {
await prompter.select({
message: "Configured channel action",
options: [{ value: "update", label: "Update" }],
});
return {
cfg: {
...cfg,
channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } },
},
};
});
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard: {
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "external-chat",
configured: true,
statusLines: [],
})),
configure: vi.fn(),
configureWhenConfigured,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
const select = vi
.fn()
.mockResolvedValueOnce("external-chat")
.mockRejectedValueOnce(new WizardNavigationError("back"))
.mockResolvedValueOnce("__done__");
const cfg = {
channels: { "external-chat": { token: "keep" } },
} as OpenClawConfig;
const result = await setupChannels(
cfg,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(select).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
message: "Configured channel action",
navigation: { canGoBack: true, canGoForward: false },
}),
);
expect(result).toEqual(cfg);
});
it("rolls back reversible plugin enablement when channel setup goes back", async () => {
const configure = vi.fn(async ({ cfg, prompter }) => {
await prompter.text({ message: "ClickClack token" });
return { cfg };
});
const clickClackPlugin = makeSetupPlugin({
id: "clickclack",
label: "ClickClack",
setupWizard: {
channel: "clickclack",
getStatus: vi.fn(async () => ({
channel: "clickclack",
configured: false,
statusLines: [],
})),
configure,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(
makeChannelSetupEntries({
entries: [{ id: "clickclack", meta: makeMeta("clickclack", "ClickClack") }],
}),
);
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(
makePluginRegistry({
channelSetups: [
{
pluginId: "clickclack",
source: "bundled",
enabled: true,
plugin: clickClackPlugin,
},
],
}),
);
const select = vi.fn().mockResolvedValueOnce("clickclack").mockResolvedValueOnce("__done__");
const text = vi.fn(async () => {
throw new WizardNavigationError("back");
});
const cfg = { plugins: { allow: ["memory-core"] } } as OpenClawConfig;
const result = await setupChannels(
cfg,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
text,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(result).toEqual(cfg);
expect(result.plugins?.allow).toEqual(["memory-core"]);
expect(result.plugins?.entries?.clickclack).toBeUndefined();
});
it("preserves accepted external install metadata when later channel setup goes back", async () => {
const configure = vi.fn(async ({ cfg, prompter }) => {
await prompter.text({ message: "External Chat token" });
return { cfg, 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,
} as ChannelSetupPlugin["setupWizard"],
});
const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
pluginId: "@vendor/external-chat-plugin",
});
resolveChannelSetupEntries.mockReturnValue(
externalChatSetupEntries({
installableCatalogEntries: [installableCatalogEntry],
installableCatalogById: new Map([["external-chat", installableCatalogEntry]]),
}),
);
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(
makePluginRegistry({
channelSetups: [
{
pluginId: "@vendor/external-chat-plugin",
source: "global",
enabled: true,
plugin: externalChatPlugin,
},
],
}),
);
ensureChannelSetupPluginInstalled.mockImplementationOnce(async (params) => {
await params.beforePersistentEffect?.();
return {
cfg: {
...params.cfg,
plugins: {
...params.cfg.plugins,
entries: {
...params.cfg.plugins?.entries,
"@vendor/external-chat-plugin": { enabled: true },
},
installs: {
...params.cfg.plugins?.installs,
"@vendor/external-chat-plugin": {
source: "npm",
spec: "@vendor/external-chat-plugin@1.0.0",
},
},
},
},
installed: true,
pluginId: "@vendor/external-chat-plugin",
status: "installed",
};
});
const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const beforePersistentEffect = vi.fn(async () => undefined);
const result = await setupChannels(
{ channels: { telegram: { botToken: "keep" } } } as OpenClawConfig,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
text: vi.fn(async () => {
throw new WizardNavigationError("back");
}),
} as never,
{
beforePersistentEffect,
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(result.channels).toEqual({ telegram: { botToken: "keep" } });
expect(result.plugins?.entries?.["@vendor/external-chat-plugin"]?.enabled).toBe(true);
expect(result.plugins?.installs?.["@vendor/external-chat-plugin"]?.spec).toBe(
"@vendor/external-chat-plugin@1.0.0",
);
expect(beforePersistentEffect).toHaveBeenCalledTimes(1);
});
it("replays earlier prompts when Back is used inside channel setup", async () => {
const configure = vi.fn(async ({ cfg, prompter }) => {
const username = await prompter.text({ message: "Username" });
const token = await prompter.text({ message: "Token" });
return {
cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { username, token } } },
};
});
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard: {
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "external-chat",
configured: false,
statusLines: [],
})),
configure,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
const text = vi
.fn()
.mockResolvedValueOnce("old-name")
.mockRejectedValueOnce(new WizardNavigationError("back"))
.mockResolvedValueOnce("new-name")
.mockResolvedValueOnce("secret-token");
const result = await setupChannels(
{} as OpenClawConfig,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"),
text,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
);
expect(configure).toHaveBeenCalledTimes(2);
expect(result.channels?.["external-chat"]).toEqual({
username: "new-name",
token: "secret-token",
});
expect(mockCall(text, 2)[0]).toEqual(
expect.objectContaining({
initialValue: "old-name",
navigation: { canGoBack: true, canGoForward: true },
}),
);
});
it("disables Back after a channel declares a persistent effect boundary", async () => {
const configure = vi.fn(async ({ cfg, prompter, options }) => {
await prompter.text({ message: "Before effect" });
await options.beforePersistentEffect?.();
await prompter.text({ message: "After effect" });
return { cfg };
});
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard: {
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "external-chat",
configured: false,
statusLines: [],
})),
configure,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
const navigationError = new WizardNavigationError("back");
const text = vi.fn().mockResolvedValueOnce("ready").mockRejectedValueOnce(navigationError);
const beforePersistentEffect = vi.fn(async () => undefined);
await expect(
setupChannels(
{} as OpenClawConfig,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select: vi.fn().mockResolvedValueOnce("external-chat"),
text,
} as never,
{
beforePersistentEffect,
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
),
).rejects.toBe(navigationError);
expect(beforePersistentEffect).toHaveBeenCalledTimes(1);
expect(mockCall(text, 1)[0]).toEqual(
expect.objectContaining({
navigation: { canGoBack: false, canGoForward: false },
}),
);
});
it("propagates Ctrl-C cancellation from channel setup", async () => {
const cancelled = new WizardCancelledError();
const configure = vi.fn(async ({ prompter }) => {
await prompter.text({ message: "Token" });
return { cfg: {} as OpenClawConfig };
});
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard: {
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "external-chat",
configured: false,
statusLines: [],
})),
configure,
} as ChannelSetupPlugin["setupWizard"],
});
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
await expect(
setupChannels(
{} as OpenClawConfig,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select: vi.fn().mockResolvedValueOnce("external-chat"),
text: vi.fn(async () => {
throw cancelled;
}),
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
skipDmPolicyPrompt: true,
},
),
).rejects.toBe(cancelled);
});
it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => {
const setupWizard = {
channel: "external-chat",
@@ -1140,10 +1745,6 @@ describe("setupChannels workspace shadow exclusion", () => {
).rejects.toBe(guardError);
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1);
expect(
callArg<Parameters<EnsureChannelSetupPluginInstalled>[0]>(ensureChannelSetupPluginInstalled)
.beforePersistentEffect,
).toBe(beforePersistentEffect);
expect(beforePersistentEffect).toHaveBeenCalledTimes(1);
});
+123 -54
View File
@@ -17,10 +17,7 @@ import {
resolveChannelSetupEntries,
shouldShowChannelInSetup,
} from "../commands/channel-setup/discovery.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
} from "../commands/channel-setup/plugin-install.js";
import { loadChannelSetupPluginRegistrySnapshotForChannel } from "../commands/channel-setup/plugin-install.js";
import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js";
import {
getTrustedChannelPluginCatalogEntry,
@@ -37,10 +34,14 @@ import type { RuntimeEnv } from "../runtime.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
ensureChannelSetupPluginInstalledWithNavigation as runPluginInstallWithNavigation,
runScopedChannelStep as runNavigationScope,
} from "./channel-setup-navigation.js";
import {
formatAccountLabel,
maybeConfigureDmPolicies,
promptConfiguredAction,
promptRemovalAccountId,
formatAccountLabel,
} from "./channel-setup.prompts.js";
import {
collectChannelStatus,
@@ -126,7 +127,6 @@ export async function setupChannels(
const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => {
const channel = plugin.id;
scopedPluginsById.set(channel, plugin);
options?.onResolvedPlugin?.(channel, plugin);
};
const activePluginsById = new Map<ChannelChoice, ChannelSetupPlugin>();
const rememberActivePlugin = (plugin: ChannelSetupPlugin) => {
@@ -424,6 +424,10 @@ export async function setupChannels(
const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => {
const previousCfg = next;
next = result.cfg;
const plugin = getVisibleChannelPlugin(channel);
if (plugin) {
options?.onResolvedPlugin?.(channel, plugin);
}
const adapter = getVisibleSetupFlowAdapter(channel);
if (result.accountId) {
recordAccount(channel, result.accountId);
@@ -451,8 +455,22 @@ export async function setupChannels(
await applySetupResult(channel, result);
return true;
};
const runScopedChannelStep = async <T>(
runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise<T>,
onPersistentEffect?: () => void,
) =>
await runNavigationScope({
prompter,
options,
runner,
...(onPersistentEffect ? { onPersistentEffect } : {}),
});
const configureChannel = async (channel: ChannelChoice) => {
const configureChannel = async (
channel: ChannelChoice,
setupPrompter: WizardPrompter,
setupOptions: SetupChannelsOptions,
) => {
if (scopedPluginsById.has(channel)) {
await loadScopedChannelPlugin(channel, undefined, {
forceReload: true,
@@ -473,8 +491,8 @@ export async function setupChannels(
const result = await adapter.configure({
cfg: next,
runtime,
prompter,
options,
prompter: setupPrompter,
options: setupOptions,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
@@ -482,50 +500,52 @@ export async function setupChannels(
await applySetupResult(channel, result);
};
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
const handleConfiguredChannel = async (
channel: ChannelChoice,
label: string,
setupPrompter: WizardPrompter,
setupOptions: SetupChannelsOptions,
) => {
const plugin = getVisibleChannelPlugin(channel);
const adapter = getVisibleSetupFlowAdapter(channel);
if (adapter?.configureWhenConfigured) {
const custom = await adapter.configureWhenConfigured({
cfg: next,
runtime,
prompter,
options,
prompter: setupPrompter,
options: setupOptions,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
configured: true,
label,
});
if (!(await applyCustomSetupResult(channel, custom))) {
return;
}
await applyCustomSetupResult(channel, custom);
return;
}
const supportsDisable = Boolean(
options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
setupOptions.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
);
const supportsDelete = Boolean(options?.allowDisable && plugin?.config.deleteAccount);
const supportsDelete = Boolean(setupOptions.allowDisable && plugin?.config.deleteAccount);
const action = await promptConfiguredAction({
prompter,
prompter: setupPrompter,
label,
supportsDisable,
supportsDelete,
});
if (action === "skip") {
return;
}
if (action === "update") {
await configureChannel(channel);
await configureChannel(channel, setupPrompter, setupOptions);
return;
}
if (!options?.allowDisable) {
if (!setupOptions.allowDisable) {
return;
}
if (action === "delete" && !supportsDelete) {
await prompter.note(
await setupPrompter.note(
t("wizard.channels.configuredDeleteUnsupported", { label }),
t("wizard.channels.removeTitle"),
);
@@ -539,7 +559,7 @@ export async function setupChannels(
const accountId = shouldPromptAccount
? await promptRemovalAccountId({
cfg: next,
prompter,
prompter: setupPrompter,
label,
channel,
plugin,
@@ -551,7 +571,7 @@ export async function setupChannels(
const accountLabel = formatAccountLabel(resolvedAccountId);
if (action === "delete") {
const confirmed = await prompter.confirm({
const confirmed = await setupPrompter.confirm({
message: t("wizard.channels.deleteAccount", { label, account: accountLabel }),
initialValue: false,
});
@@ -577,9 +597,29 @@ export async function setupChannels(
await refreshStatus(channel);
};
const ensureChannelSetupPluginInstalledWithNavigation = async (
install: Parameters<typeof runPluginInstallWithNavigation>[0]["install"],
) => await runPluginInstallWithNavigation({ install, prompter, options });
const handleChannelChoice = async (
channel: ChannelChoice,
): Promise<"done" | "retry_selection"> => {
const cfgBeforeChoice = next;
let cfgOnBack = cfgBeforeChoice;
const scopedPluginsBeforeChoice = new Map(scopedPluginsById);
const statusBeforeChoice = new Map(statusByChannel);
const returnToSelection = (): "retry_selection" => {
next = cfgOnBack;
scopedPluginsById.clear();
for (const [id, plugin] of scopedPluginsBeforeChoice) {
scopedPluginsById.set(id, plugin);
}
statusByChannel.clear();
for (const [id, status] of statusBeforeChoice) {
statusByChannel.set(id, status);
}
return "retry_selection";
};
const { catalogById, installedCatalogById } = getChannelEntries();
const catalogEntry = catalogById.get(channel);
const installedCatalogEntry = installedCatalogById.get(channel);
@@ -598,21 +638,24 @@ export async function setupChannels(
}
if (catalogEntry) {
const workspaceDir = resolveWorkspaceDir();
const result = await ensureChannelSetupPluginInstalled({
const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation({
cfg: next,
entry: catalogEntry,
prompter,
runtime,
workspaceDir,
autoConfirmSingleSource: true,
...(options?.beforePersistentEffect
? { beforePersistentEffect: options.beforePersistentEffect }
: {}),
});
if (installOutcome.status === "back") {
return returnToSelection();
}
const result = installOutcome.value;
next = result.cfg;
if (!result.installed) {
return "retry_selection";
}
if (installOutcome.persistentEffectStarted) {
cfgOnBack = next;
}
await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
await refreshStatus(channel);
} else if (installedCatalogEntry) {
@@ -638,21 +681,24 @@ export async function setupChannels(
return "done";
}
const workspaceDir = resolveWorkspaceDir();
const result = await ensureChannelSetupPluginInstalled({
const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation({
cfg: next,
entry: installedCatalogEntry,
prompter,
runtime,
workspaceDir,
autoConfirmSingleSource: true,
...(options?.beforePersistentEffect
? { beforePersistentEffect: options.beforePersistentEffect }
: {}),
});
if (installOutcome.status === "back") {
return returnToSelection();
}
const result = installOutcome.value;
next = result.cfg;
if (!result.installed) {
return "retry_selection";
}
if (installOutcome.persistentEffectStarted) {
cfgOnBack = next;
}
plugin = await loadScopedChannelPlugin(
channel,
result.pluginId ?? installedCatalogEntry.pluginId,
@@ -696,21 +742,24 @@ export async function setupChannels(
return "done";
}
const workspaceDir = resolveWorkspaceDir();
const result = await ensureChannelSetupPluginInstalled({
const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation({
cfg: next,
entry: fallbackCatalogEntry,
prompter,
runtime,
workspaceDir,
autoConfirmSingleSource: true,
...(options?.beforePersistentEffect
? { beforePersistentEffect: options.beforePersistentEffect }
: {}),
});
if (installOutcome.status === "back") {
return returnToSelection();
}
const result = installOutcome.value;
next = result.cfg;
if (!result.installed) {
return "retry_selection";
}
if (installOutcome.persistentEffectStarted) {
cfgOnBack = next;
}
await loadScopedChannelPlugin(channel, result.pluginId ?? fallbackCatalogEntry.pluginId);
await refreshStatus(channel);
} else {
@@ -726,28 +775,48 @@ export async function setupChannels(
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
const status = statusByChannel.get(channel);
const configured = status?.configured ?? false;
if (adapter?.configureInteractive) {
const custom = await adapter.configureInteractive({
cfg: next,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
configured,
label,
});
const configureInteractive = adapter?.configureInteractive;
if (configureInteractive) {
const outcome = await runScopedChannelStep(
async (scopedPrompter, scopedOptions) =>
await configureInteractive({
cfg: next,
runtime,
prompter: scopedPrompter,
options: scopedOptions,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
configured,
label,
}),
);
if (outcome.status === "back") {
return returnToSelection();
}
const custom = outcome.value;
if (!(await applyCustomSetupResult(channel, custom))) {
return "done";
}
return "done";
}
if (configured) {
await handleConfiguredChannel(channel, label);
const outcome = await runScopedChannelStep(
async (scopedPrompter, scopedOptions) =>
await handleConfiguredChannel(channel, label, scopedPrompter, scopedOptions),
);
if (outcome.status === "back") {
return returnToSelection();
}
return "done";
}
await configureChannel(channel);
const outcome = await runScopedChannelStep(
async (scopedPrompter, scopedOptions) =>
await configureChannel(channel, scopedPrompter, scopedOptions),
);
if (outcome.status === "back") {
return returnToSelection();
}
return "done";
};
+68 -1
View File
@@ -1,7 +1,10 @@
// Prompt navigation tests cover setup history replay and forward acceptance.
import { describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { runWizardWithPromptNavigation } from "./navigation-prompter.js";
import {
runWizardWithPromptNavigation,
runWizardWithPromptNavigationScope,
} from "./navigation-prompter.js";
import type { WizardPrompter, WizardSelectParams } from "./prompts.js";
import { WizardNavigationError } from "./prompts.js";
@@ -188,3 +191,67 @@ describe("runWizardWithPromptNavigation", () => {
});
});
});
describe("runWizardWithPromptNavigationScope", () => {
it("returns a typed back outcome from the first prompt", async () => {
const select = vi.fn(async () => {
throw new WizardNavigationError("back");
}) as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({ select });
const outcome = await runWizardWithPromptNavigationScope(prompter, async (scopedPrompter) => {
await scopedPrompter.select({
message: "First channel prompt",
options: selectOptions(["continue"]),
});
return "completed";
});
expect(outcome).toEqual({ status: "back" });
expect(selectParamsAt(select as ReturnType<typeof vi.fn>, 0).navigation).toEqual({
canGoBack: true,
canGoForward: false,
});
});
it("creates a fresh scope inside an outer wizard whose back history is disabled", async () => {
const select = vi.fn(async () => {
throw new WizardNavigationError("back");
}) as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({ select });
let outcome: Awaited<ReturnType<typeof runWizardWithPromptNavigationScope<string>>> | undefined;
await runWizardWithPromptNavigation(prompter, async (outerPrompter) => {
outerPrompter.disableBackNavigation?.();
outcome = await runWizardWithPromptNavigationScope(outerPrompter, async (scopedPrompter) => {
await scopedPrompter.select({
message: "First channel prompt",
options: selectOptions(["continue"]),
});
return "completed";
});
});
expect(outcome).toEqual({ status: "back" });
expect(selectParamsAt(select as ReturnType<typeof vi.fn>, 0).navigation).toEqual({
canGoBack: true,
canGoForward: false,
});
});
it("preserves optional client actions inside a navigation scope", async () => {
const deviceCode = vi.fn(async () => undefined);
const openUrl = vi.fn(async () => undefined);
const prompter = createWizardPrompter({ deviceCode, openUrl });
const outcome = await runWizardWithPromptNavigationScope(prompter, async (scopedPrompter) => {
await scopedPrompter.deviceCode?.({ title: "Link device", code: "ABCD" });
await scopedPrompter.openUrl?.("https://example.com/link");
return "completed";
});
expect(outcome).toEqual({ status: "completed", value: "completed" });
expect(deviceCode).toHaveBeenCalledWith({ title: "Link device", code: "ABCD" });
expect(openUrl).toHaveBeenCalledWith("https://example.com/link");
});
});
+80 -2
View File
@@ -27,6 +27,18 @@ type PromptRequest<T, Params> = {
call: (params: Params) => Promise<T>;
};
const basePrompterByNavigationPrompter = new WeakMap<WizardPrompter, WizardPrompter>();
function unwrapNavigationPrompter(prompter: WizardPrompter): WizardPrompter {
let current = prompter;
let base = basePrompterByNavigationPrompter.get(current);
while (base) {
current = base;
base = basePrompterByNavigationPrompter.get(current);
}
return current;
}
function inertProgress(): WizardProgress {
return {
update: () => {},
@@ -75,10 +87,16 @@ class WizardPromptNavigator {
private cursor = 0;
private targetIndex: number | undefined;
private restartRequested = false;
private boundaryBackRequested = false;
private backNavigationDisabled = false;
private records: Array<PromptRecord | undefined> = [];
constructor(private readonly base: WizardPrompter) {}
constructor(
private readonly base: WizardPrompter,
private readonly options: { allowBackFromStart?: boolean } = {},
) {
basePrompterByNavigationPrompter.set(this.prompter, unwrapNavigationPrompter(base));
}
readonly prompter: WizardPrompter = {
intro: async (title) => {
@@ -96,6 +114,15 @@ class WizardPromptNavigator {
await this.base.note(message, title);
}
},
...(this.base.deviceCode
? {
deviceCode: async (params) => {
if (!this.shouldSuppressOutput()) {
await this.base.deviceCode?.(params);
}
},
}
: {}),
plain: async (message) => {
if (!this.shouldSuppressOutput()) {
await this.base.plain?.(message);
@@ -151,6 +178,15 @@ class WizardPromptNavigator {
}),
progress: (label) =>
this.shouldSuppressOutput() ? inertProgress() : this.base.progress(label),
...(this.base.openUrl
? {
openUrl: async (url) => {
if (!this.shouldSuppressOutput()) {
await this.base.openUrl?.(url);
}
},
}
: {}),
disableBackNavigation: () => {
this.backNavigationDisabled = true;
this.targetIndex = undefined;
@@ -160,12 +196,17 @@ class WizardPromptNavigator {
beginPass() {
this.cursor = 0;
this.restartRequested = false;
this.boundaryBackRequested = false;
}
hasRestartRequest(): boolean {
return this.restartRequested;
}
hasBoundaryBackRequest(): boolean {
return this.boundaryBackRequested;
}
private shouldSuppressOutput(): boolean {
return this.targetIndex !== undefined && this.cursor <= this.targetIndex;
}
@@ -220,7 +261,8 @@ class WizardPromptNavigator {
? request.withInitial(request.params, record.answer)
: request.params;
const paramsWithNavigation = applyNavigation(paramsWithInitial, {
canGoBack: !this.backNavigationDisabled && index > 0,
canGoBack:
!this.backNavigationDisabled && (index > 0 || this.options.allowBackFromStart === true),
canGoForward: record !== undefined,
});
@@ -239,6 +281,14 @@ class WizardPromptNavigator {
this.targetIndex = undefined;
return record.answer as T;
}
if (
error.direction === "back" &&
!this.backNavigationDisabled &&
index === 0 &&
this.options.allowBackFromStart === true
) {
this.boundaryBackRequested = true;
}
if (error.direction === "back" && !this.backNavigationDisabled && index > 0) {
this.targetIndex = index - 1;
this.restartRequested = true;
@@ -249,6 +299,34 @@ class WizardPromptNavigator {
}
}
type WizardPromptNavigationScopeOutcome<T> = { status: "completed"; value: T } | { status: "back" };
export async function runWizardWithPromptNavigationScope<T>(
basePrompter: WizardPrompter,
runner: (prompter: WizardPrompter) => Promise<T>,
): Promise<WizardPromptNavigationScopeOutcome<T>> {
const navigator = new WizardPromptNavigator(unwrapNavigationPrompter(basePrompter), {
allowBackFromStart: true,
});
while (true) {
navigator.beginPass();
try {
return { status: "completed", value: await runner(navigator.prompter) };
} catch (error) {
if (error instanceof WizardNavigationError && error.direction === "back") {
if (navigator.hasRestartRequest()) {
continue;
}
if (navigator.hasBoundaryBackRequest()) {
return { status: "back" };
}
}
throw error;
}
}
}
export async function runWizardWithPromptNavigation(
basePrompter: WizardPrompter,
runner: (prompter: WizardPrompter) => Promise<void>,