fix(discord): return native status replies directly (#66434)

This commit is contained in:
Vincent Koc
2026-04-14 09:55:02 +01:00
committed by GitHub
parent 56625a189b
commit 381a8e860a
10 changed files with 408 additions and 39 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with `ERR_MODULE_NOT_FOUND` at runtime. (#66420) Thanks @obviyus.
- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.
- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
## 2026.4.14-beta.1

View File

@@ -1,2 +1,2 @@
7003e0d0ba1cddb7eb388204825ac892206209a4a9c795e76c4e34b5fc7b50f0 plugin-sdk-api-baseline.json
14e39520459abc7db7993a700a4f07adfa0855d9233d123c4725477b91f1cb13 plugin-sdk-api-baseline.jsonl
7b121e2b694f80433fa91ce9037527ca58be546a7f18798470a4ade66593e5e1 plugin-sdk-api-baseline.json
7b802cc04f0eac0b498b50711e39a7afe93bbb6b682a2013d2c303583fb73f40 plugin-sdk-api-baseline.jsonl

View File

@@ -19,6 +19,7 @@ const runtimeModuleMocks = vi.hoisted(() => ({
matchPluginCommand: vi.fn(),
executePluginCommand: vi.fn(),
dispatchReplyWithDispatcher: vi.fn(),
resolveDirectStatusReplyForSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
@@ -43,6 +44,11 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
};
});
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
}));
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
@@ -306,35 +312,24 @@ function createDispatchSpy() {
} as never);
}
function expectBoundSessionDispatch(
dispatchSpy: ReturnType<typeof createDispatchSpy>,
expectedPattern: RegExp,
) {
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
if (!dispatchCall.ctx?.SessionKey || !dispatchCall.ctx.CommandTargetSessionKey) {
throw new Error("native command dispatch did not include bound session context");
}
expect(dispatchCall.ctx.SessionKey).toMatch(expectedPattern);
expect(dispatchCall.ctx.CommandTargetSessionKey).toMatch(expectedPattern);
}
async function expectBoundStatusCommandDispatch(params: {
async function expectBoundStatusCommandDirectReply(params: {
cfg: OpenClawConfig;
interaction: MockCommandInteraction;
expectedPattern: RegExp;
}) {
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
const statusSpy = runtimeModuleMocks.resolveDirectStatusReplyForSession;
const command = await createStatusCommand(params.cfg);
await (command as { run: (interaction: unknown) => Promise<void> }).run(
params.interaction as unknown,
);
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(statusSpy).toHaveBeenCalledTimes(1);
const statusCall = statusSpy.mock.calls[0]?.[0] as { sessionKey?: string };
expect(statusCall.sessionKey).toMatch(params.expectedPattern);
}
describe("Discord native plugin command dispatch", () => {
@@ -366,6 +361,10 @@ describe("Discord native plugin command dispatch", () => {
tool: 0,
},
} as never);
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockReset();
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({
text: "status reply",
});
discordNativeCommandTesting.setMatchPluginCommand(
runtimeModuleMocks.matchPluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand,
);
@@ -632,7 +631,7 @@ describe("Discord native plugin command dispatch", () => {
}),
);
await expectBoundStatusCommandDispatch({
await expectBoundStatusCommandDirectReply({
cfg,
interaction,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
@@ -683,7 +682,8 @@ describe("Discord native plugin command dispatch", () => {
}),
);
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
const statusSpy = runtimeModuleMocks.resolveDirectStatusReplyForSession;
const command = await createStatusCommand(cfg);
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () => ({
route: {
@@ -712,14 +712,10 @@ describe("Discord native plugin command dispatch", () => {
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe("agent:qwen:discord:slash:owner");
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
"agent:qwen:discord:channel:1478836151241412759",
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(statusSpy).toHaveBeenCalledTimes(1);
const statusCall = statusSpy.mock.calls[0]?.[0] as { sessionKey?: string };
expect(statusCall.sessionKey).toBe("agent:qwen:discord:channel:1478836151241412759");
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
@@ -735,7 +731,7 @@ describe("Discord native plugin command dispatch", () => {
}),
);
await expectBoundStatusCommandDispatch({
await expectBoundStatusCommandDirectReply({
cfg,
interaction,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,

View File

@@ -0,0 +1,200 @@
import { ChannelType } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockCommandInteraction,
type MockCommandInteraction,
} from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
const runtimeModuleMocks = vi.hoisted(() => ({
dispatchReplyWithDispatcher: vi.fn(),
resolveDirectStatusReplyForSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-dispatch-runtime")>(
"openclaw/plugin-sdk/reply-dispatch-runtime",
);
return {
...actual,
dispatchReplyWithDispatcher: (...args: unknown[]) =>
runtimeModuleMocks.dispatchReplyWithDispatcher(...args),
};
});
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
}));
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
let discordNativeCommandTesting: typeof import("./native-command.js").__testing;
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
threadParentId?: string | null;
guildId?: string | null;
guildName?: string;
}): MockCommandInteraction {
return createMockCommandInteraction({
userId: "owner",
username: "tester",
globalName: "Tester",
channelType: params?.channelType ?? ChannelType.DM,
channelId: params?.channelId ?? "dm-1",
threadParentId: params?.threadParentId,
guildId: params?.guildId ?? null,
guildName: params?.guildName,
interactionId: "interaction-1",
});
}
function createConfig(params?: { requireMention?: boolean }): OpenClawConfig {
return {
commands: {
useAccessGroups: false,
},
channels: {
discord: {
dm: { enabled: true, policy: "open" },
guilds: {
guild1: {
requireMention: true,
channels: {
chan1: {
allow: true,
requireMention: params?.requireMention ?? true,
},
},
},
},
},
},
} as OpenClawConfig;
}
async function createStatusCommand(cfg: OpenClawConfig) {
return createDiscordNativeCommand({
command: {
name: "status",
description: "Status",
acceptsArgs: false,
},
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
}
function setDefaultRouteState() {
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) => ({
route: {
agentId: "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
},
effectiveRoute: {
agentId: "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
},
boundSessionKey: undefined,
configuredRoute: null,
configuredBinding: null,
bindingReadiness: null,
}));
}
function firstStatusCall(): {
cfg: OpenClawConfig;
sessionKey: string;
channel: string;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
} {
const call = runtimeModuleMocks.resolveDirectStatusReplyForSession.mock.calls[0]?.[0];
if (!call) {
throw new Error("expected resolveDirectStatusReplyForSession to be called");
}
return call as {
cfg: OpenClawConfig;
sessionKey: string;
channel: string;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
};
}
describe("discord native /status", () => {
beforeAll(async () => {
({ createDiscordNativeCommand, __testing: discordNativeCommandTesting } =
await import("./native-command.js"));
});
beforeEach(() => {
vi.clearAllMocks();
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
counts: {
final: 0,
block: 0,
tool: 0,
},
queuedFinal: false,
} as never);
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({
text: "status reply",
});
discordNativeCommandTesting.setDispatchReplyWithDispatcher(
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithDispatcher,
);
setDefaultRouteState();
});
it("returns a direct status reply without falling through the generic dispatcher", async () => {
const cfg = createConfig();
const command = await createStatusCommand(cfg);
const interaction = createInteraction();
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenCalledTimes(1);
expect(runtimeModuleMocks.dispatchReplyWithDispatcher).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: "status reply",
}),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("passes through the effective guild activation when requireMention is disabled", async () => {
const cfg = createConfig({ requireMention: false });
const command = await createStatusCommand(cfg);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "chan1",
guildId: "guild1",
guildName: "Guild One",
});
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
const statusCall = firstStatusCall();
expect(statusCall.channel).toBe("discord");
expect(statusCall.isGroup).toBe(true);
expect(statusCall.defaultGroupActivation()).toBe("always");
});
});

View File

@@ -18,6 +18,7 @@ import {
resolveCommandAuthorizedFromAuthorizers,
resolveNativeCommandSessionTargets,
} from "openclaw/plugin-sdk/command-auth-native";
import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime";
import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
@@ -755,6 +756,7 @@ async function dispatchDiscordCommandInteraction(params: {
threadBindings,
suppressReplies,
} = params;
const commandName = command.nativeName ?? command.key;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
const payload = {
content,
@@ -869,15 +871,10 @@ async function dispatchDiscordCommandInteraction(params: {
conversationId: rawChannelId || "unknown",
parentConversationId: threadParentId,
threadBinding: isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined,
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(
command.nativeName ?? command.key,
),
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(commandName),
}));
const canBypassConfiguredAcpGuildGuards = async () => {
if (
!interaction.guild ||
!shouldBypassConfiguredAcpGuildGuards(command.nativeName ?? command.key)
) {
if (!interaction.guild || !shouldBypassConfiguredAcpGuildGuards(commandName)) {
return false;
}
const routeState = await getNativeRouteState();
@@ -1131,6 +1128,36 @@ async function dispatchDiscordCommandInteraction(params: {
targetSessionKey: effectiveRoute.sessionKey,
boundSessionKey,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
if (!suppressReplies && commandName === "status") {
const statusReply = await resolveDirectStatusReplyForSession({
cfg,
sessionKey: commandTargetSessionKey?.trim() || sessionKey,
channel: "discord",
senderId: sender.id,
senderIsOwner: ownerOk,
isAuthorizedSender: commandAuthorized,
isGroup: isGuild || isGroupDm,
defaultGroupActivation: () =>
!isGuild ? "always" : channelConfig?.requireMention === false ? "always" : "mention",
});
if (statusReply && hasRenderableReplyPayload(statusReply)) {
await deliverDiscordInteractionReply({
interaction,
payload: statusReply,
mediaLocalRoots,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: 2000,
}),
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
preferFollowUp,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
return;
}
await respond("Status unavailable.");
return;
}
const ctxPayload = buildDiscordNativeCommandContext({
prompt,
commandArgs: commandArgs ?? {},
@@ -1164,7 +1191,6 @@ async function dispatchDiscordCommandInteraction(params: {
channel: "discord",
accountId: effectiveRoute.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
let didReply = false;

View File

@@ -482,6 +482,10 @@
"types": "./dist/plugin-sdk/command-status.d.ts",
"default": "./dist/plugin-sdk/command-status.js"
},
"./plugin-sdk/command-status-runtime": {
"types": "./dist/plugin-sdk/command-status-runtime.d.ts",
"default": "./dist/plugin-sdk/command-status-runtime.js"
},
"./plugin-sdk/command-detection": {
"types": "./dist/plugin-sdk/command-detection.d.ts",
"default": "./dist/plugin-sdk/command-detection.js"

View File

@@ -65,6 +65,9 @@ export const pluginSdkDocMetadata = {
"command-status": {
category: "channel",
},
"command-status-runtime": {
category: "runtime",
},
"secret-input": {
category: "channel",
},

View File

@@ -108,6 +108,7 @@
"command-auth",
"command-auth-native",
"command-status",
"command-status-runtime",
"command-detection",
"command-surface",
"collection-runtime",

View File

@@ -0,0 +1,13 @@
import { createLazyRuntimeMethodBinder, createLazyRuntimeModule } from "../shared/lazy-runtime.js";
type CommandStatusRuntime = typeof import("./command-status.runtime.js");
const loadCommandStatusRuntime = createLazyRuntimeModule(
() => import("./command-status.runtime.js"),
);
const bindCommandStatusRuntime = createLazyRuntimeMethodBinder(loadCommandStatusRuntime);
export type { ResolveDirectStatusReplyForSessionParams } from "./command-status.runtime.js";
export const resolveDirectStatusReplyForSession: CommandStatusRuntime["resolveDirectStatusReplyForSession"] =
bindCommandStatusRuntime((runtime) => runtime.resolveDirectStatusReplyForSession);

View File

@@ -0,0 +1,125 @@
import { listAgentEntries, resolveSessionAgentId } from "../agents/agent-scope.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { buildStatusReply } from "../auto-reply/reply/commands-status.js";
import type { CommandContext } from "../auto-reply/reply/commands-types.js";
import { resolveDefaultModel } from "../auto-reply/reply/directive-handling.defaults.js";
import { resolveCurrentDirectiveLevels } from "../auto-reply/reply/directive-handling.levels.js";
import { createModelSelectionState } from "../auto-reply/reply/model-selection.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadSessionEntry } from "../gateway/session-utils.js";
export type ResolveDirectStatusReplyForSessionParams = {
cfg: OpenClawConfig;
sessionKey: string;
channel: string;
senderId?: string;
senderIsOwner: boolean;
isAuthorizedSender: boolean;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
};
export async function resolveDirectStatusReplyForSession(
params: ResolveDirectStatusReplyForSessionParams,
): Promise<ReplyPayload | undefined> {
const requestedSessionKey = params.sessionKey.trim();
if (!requestedSessionKey) {
return undefined;
}
const statusLoaded = loadSessionEntry(requestedSessionKey);
const statusCfg = statusLoaded.cfg ?? params.cfg;
const statusSessionKey = statusLoaded.canonicalKey;
const statusEntry = statusLoaded.entry;
const statusAgentId = resolveSessionAgentId({
sessionKey: statusSessionKey,
config: statusCfg,
});
const agentCfg = statusCfg.agents?.defaults;
const agentEntry = listAgentEntries(statusCfg).find(
(entry) => entry.id?.trim().toLowerCase() === statusAgentId,
);
const statusModel = resolveDefaultModelForAgent({
cfg: statusCfg,
agentId: statusAgentId,
});
const { defaultProvider, defaultModel } = resolveDefaultModel({
cfg: statusCfg,
agentId: statusAgentId,
});
const selectedProvider =
statusEntry?.providerOverride?.trim() ||
statusEntry?.modelProvider?.trim() ||
statusModel.provider;
const selectedModel =
statusEntry?.modelOverride?.trim() || statusEntry?.model?.trim() || statusModel.model;
const modelState = await createModelSelectionState({
cfg: statusCfg,
agentId: statusAgentId,
agentCfg,
sessionEntry: statusEntry,
sessionStore: statusLoaded.store,
sessionKey: statusSessionKey,
parentSessionKey: statusEntry?.parentSessionKey,
storePath: statusLoaded.storePath,
defaultProvider,
defaultModel,
provider: selectedProvider,
model: selectedModel,
hasModelDirective: false,
});
const {
currentThinkLevel,
currentFastMode,
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
} = await resolveCurrentDirectiveLevels({
sessionEntry: statusEntry,
agentEntry,
agentCfg,
resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(),
});
let resolvedReasoningLevel = currentReasoningLevel;
const hasAgentReasoningDefault =
agentEntry?.reasoningDefault !== undefined && agentEntry.reasoningDefault !== null;
const reasoningExplicitlySet =
(statusEntry?.reasoningLevel !== undefined && statusEntry.reasoningLevel !== null) ||
hasAgentReasoningDefault;
if (!reasoningExplicitlySet && resolvedReasoningLevel === "off" && currentThinkLevel === "off") {
resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel();
}
const command: CommandContext = {
surface: params.channel,
channel: params.channel,
ownerList: [],
senderIsOwner: params.senderIsOwner,
isAuthorizedSender: params.isAuthorizedSender,
senderId: params.senderId,
rawBodyNormalized: "/status",
commandBodyNormalized: "/status",
};
return await buildStatusReply({
cfg: statusCfg,
command,
sessionEntry: statusEntry,
sessionKey: statusSessionKey,
parentSessionKey: statusEntry?.parentSessionKey,
sessionScope: statusCfg.session?.scope,
storePath: statusLoaded.storePath,
provider: selectedProvider,
model: selectedModel,
contextTokens: statusEntry?.contextTokens ?? 0,
resolvedThinkLevel: currentThinkLevel,
resolvedFastMode: currentFastMode,
resolvedVerboseLevel: currentVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel: currentElevatedLevel,
resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(),
isGroup: params.isGroup,
defaultGroupActivation: params.defaultGroupActivation,
});
}