diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 2467c86c288..d0df8e75ca6 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -424,6 +424,22 @@ describe("reconcileReusedSandboxMessaging", () => { expect(result.selectedChannels).toEqual(["whatsapp"]); }); + it("keeps a lifecycle-selected channel in a reused sandbox selection (#9283)", () => { + const plan = { + ...discordPlan(hashCredential("previous-discord-token") ?? ""), + workflow: "add-channel" as const, + }; + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = reconcileReusedSandboxMessaging( + plan, + { name: "openclaw" }, + { clearPlanEnv() {} }, + ); + + expect(result).toEqual({ plan, selectedChannels: ["discord"], changed: false }); + }); + it("removes every unsupported channel artifact from a reused plan", () => { // Keep the channel host-configured so this case stays about unsupported // artifact removal, not the #9283 unconfigured-channel selection filter. @@ -597,6 +613,30 @@ describe("reconcileSandboxMessaging plan authority", () => { expect(result).toEqual({ plan: null, selectedChannels: ["whatsapp"] }); }); + it("keeps a lifecycle-selected channel in a completed registry resume (#9283)", async () => { + const registryPlan = { + ...discordPlan(hashCredential("previous-discord-token") ?? ""), + workflow: "add-channel" as const, + }; + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ + authoritative: true, + plan: registryPlan, + }); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: true, + session: completedCheckpointSession(registryPlan), + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); + expect(result).toEqual({ plan: registryPlan, selectedChannels: ["discord"] }); + }); + it("keeps an in-sandbox QR channel in a completed registry resume (#9109)", async () => { const registryPlan = whatsappPlan(); const deps = reconcileDeps([]); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index ad6efb84acd..2b468ad1f05 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -435,15 +435,16 @@ export function reconcileReusedSandboxMessaging( const filtered = plan ? filterMessagingPlanForCurrentAgent(plan, agent) : null; const changed = !isDeepStrictEqual(filtered, recordedPlan); if (changed) deps.clearPlanEnv(); - // The reused plan records the previous selection, not the current host - // input. Report only channels the environment still configures so the - // policies handler can classify a retired channel as unconfigured and drop - // its egress preset (#9283). The plan itself stays untouched. + const selection = { + plan: filtered, + selectedChannels: getActiveChannelsFromPlan(filtered), + }; + const currentSelection = + filtered && registryPlanRecordsLifecycleSelection(filtered) + ? selection + : filterUnconfiguredHostChannelsFromSelection(selection, agent); return { - ...filterUnconfiguredHostChannelsFromSelection( - { plan: filtered, selectedChannels: getActiveChannelsFromPlan(filtered) }, - agent, - ), + ...currentSelection, changed, }; } @@ -614,6 +615,7 @@ async function selectionFromRegistryAuthority( authority.plan, false, ); + if (authority.plan && registryPlanRecordsLifecycleSelection(authority.plan)) return selection; return filterUnconfiguredHostChannelsFromSelection(selection, options.agent); } if (authority.plan) return selectionFromRegistryPlan(authority.plan, options);