diff --git a/src/commands/configure.channels.test.ts b/src/commands/configure.channels.test.ts index fefdaf1caf7..2fce7d59635 100644 --- a/src/commands/configure.channels.test.ts +++ b/src/commands/configure.channels.test.ts @@ -25,6 +25,9 @@ vi.mock("./configure.shared.js", () => ({ import { removeChannelConfigWizard } from "./configure.channels.js"; +const channelChoice = (id: string) => ({ kind: "channel" as const, id }); +const doneChoice = { kind: "done" as const }; + describe("removeChannelConfigWizard", () => { beforeEach(() => { vi.resetAllMocks(); @@ -36,7 +39,7 @@ describe("removeChannelConfigWizard", () => { }); it("lists configured channels from openclaw.json even when no plugins are loaded", async () => { - select.mockResolvedValue("done"); + select.mockResolvedValue(doneChoice); await removeChannelConfigWizard( { @@ -55,17 +58,17 @@ describe("removeChannelConfigWizard", () => { expect.objectContaining({ message: "Remove which channel config?", options: [ - expect.objectContaining({ value: "telegram", label: "Telegram" }), - expect.objectContaining({ value: "twitch", label: "Twitch" }), - expect.objectContaining({ value: "unknown", label: "unknown" }), - { value: "done", label: "Done" }, + expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram" }), + expect.objectContaining({ value: channelChoice("twitch"), label: "Twitch" }), + expect.objectContaining({ value: channelChoice("unknown"), label: "unknown" }), + { value: doneChoice, label: "Done" }, ], }), ); }); it("deletes the selected channel block from openclaw.json", async () => { - select.mockResolvedValueOnce("telegram").mockResolvedValueOnce("done"); + select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice); const next = await removeChannelConfigWizard( { @@ -89,8 +92,33 @@ describe("removeChannelConfigWizard", () => { ); }); + it("deletes a real channel block named done", async () => { + select.mockResolvedValueOnce(channelChoice("done")).mockResolvedValueOnce(doneChoice); + + const next = await removeChannelConfigWizard( + { + channels: { + done: { token: "secret" }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete done configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(next.channels).toEqual({ telegram: { token: "secret" } }); + expect(note).toHaveBeenCalledWith( + "done removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); + it("preserves channel-wide defaults when deleting the last channel block", async () => { - select.mockResolvedValueOnce("telegram").mockResolvedValueOnce("done"); + select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice); const next = await removeChannelConfigWizard( { @@ -110,7 +138,7 @@ describe("removeChannelConfigWizard", () => { }); it("does not list blocked object keys as removable channels", async () => { - select.mockResolvedValue("done"); + select.mockResolvedValue(doneChoice); await removeChannelConfigWizard( { @@ -127,8 +155,8 @@ describe("removeChannelConfigWizard", () => { expect(select).toHaveBeenCalledWith( expect.objectContaining({ options: [ - expect.objectContaining({ value: "telegram", label: "Telegram" }), - { value: "done", label: "Done" }, + expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram" }), + { value: doneChoice, label: "Done" }, ], }), ); @@ -139,7 +167,7 @@ describe("removeChannelConfigWizard", () => { { id: "telegram", label: "Telegram\u001B[31m\nBot\u0007" }, { id: "twitch", label: "Twitch" }, ]); - select.mockResolvedValueOnce("telegram").mockResolvedValueOnce("done"); + select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice); await removeChannelConfigWizard( { @@ -153,7 +181,7 @@ describe("removeChannelConfigWizard", () => { expect(select).toHaveBeenCalledWith( expect.objectContaining({ options: expect.arrayContaining([ - expect.objectContaining({ value: "telegram", label: "Telegram\\nBot" }), + expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram\\nBot" }), ]), }), ); @@ -170,7 +198,7 @@ describe("removeChannelConfigWizard", () => { it("sanitizes unknown channel keys before rendering prompts", async () => { const unsafeChannel = "bad\u001B[31m\nkey\u0007"; - select.mockResolvedValueOnce(unsafeChannel).mockResolvedValueOnce("done"); + select.mockResolvedValueOnce(channelChoice(unsafeChannel)).mockResolvedValueOnce(doneChoice); const next = await removeChannelConfigWizard( { @@ -185,7 +213,7 @@ describe("removeChannelConfigWizard", () => { expect(select).toHaveBeenCalledWith( expect.objectContaining({ options: expect.arrayContaining([ - expect.objectContaining({ value: unsafeChannel, label: "bad\\nkey" }), + expect.objectContaining({ value: channelChoice(unsafeChannel), label: "bad\\nkey" }), ]), }), ); @@ -203,7 +231,7 @@ describe("removeChannelConfigWizard", () => { it("uses a placeholder when an unknown channel key sanitizes to empty", async () => { const unsafeChannel = "\u001B[31m\u0007"; - select.mockResolvedValueOnce(unsafeChannel).mockResolvedValueOnce("done"); + select.mockResolvedValueOnce(channelChoice(unsafeChannel)).mockResolvedValueOnce(doneChoice); const next = await removeChannelConfigWizard( { @@ -218,7 +246,10 @@ describe("removeChannelConfigWizard", () => { expect(select).toHaveBeenCalledWith( expect.objectContaining({ options: expect.arrayContaining([ - expect.objectContaining({ value: unsafeChannel, label: "" }), + expect.objectContaining({ + value: channelChoice(unsafeChannel), + label: "", + }), ]), }), ); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index e4452978578..69321f09f95 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -15,7 +15,10 @@ type ConfiguredChannelRemovalChoice = { label: string; }; +type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" }; + const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); +const DONE_VALUE: ChannelRemovalSelectValue = { kind: "done" }; function listConfiguredChannelRemovalChoices( cfg: OpenClawConfig, @@ -74,25 +77,26 @@ export async function removeChannelConfigWizard( return next; } - const channel = guardCancel( - await select({ + const choice = guardCancel( + await select({ message: "Remove which channel config?", options: [ ...configured.map((meta) => ({ - value: meta.id, + value: { kind: "channel" as const, id: meta.id }, label: meta.label, hint: "Deletes tokens + settings from config (credentials stay on disk)", })), - { value: "done", label: "Done" }, + { value: DONE_VALUE, label: "Done" }, ], }), runtime, ); - if (channel === "done") { + if (choice.kind === "done") { return next; } + const channel = choice.id; const label = configured.find((entry) => entry.id === channel)?.label ?? channel; const confirmed = guardCancel( await confirm({