From dc495e6d62db76d1cd1a0c8be08ff35f04b1e852 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 13:50:26 +0100 Subject: [PATCH] refactor(discord): isolate model picker apply flow --- .../src/monitor/native-command-dispatch.ts | 35 +++ .../native-command-model-picker-apply.ts | 180 ++++++++++++++ .../discord/src/monitor/native-command-ui.ts | 219 +++--------------- .../discord/src/monitor/native-command.ts | 2 +- 4 files changed, 253 insertions(+), 183 deletions(-) create mode 100644 extensions/discord/src/monitor/native-command-dispatch.ts create mode 100644 extensions/discord/src/monitor/native-command-model-picker-apply.ts diff --git a/extensions/discord/src/monitor/native-command-dispatch.ts b/extensions/discord/src/monitor/native-command-dispatch.ts new file mode 100644 index 00000000000..9906c890cfc --- /dev/null +++ b/extensions/discord/src/monitor/native-command-dispatch.ts @@ -0,0 +1,35 @@ +import type { + ButtonInteraction, + CommandInteraction, + StringSelectMenuInteraction, +} from "@buape/carbon"; +import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import type { ThreadBindingManager } from "./thread-bindings.js"; + +type DiscordConfig = NonNullable["discord"]; + +export type DispatchDiscordCommandInteractionParams = { + interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; + prompt: string; + command: ChatCommandDefinition; + commandArgs?: CommandArgs; + cfg: OpenClawConfig; + discordConfig: DiscordConfig; + accountId: string; + sessionPrefix: string; + preferFollowUp: boolean; + threadBindings: ThreadBindingManager; + responseEphemeral?: boolean; + suppressReplies?: boolean; +}; + +export type DispatchDiscordCommandInteractionResult = { + accepted: boolean; + effectiveRoute?: ResolvedAgentRoute; +}; + +export type DispatchDiscordCommandInteraction = ( + params: DispatchDiscordCommandInteractionParams, +) => Promise; diff --git a/extensions/discord/src/monitor/native-command-model-picker-apply.ts b/extensions/discord/src/monitor/native-command-model-picker-apply.ts new file mode 100644 index 00000000000..8d022ed2580 --- /dev/null +++ b/extensions/discord/src/monitor/native-command-model-picker-apply.ts @@ -0,0 +1,180 @@ +import type { ButtonInteraction, StringSelectMenuInteraction } from "@buape/carbon"; +import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth"; +import { + applyModelOverrideToSessionEntry, + resolveStorePath, + updateSessionStore, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; +import { + recordDiscordModelPickerRecentModel, + type DiscordModelPickerPreferenceScope, +} from "./model-picker-preferences.js"; +import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js"; +import type { ThreadBindingManager } from "./thread-bindings.js"; + +type DiscordConfig = NonNullable["discord"]; + +export type DiscordModelPickerSelectionCommand = { + prompt: string; + command: ChatCommandDefinition; + args?: CommandArgs; +}; + +export type DiscordModelPickerApplyResult = + | { status: "success"; effectiveModelRef: string; noticeMessage: string } + | { status: "mismatch"; effectiveModelRef: string; noticeMessage: string } + | { status: "rejected"; noticeMessage: string } + | { status: "timeout"; noticeMessage: string } + | { status: "failed"; noticeMessage: string }; + +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; +} + +export async function applyDiscordModelPickerSelection(params: { + interaction: ButtonInteraction | StringSelectMenuInteraction; + selectionCommand: DiscordModelPickerSelectionCommand; + dispatchCommandInteraction: DispatchDiscordCommandInteraction; + cfg: OpenClawConfig; + discordConfig: DiscordConfig; + accountId: string; + sessionPrefix: string; + threadBindings: ThreadBindingManager; + route: ResolvedAgentRoute; + resolvedModelRef: string; + selectedProvider: string; + selectedModel: string; + defaultProvider: string; + defaultModel: string; + preferenceScope: DiscordModelPickerPreferenceScope; + settleMs: number; + resolveCurrentModel: (route: ResolvedAgentRoute) => string; +}): Promise { + try { + const dispatchResult = await withTimeout( + params.dispatchCommandInteraction({ + interaction: params.interaction, + prompt: params.selectionCommand.prompt, + command: params.selectionCommand.command, + commandArgs: params.selectionCommand.args, + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: params.accountId, + sessionPrefix: params.sessionPrefix, + preferFollowUp: true, + threadBindings: params.threadBindings, + suppressReplies: true, + }), + 12000, + ); + if (!dispatchResult.accepted) { + return { + status: "rejected", + noticeMessage: `❌ Failed to apply ${params.resolvedModelRef}. Try /model ${params.resolvedModelRef} directly.`, + }; + } + + const fallbackRoute = dispatchResult.effectiveRoute ?? params.route; + if (params.settleMs > 0) { + await new Promise((resolve) => setTimeout(resolve, params.settleMs)); + } + + let effectiveModelRef = params.resolveCurrentModel(fallbackRoute); + let persisted = effectiveModelRef === params.resolvedModelRef; + + if (!persisted) { + logVerbose( + `discord: model picker override mismatch — expected ${params.resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}; attempting direct session override persist`, + ); + try { + const directlyPersisted = await persistDiscordModelPickerOverride({ + cfg: params.cfg, + route: fallbackRoute, + provider: params.selectedProvider, + model: params.selectedModel, + isDefault: + params.selectedProvider === params.defaultProvider && + params.selectedModel === params.defaultModel, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + effectiveModelRef = params.resolveCurrentModel(fallbackRoute); + persisted = effectiveModelRef === params.resolvedModelRef; + if (!persisted) { + logVerbose( + `discord: direct session override persist failed — expected ${params.resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}`, + ); + } else if (!directlyPersisted) { + logVerbose( + `discord: direct session override persist became a no-op because ${params.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: params.preferenceScope, + modelRef: params.resolvedModelRef, + limit: 5, + }).catch(() => undefined); + } + + return persisted + ? { + status: "success", + effectiveModelRef, + noticeMessage: `✅ Model set to ${params.resolvedModelRef}.`, + } + : { + status: "mismatch", + effectiveModelRef, + noticeMessage: `⚠️ Tried to set ${params.resolvedModelRef}, but current model is ${effectiveModelRef}.`, + }; + } catch (error) { + if (error instanceof Error && error.message === "timeout") { + return { + status: "timeout", + noticeMessage: `⏳ Model change to ${params.resolvedModelRef} is still processing. Check /status in a few seconds.`, + }; + } + return { + status: "failed", + noticeMessage: `❌ Failed to apply ${params.resolvedModelRef}. Try /model ${params.resolvedModelRef} directly.`, + }; + } +} diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 35e59c973b1..eec4c714f6a 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -25,24 +25,16 @@ import { type CommandArgs, } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - applyModelOverrideToSessionEntry, - loadSessionStore, - resolveStorePath, - updateSessionStore, -} from "openclaw/plugin-sdk/config-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems, normalizeLowercaseStringOrEmpty, normalizeOptionalString, - withTimeout, } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordSlashCommandConfig } from "./commands.js"; import { readDiscordModelPickerRecentModels, - recordDiscordModelPickerRecentModel, type DiscordModelPickerPreferenceScope, } from "./model-picker-preferences.js"; import { @@ -55,7 +47,14 @@ import { toDiscordModelPickerMessagePayload, type DiscordModelPickerCommandContext, } from "./model-picker.js"; +import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js"; +import { applyDiscordModelPickerSelection } from "./native-command-model-picker-apply.js"; import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; +export type { + DispatchDiscordCommandInteraction, + DispatchDiscordCommandInteractionParams, + DispatchDiscordCommandInteractionResult, +} from "./native-command-dispatch.js"; import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; @@ -79,30 +78,6 @@ export type DiscordCommandArgContext = { export type DiscordModelPickerContext = DiscordCommandArgContext; -export type DispatchDiscordCommandInteractionParams = { - interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; - prompt: string; - command: ChatCommandDefinition; - commandArgs?: CommandArgs; - cfg: OpenClawConfig; - discordConfig: DiscordConfig; - accountId: string; - sessionPrefix: string; - preferFollowUp: boolean; - threadBindings: ThreadBindingManager; - responseEphemeral?: boolean; - suppressReplies?: boolean; -}; - -export type DispatchDiscordCommandInteractionResult = { - accepted: boolean; - effectiveRoute?: ResolvedAgentRoute; -}; - -export type DispatchDiscordCommandInteraction = ( - params: DispatchDiscordCommandInteractionParams, -) => Promise; - export type SafeDiscordInteractionCall = ( label: string, fn: () => Promise, @@ -383,36 +358,6 @@ 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; @@ -896,128 +841,38 @@ export async function handleDiscordModelPickerInteraction(params: { return; } - try { - const dispatchResult = await withTimeout( - params.dispatchCommandInteraction({ - interaction, - prompt: selectionCommand.prompt, - command: selectionCommand.command, - commandArgs: selectionCommand.args, + const applyResult = await applyDiscordModelPickerSelection({ + interaction, + selectionCommand, + dispatchCommandInteraction: params.dispatchCommandInteraction, + cfg: ctx.cfg, + discordConfig: ctx.discordConfig, + accountId: ctx.accountId, + sessionPrefix: ctx.sessionPrefix, + threadBindings: ctx.threadBindings, + route, + resolvedModelRef, + selectedProvider: parsedModelRef.provider, + selectedModel: parsedModelRef.model, + defaultProvider: pickerData.resolvedDefault.provider, + defaultModel: pickerData.resolvedDefault.model, + preferenceScope, + settleMs: ctx.postApplySettleMs ?? 250, + resolveCurrentModel: (currentRoute) => + resolveDiscordModelPickerCurrentModel({ cfg: ctx.cfg, - discordConfig: ctx.discordConfig, - accountId: ctx.accountId, - sessionPrefix: ctx.sessionPrefix, - preferFollowUp: true, - threadBindings: ctx.threadBindings, - suppressReplies: true, + route: currentRoute, + data: pickerData, }), - 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", () => - interaction.followUp({ - ...buildDiscordModelPickerNoticePayload( - `⏳ Model change to ${resolvedModelRef} is still processing. Check /status in a few seconds.`, - ), - ephemeral: true, - }), - ); - return; - } - - await params.safeInteractionCall("model picker follow-up", () => - interaction.followUp({ - ...buildDiscordModelPickerNoticePayload( - `❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`, - ), - ephemeral: true, - }), - ); - return; - } + await params.safeInteractionCall("model picker follow-up", () => + interaction.followUp({ + ...buildDiscordModelPickerNoticePayload(applyResult.noticeMessage), + ephemeral: true, + }), + ); + return; } if (parsed.action === "cancel") { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 9474bb4576d..a24d140c7e4 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -67,6 +67,7 @@ import { resolveDiscordChannelTopicSafe } from "./channel-access.js"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; import { buildDiscordNativeCommandContext } from "./native-command-context.js"; +import type { DispatchDiscordCommandInteractionResult } from "./native-command-dispatch.js"; import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; import { buildDiscordCommandArgMenu, @@ -77,7 +78,6 @@ import { resolveDiscordNativeChoiceContext, shouldOpenDiscordModelPickerFromCommand, type DiscordCommandArgContext, - type DispatchDiscordCommandInteractionResult, type DiscordModelPickerContext, } from "./native-command-ui.js"; import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";