diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index b6277a2ab991..0b8b602f995b 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -1,7 +1,11 @@ // Discord tests cover native command.options plugin behavior. import { ApplicationCommandType, ChannelType, InteractionContextType } from "discord-api-types/v10"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { logVerboseMock } = vi.hoisted(() => ({ logVerboseMock: vi.fn(), @@ -222,10 +226,15 @@ describe("createDiscordNativeCommand option wiring", () => { }); beforeEach(() => { + clearRuntimeConfigSnapshot(); logVerboseMock.mockReset(); loggerWarnMock.mockReset(); }); + afterEach(() => { + clearRuntimeConfigSnapshot(); + }); + it("uses autocomplete for /acp action so inline action values are accepted", async () => { const command = createNativeCommand("acp"); const action = requireOption(command, "action"); @@ -399,6 +408,78 @@ describe("createDiscordNativeCommand option wiring", () => { } }); + it("refreshes autocomplete authorization and dynamic choices between invocations", async () => { + const restoreMatchPluginCommand = nativeCommandTesting.setMatchPluginCommand((prompt) => + prompt === "/scope" ? ({ command: { name: "scope" }, args: "" } as never) : null, + ); + const sourceCfg = { + session: { dmScope: "main" }, + channels: { + discord: { + dm: { enabled: true, policy: "disabled" }, + }, + }, + } as OpenClawConfig; + const runtimeCfg = { + session: { dmScope: "per-channel-peer" }, + channels: { + discord: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + }, + }, + } as OpenClawConfig; + try { + const command = createDiscordNativeCommand({ + command: { + name: "scope", + description: "Scope", + acceptsArgs: true, + args: [ + { + name: "value", + description: "Scope value", + type: "string", + preferAutocomplete: true, + choices: ({ cfg }) => { + const dmScope = cfg?.session?.dmScope ?? "missing"; + return [{ label: dmScope, value: dmScope }]; + }, + }, + ], + }, + cfg: sourceCfg, + discordConfig: sourceCfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + const value = requireOption(command, "value"); + const autocomplete = requireAutocomplete( + value, + "scope value option did not wire autocomplete", + ); + const autocompleteParams = { + userId: "owner", + channelType: ChannelType.DM, + channelId: "dm-1", + channelName: "dm-1", + focusedValue: "", + } as const; + + const blockedRespond = await runAutocomplete(autocomplete, autocompleteParams); + expect(blockedRespond).toHaveBeenCalledWith([]); + + setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg); + const refreshedRespond = await runAutocomplete(autocomplete, autocompleteParams); + expect(refreshedRespond).toHaveBeenCalledWith([ + { name: "per-channel-peer", value: "per-channel-peer" }, + ]); + } finally { + nativeCommandTesting.setMatchPluginCommand(restoreMatchPluginCommand); + } + }); + it("returns no autocomplete choices outside the Discord allowlist when commands.useAccessGroups is false and commands.allowFrom is not configured", async () => { const command = createNativeCommand("think", { cfg: { diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts index e18c9d0b9247..069e04a13448 100644 --- a/extensions/discord/src/monitor/native-command.options.ts +++ b/extensions/discord/src/monitor/native-command.options.ts @@ -58,12 +58,13 @@ function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string { export function buildDiscordCommandOptions(params: { command: ChatCommandDefinition; cfg: OpenClawConfig; + resolveConfig?: () => OpenClawConfig; authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise; resolveChoiceContext?: ( interaction: AutocompleteInteraction, ) => Promise<{ provider?: string; model?: string } | null>; }): CommandOptions | undefined { - const { command, cfg, authorizeChoiceContext, resolveChoiceContext } = params; + const { command, cfg, resolveConfig, authorizeChoiceContext, resolveChoiceContext } = params; const commandLabel = resolveDiscordCommandLogLabel(command); const args = command.args; if (!args || args.length === 0) { @@ -115,10 +116,11 @@ export function buildDiscordCommandOptions(params: { typeof arg.choices === "function" && resolveChoiceContext ? await resolveChoiceContext(interaction) : null; + const currentCfg = resolveConfig?.() ?? cfg; const choices = resolveCommandArgChoices({ command, arg, - cfg, + cfg: currentCfg, provider: context?.provider, model: context?.model, }); diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 9f59ef4f462e..1a6d8d570afc 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -14,8 +14,12 @@ import { setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { defineThrowingDiscordChannelGetter } from "../test-support/partial-channel.js"; import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; import { @@ -415,6 +419,7 @@ describe("Discord native plugin command dispatch", () => { }); beforeEach(() => { + clearRuntimeConfigSnapshot(); vi.clearAllMocks(); clearPluginCommands(); setActivePluginRegistry(createTestRegistry()); @@ -461,6 +466,50 @@ describe("Discord native plugin command dispatch", () => { ); }); + afterEach(() => { + clearRuntimeConfigSnapshot(); + }); + + it("refreshes native command routing config between invocations", async () => { + const sourceCfg = { + ...createConfig(), + session: { dmScope: "main" }, + } as OpenClawConfig; + const runtimeCfg = { + ...sourceCfg, + session: { dmScope: "per-channel-peer" }, + } as OpenClawConfig; + const resolveRouteState = vi.fn(async (params: { cfg: OpenClawConfig }) => + createUnboundRouteState({ + sessionKey: + params.cfg.session?.dmScope === "per-channel-peer" + ? "agent:main:discord:direct:owner" + : "agent:main:main", + }), + ); + discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState( + resolveRouteState as typeof resolveDiscordNativeInteractionRouteState, + ); + const command = await createStatusCommand(sourceCfg); + + await (command as { run: (interaction: unknown) => Promise }).run( + createInteraction() as unknown, + ); + setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg); + await (command as { run: (interaction: unknown) => Promise }).run( + createInteraction() as unknown, + ); + + expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ sessionKey: "agent:main:main" }), + ); + expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sessionKey: "agent:main:discord:direct:owner" }), + ); + }); + it("executes plugin commands from the real registry through the native Discord command path", async () => { const cfg = createConfig(); const interaction = createInteraction(); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index f23eeb40a588..265ad224edba 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -15,6 +15,7 @@ import { type NativeCommandSpec, } from "openclaw/plugin-sdk/native-command-registry"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; +import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { @@ -112,13 +113,15 @@ export function createDiscordNativeCommand(params: { includeBundledChannelFallback: false, }) ?? fallbackCommandDefinition); const argDefinitions = commandDefinition.args ?? command.args; + const resolveCurrentConfig = () => getRuntimeConfigSnapshot() ?? cfg; const commandOptions = buildDiscordCommandOptions({ command: commandDefinition, cfg, + resolveConfig: resolveCurrentConfig, authorizeChoiceContext: async (interaction) => await resolveDiscordNativeAutocompleteAuthorized({ interaction, - cfg, + cfg: resolveCurrentConfig(), discordConfig, accountId, skipCommandOwnerAllowFrom: pluginCommandMatch !== null, @@ -126,7 +129,7 @@ export function createDiscordNativeCommand(params: { resolveChoiceContext: async (interaction) => resolveDiscordNativeChoiceContext({ interaction, - cfg, + cfg: resolveCurrentConfig(), accountId, threadBindings, }), @@ -215,7 +218,7 @@ async function dispatchDiscordCommandInteraction(params: { prompt, command, commandArgs, - cfg, + cfg: inputConfig, discordConfig, accountId, sessionPrefix, @@ -224,6 +227,7 @@ async function dispatchDiscordCommandInteraction(params: { responseEphemeral, suppressReplies, } = params; + const cfg = getRuntimeConfigSnapshot() ?? inputConfig; const commandName = command.nativeName ?? command.key; const respond = async (content: string, options?: { ephemeral?: boolean }) => { const ephemeral = options?.ephemeral ?? responseEphemeral; diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 7d3c418375be..65f396638f25 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -1,5 +1,10 @@ // Slack tests cover slash plugin behavior. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js"; vi.mock("./slash-commands.runtime.js", () => { @@ -260,9 +265,14 @@ const { registerSlackMonitorSlashCommands } = (await import("./slash.js")) as { const { dispatchMock } = getSlackSlashMocks(); beforeEach(() => { + clearRuntimeConfigSnapshot(); resetSlackSlashMocks(); }); +afterEach(() => { + clearRuntimeConfigSnapshot(); +}); + async function registerCommands(ctx: unknown, account: unknown, trackEvent?: () => void) { await registerSlackMonitorSlashCommands({ ctx: ctx as never, @@ -1252,7 +1262,61 @@ describe("slack slash commands access groups", () => { }); describe("slack slash command session metadata", () => { - const { deliverSlackSlashRepliesMock, recordSessionMetaFromInboundMock } = getSlackSlashMocks(); + const { deliverSlackSlashRepliesMock, recordSessionMetaFromInboundMock, resolveAgentRouteMock } = + getSlackSlashMocks(); + + it("refreshes slash routing config between invocations", async () => { + const harness = createPolicyHarness({ + channelId: "D123", + channelName: "directmessage", + resolveChannelName: async () => ({ name: "directmessage", type: "im" }), + }); + const sourceCfg = (harness.ctx as { cfg: OpenClawConfig }).cfg; + const runtimeCfg = { + ...sourceCfg, + session: { dmScope: "per-channel-peer" }, + } as OpenClawConfig; + resolveAgentRouteMock.mockImplementation((params: { cfg: OpenClawConfig }) => ({ + agentId: "main", + accountId: "acct", + sessionKey: + params.cfg.session?.dmScope === "per-channel-peer" + ? "agent:main:slack:direct:U1" + : "agent:main:main", + })); + await registerCommands(harness.ctx, harness.account); + + await runSlashHandler({ + commands: harness.commands, + command: { + channel_id: harness.channelId, + channel_name: harness.channelName, + }, + }); + setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg); + await runSlashHandler({ + commands: harness.commands, + command: { + channel_id: harness.channelId, + channel_name: harness.channelName, + }, + }); + + expect(dispatchMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ctx: expect.objectContaining({ CommandTargetSessionKey: "agent:main:main" }), + }), + ); + expect(dispatchMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ctx: expect.objectContaining({ + CommandTargetSessionKey: "agent:main:slack:direct:U1", + }), + }), + ); + }); it("calls recordSessionMetaFromInbound after dispatching a slash command", async () => { const harness = createPolicyHarness({ groupPolicy: "open" }); diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 3b980c57bfe8..c09895cf0e95 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -18,6 +18,7 @@ import { } from "openclaw/plugin-sdk/native-command-config-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { @@ -374,7 +375,7 @@ export async function registerSlackMonitorSlashCommands(params: { trackEvent?: () => void; }): Promise { const { ctx, account, trackEvent } = params; - const cfg = ctx.cfg; + const startupCfg = ctx.cfg; const runtime = ctx.runtime; const supportsInteractiveArgMenus = @@ -395,6 +396,7 @@ export async function registerSlackMonitorSlashCommands(params: { commandDefinition?: ChatCommandDefinition; }) => { const { command, ack, respond, body, prompt, commandArgs, commandDefinition } = p; + const cfg = getRuntimeConfigSnapshot() ?? ctx.cfg; try { if (ctx.shouldDropMismatchedSlackEvent?.(body)) { await ack(); @@ -788,12 +790,12 @@ export async function registerSlackMonitorSlashCommands(params: { const nativeEnabled = resolveNativeCommandsEnabled({ providerId: "slack", providerSetting: account.config.commands?.native, - globalSetting: cfg.commands?.native, + globalSetting: startupCfg.commands?.native, }); const nativeSkillsEnabled = resolveNativeSkillsEnabled({ providerId: "slack", providerSetting: account.config.commands?.nativeSkills, - globalSetting: cfg.commands?.nativeSkills, + globalSetting: startupCfg.commands?.nativeSkills, }); let nativeCommands: Array<{ name: string }> = []; @@ -801,9 +803,9 @@ export async function registerSlackMonitorSlashCommands(params: { if (nativeEnabled) { slashCommandsRuntime = await loadSlashCommandsRuntime(); const skillCommands = nativeSkillsEnabled - ? (await loadSlashSkillCommandsRuntime()).listSkillCommandsForAgents({ cfg }) + ? (await loadSlashSkillCommandsRuntime()).listSkillCommandsForAgents({ cfg: startupCfg }) : []; - nativeCommands = slashCommandsRuntime.listNativeCommandSpecsForConfig(cfg, { + nativeCommands = slashCommandsRuntime.listNativeCommandSpecsForConfig(startupCfg, { skillCommands, provider: "slack", });