fix(discord): persist stale model picker overrides

This commit is contained in:
Peter Steinberger
2026-04-27 13:43:26 +01:00
parent 1fbe83d09f
commit 951a0d89d8
5 changed files with 265 additions and 64 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111.
- Discord: persist routed model-picker overrides when the hidden `/model` dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129.
- Nodes/CLI: add `openclaw nodes remove --node <id|name|ip>` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw.
- Docker: install the CA certificate bundle in the slim runtime image so HTTPS calls from containerized gateways no longer fail TLS setup after the `bookworm-slim` base switch. Fixes #72787. Thanks @ryuhaneul.
- Providers/OpenRouter: remove retired Hunter Alpha and Healer Alpha static catalog rows and disable proxy reasoning injection for stale Hunter Alpha configs, so replies are not hidden when OpenRouter returns answer text in reasoning fields. Fixes #43942. Thanks @EvanDataForge.

View File

@@ -25,7 +25,12 @@ import {
type CommandArgs,
} from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import {
applyModelOverrideToSessionEntry,
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "openclaw/plugin-sdk/config-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
@@ -89,9 +94,14 @@ export type DispatchDiscordCommandInteractionParams = {
suppressReplies?: boolean;
};
export type DispatchDiscordCommandInteractionResult = {
accepted: boolean;
effectiveRoute?: ResolvedAgentRoute;
};
export type DispatchDiscordCommandInteraction = (
params: DispatchDiscordCommandInteractionParams,
) => Promise<void>;
) => Promise<DispatchDiscordCommandInteractionResult>;
export type SafeDiscordInteractionCall = <T>(
label: string,
@@ -373,6 +383,36 @@ function resolveDiscordModelPickerCurrentModel(params: {
}
}
async function persistDiscordModelPickerOverride(params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
provider: string;
model: string;
isDefault: boolean;
}): Promise<boolean> {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
let persisted = false;
await updateSessionStore(storePath, (store) => {
const entry = store[params.route.sessionKey];
if (!entry) {
return;
}
persisted =
applyModelOverrideToSessionEntry({
entry,
selection: {
provider: params.provider,
model: params.model,
isDefault: params.isDefault,
},
markLiveSwitchPending: true,
}).updated || persisted;
});
return persisted;
}
function resolveDiscordModelPickerCurrentRuntime(params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
@@ -857,7 +897,7 @@ export async function handleDiscordModelPickerInteraction(params: {
}
try {
await withTimeout(
const dispatchResult = await withTimeout(
params.dispatchCommandInteraction({
interaction,
prompt: selectionCommand.prompt,
@@ -873,6 +913,88 @@ export async function handleDiscordModelPickerInteraction(params: {
}),
12000,
);
if (!dispatchResult.accepted) {
await params.safeInteractionCall("model picker follow-up", () =>
interaction.followUp({
...buildDiscordModelPickerNoticePayload(
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
),
ephemeral: true,
}),
);
return;
}
const fallbackRoute = dispatchResult.effectiveRoute ?? route;
const settleMs = ctx.postApplySettleMs ?? 250;
if (settleMs > 0) {
await new Promise((resolve) => setTimeout(resolve, settleMs));
}
let effectiveModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route: fallbackRoute,
data: pickerData,
});
let persisted = effectiveModelRef === resolvedModelRef;
if (!persisted) {
logVerbose(
`discord: model picker override mismatch — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}; attempting direct session override persist`,
);
try {
const directlyPersisted = await persistDiscordModelPickerOverride({
cfg: ctx.cfg,
route: fallbackRoute,
provider: parsedModelRef.provider,
model: parsedModelRef.model,
isDefault:
parsedModelRef.provider === pickerData.resolvedDefault.provider &&
parsedModelRef.model === pickerData.resolvedDefault.model,
});
await new Promise((resolve) => setTimeout(resolve, 100));
effectiveModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route: fallbackRoute,
data: pickerData,
});
persisted = effectiveModelRef === resolvedModelRef;
if (!persisted) {
logVerbose(
`discord: direct session override persist failed — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}`,
);
} else if (!directlyPersisted) {
logVerbose(
`discord: direct session override persist became a no-op because ${resolvedModelRef} was already present on re-read for session key ${fallbackRoute.sessionKey}`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(
`discord: direct session override persist threw for session key ${fallbackRoute.sessionKey}: ${message}`,
);
}
}
if (persisted) {
await recordDiscordModelPickerRecentModel({
scope: preferenceScope,
modelRef: resolvedModelRef,
limit: 5,
}).catch(() => undefined);
}
await params.safeInteractionCall("model picker follow-up", () =>
interaction.followUp({
...buildDiscordModelPickerNoticePayload(
persisted
? `✅ Model set to ${resolvedModelRef}.`
: `⚠️ Tried to set ${resolvedModelRef}, but current model is ${effectiveModelRef}.`,
),
ephemeral: true,
}),
);
return;
} catch (error) {
if (error instanceof Error && error.message === "timeout") {
await params.safeInteractionCall("model picker follow-up", () =>
@@ -896,44 +1018,6 @@ export async function handleDiscordModelPickerInteraction(params: {
);
return;
}
const settleMs = ctx.postApplySettleMs ?? 250;
if (settleMs > 0) {
await new Promise((resolve) => setTimeout(resolve, settleMs));
}
const effectiveModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route,
data: pickerData,
});
const persisted = effectiveModelRef === resolvedModelRef;
if (!persisted) {
logVerbose(
`discord: model picker override mismatch — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${route.sessionKey}`,
);
}
if (persisted) {
await recordDiscordModelPickerRecentModel({
scope: preferenceScope,
modelRef: resolvedModelRef,
limit: 5,
}).catch(() => undefined);
}
await params.safeInteractionCall("model picker follow-up", () =>
interaction.followUp({
...buildDiscordModelPickerNoticePayload(
persisted
? `✅ Model set to ${resolvedModelRef}.`
: `⚠️ Tried to set ${resolvedModelRef}, but current model is ${effectiveModelRef}.`,
),
ephemeral: true,
}),
);
return;
}
if (parsed.action === "cancel") {

View File

@@ -73,7 +73,9 @@ describe("discord command argument fallback", () => {
it("preserves public slash command visibility for selected argument follow-ups", async () => {
const commandDefinition = createCommandDefinition();
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockReturnValue(commandDefinition);
const dispatchSpy = vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue();
const dispatchSpy = vi
.fn<DispatchDiscordCommandInteraction>()
.mockResolvedValue({ accepted: true });
const button = createDiscordCommandArgFallbackButton({
ctx: createContext({ slashCommand: { ephemeral: false } }),
safeInteractionCall,

View File

@@ -1,8 +1,16 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ChannelType } from "discord-api-types/v10";
import * as commandRegistryModule from "openclaw/plugin-sdk/command-auth";
import type { ChatCommandDefinition, CommandArgsParsing } from "openclaw/plugin-sdk/command-auth";
import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
loadSessionStore,
resolveStorePath,
saveSessionStore,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -40,6 +48,8 @@ type MockInteraction = {
client: object;
};
let tempDir: string;
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
return createBaseModelsProviderData(entries, { defaultProviderOrder: "sorted" });
}
@@ -61,6 +71,9 @@ async function waitForCondition(
function createModelPickerContext(): ModelPickerContext {
const cfg = {
session: {
store: path.join(tempDir, "sessions.json"),
},
channels: {
discord: {
dm: {
@@ -162,7 +175,7 @@ async function safeInteractionCall<T>(_label: string, fn: () => Promise<T>): Pro
}
function createDispatchSpy() {
return vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue();
return vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({ accepted: true });
}
function createModelPickerFallbackButton(
@@ -264,13 +277,15 @@ function createBoundThreadBindingManager(params: {
}
describe("Discord model picker interactions", () => {
beforeEach(() => {
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-discord-model-picker-"));
vi.useRealTimers();
vi.restoreAllMocks();
});
afterEach(() => {
afterEach(async () => {
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("registers distinct fallback ids for button and select handlers", () => {
@@ -619,6 +634,103 @@ describe("Discord model picker interactions", () => {
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
});
it("persists suffixed LM Studio model overrides when dispatch leaves the routed session stale", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createModelsProviderData({
anthropic: ["claude-sonnet-4-5"],
lmstudio: ["unsloth/gemma-4-26b-a4b-it@iq4_xs"],
});
const modelCommand = createModelCommandDefinition();
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
await saveSessionStore(storePath, {
"agent:worker:subagent:bound": {
updatedAt: Date.now(),
sessionId: "bound-session",
},
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const button = createModelPickerFallbackButton(context, dispatchSpy);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, {
...createModelsViewSubmitData(),
p: "lmstudio",
mi: "1",
});
const store = loadSessionStore(storePath, { skipCache: true });
expect(store["agent:worker:subagent:bound"]?.providerOverride).toBe("lmstudio");
expect(store["agent:worker:subagent:bound"]?.modelOverride).toBe(
"unsloth/gemma-4-26b-a4b-it@iq4_xs",
);
expect(store["agent:worker:subagent:bound"]?.liveModelSwitchPending).toBe(true);
expectDispatchedModelSelection({
dispatchSpy,
model: "lmstudio/unsloth/gemma-4-26b-a4b-it@iq4_xs",
});
expect(JSON.stringify(submitInteraction.followUp.mock.calls[0]?.[0])).toContain(
"✅ Model set to lmstudio/unsloth/gemma-4-26b-a4b-it@iq4_xs.",
);
});
it("does not write a fallback override when hidden /model dispatch is rejected", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createDefaultModelPickerData();
const modelCommand = createModelCommandDefinition();
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
await saveSessionStore(storePath, {
"agent:worker:subagent:bound": {
updatedAt: Date.now(),
sessionId: "bound-session",
},
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const button = createModelPickerFallbackButton(
context,
vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({ accepted: false }),
);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
await button.run(
submitInteraction as unknown as PickerButtonInteraction,
createModelsViewSubmitData(),
);
const store = loadSessionStore(storePath, { skipCache: true });
expect(store["agent:worker:subagent:bound"]?.providerOverride).toBeUndefined();
expect(store["agent:worker:subagent:bound"]?.modelOverride).toBeUndefined();
expect(JSON.stringify(submitInteraction.followUp.mock.calls[0]?.[0])).toContain(
"❌ Failed to apply openai/gpt-4o.",
);
});
it("loads model picker data from the effective bound route", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({

View File

@@ -77,6 +77,7 @@ import {
resolveDiscordNativeChoiceContext,
shouldOpenDiscordModelPickerFromCommand,
type DiscordCommandArgContext,
type DispatchDiscordCommandInteractionResult,
type DiscordModelPickerContext,
} from "./native-command-ui.js";
import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";
@@ -749,7 +750,7 @@ async function dispatchDiscordCommandInteraction(params: {
threadBindings: ThreadBindingManager;
responseEphemeral?: boolean;
suppressReplies?: boolean;
}) {
}): Promise<DispatchDiscordCommandInteractionResult> {
const {
interaction,
prompt,
@@ -783,7 +784,7 @@ async function dispatchDiscordCommandInteraction(params: {
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const user = interaction.user;
if (!user) {
return;
return { accepted: false };
}
const sender = resolveDiscordSenderIdentity({ author: user, pluralkitInfo: null });
const channel = interaction.channel;
@@ -882,7 +883,7 @@ async function dispatchDiscordCommandInteraction(params: {
};
if (channelConfig?.enabled === false && !(await canBypassConfiguredAcpGuildGuards())) {
await respond("This channel is disabled.");
return;
return { accepted: false };
}
if (
interaction.guild &&
@@ -890,7 +891,7 @@ async function dispatchDiscordCommandInteraction(params: {
!(await canBypassConfiguredAcpGuildGuards())
) {
await respond("This channel is not allowed.");
return;
return { accepted: false };
}
if (useAccessGroups && interaction.guild) {
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
@@ -905,7 +906,7 @@ async function dispatchDiscordCommandInteraction(params: {
});
if (!policyAuthorizer.allowed && !(await canBypassConfiguredAcpGuildGuards())) {
await respond("This channel is not allowed.");
return;
return { accepted: false };
}
}
const dmEnabled = discordConfig?.dm?.enabled ?? true;
@@ -914,7 +915,7 @@ async function dispatchDiscordCommandInteraction(params: {
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await respond("Discord DMs are disabled.");
return;
return { accepted: false };
}
const dmAccess = await resolveDiscordDmCommandAccess({
accountId,
@@ -952,7 +953,7 @@ async function dispatchDiscordCommandInteraction(params: {
await respond("You are not authorized to use this command.", { ephemeral: true });
},
});
return;
return { accepted: false };
}
}
const groupDmAccess = resolveDiscordNativeGroupDmAccess({
@@ -969,7 +970,7 @@ async function dispatchDiscordCommandInteraction(params: {
? "Discord group DMs are disabled."
: "This group DM is not allowed.",
);
return;
return { accepted: false };
}
if (!isDirectMessage) {
commandAuthorized = resolveDiscordGuildNativeCommandAuthorized({
@@ -987,7 +988,7 @@ async function dispatchDiscordCommandInteraction(params: {
});
if (!commandAuthorized && !(await canBypassConfiguredAcpGuildGuards())) {
await respond("You are not authorized to use this command.", { ephemeral: true });
return;
return { accepted: false };
}
}
@@ -1034,7 +1035,7 @@ async function dispatchDiscordCommandInteraction(params: {
ephemeral: true,
}),
);
return;
return { accepted: true };
}
await safeDiscordInteractionCall("interaction reply", () =>
interaction.reply({
@@ -1043,13 +1044,13 @@ async function dispatchDiscordCommandInteraction(params: {
ephemeral: true,
}),
);
return;
return { accepted: true };
}
const pluginMatch = matchPluginCommandImpl(prompt);
if (pluginMatch) {
if (suppressReplies) {
return;
return { accepted: true };
}
const channelId = rawChannelId || "unknown";
const messageThreadId = !isDirectMessage && isThreadChannel ? channelId : undefined;
@@ -1077,7 +1078,7 @@ async function dispatchDiscordCommandInteraction(params: {
});
if (!hasRenderableReplyPayload(pluginReply)) {
await respond("Done.");
return;
return { accepted: true, effectiveRoute };
}
await deliverDiscordInteractionReply({
interaction,
@@ -1090,7 +1091,7 @@ async function dispatchDiscordCommandInteraction(params: {
responseEphemeral,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
return;
return { accepted: true, effectiveRoute };
}
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
@@ -1108,7 +1109,7 @@ async function dispatchDiscordCommandInteraction(params: {
preferFollowUp,
safeInteractionCall: safeDiscordInteractionCall,
});
return;
return { accepted: true };
}
const isGuild = Boolean(interaction.guild);
@@ -1122,7 +1123,7 @@ async function dispatchDiscordCommandInteraction(params: {
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${routeState.bindingReadiness.error}`,
);
await respond("Configured ACP binding is unavailable right now. Please try again.");
return;
return { accepted: false };
}
}
const boundSessionKey = routeState.boundSessionKey;
@@ -1160,10 +1161,10 @@ async function dispatchDiscordCommandInteraction(params: {
responseEphemeral,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
return;
return { accepted: true, effectiveRoute };
}
await respond("Status unavailable.");
return;
return { accepted: true, effectiveRoute };
}
const ctxPayload = buildDiscordNativeCommandContext({
prompt,
@@ -1270,6 +1271,7 @@ async function dispatchDiscordCommandInteraction(params: {
await interaction.reply(payload);
});
}
return { accepted: true, effectiveRoute };
}
export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgContext): Button {