fix: refresh slash command routing config (#39617)

Use the active runtime snapshot for Discord and Slack native command routing and Discord autocomplete after config hot writes.

Fixes #39605

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Ciward
2026-06-15 00:32:18 +08:00
committed by GitHub
parent 10b0dea77a
commit 364461949d
6 changed files with 216 additions and 14 deletions

View File

@@ -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: {

View File

@@ -58,12 +58,13 @@ function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string {
export function buildDiscordCommandOptions(params: {
command: ChatCommandDefinition;
cfg: OpenClawConfig;
resolveConfig?: () => OpenClawConfig;
authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise<boolean>;
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,
});

View File

@@ -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<void> }).run(
createInteraction() as unknown,
);
setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg);
await (command as { run: (interaction: unknown) => Promise<void> }).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();

View File

@@ -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;

View File

@@ -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" });

View File

@@ -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<void> {
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",
});