diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf33fdeba0..a0c7bb7c435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index e26165d0ebe..35e59c973b1 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -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; +) => Promise; export type SafeDiscordInteractionCall = ( label: string, @@ -373,6 +383,36 @@ function resolveDiscordModelPickerCurrentModel(params: { } } +async function persistDiscordModelPickerOverride(params: { + cfg: OpenClawConfig; + route: ResolvedAgentRoute; + provider: string; + model: string; + isDefault: boolean; +}): Promise { + 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") { diff --git a/extensions/discord/src/monitor/native-command.command-arg.test.ts b/extensions/discord/src/monitor/native-command.command-arg.test.ts index 4e7b13d2343..901f2955acf 100644 --- a/extensions/discord/src/monitor/native-command.command-arg.test.ts +++ b/extensions/discord/src/monitor/native-command.command-arg.test.ts @@ -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().mockResolvedValue(); + const dispatchSpy = vi + .fn() + .mockResolvedValue({ accepted: true }); const button = createDiscordCommandArgFallbackButton({ ctx: createContext({ slashCommand: { ephemeral: false } }), safeInteractionCall, diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index 8540bc06ef0..5eaf3e719f0 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -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): 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(_label: string, fn: () => Promise): Pro } function createDispatchSpy() { - return vi.fn().mockResolvedValue(); + return vi.fn().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().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({ diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 9e8d2f59bc7..9474bb4576d 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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 { 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 {