refactor(discord): isolate model picker apply flow

This commit is contained in:
Peter Steinberger
2026-04-27 13:50:26 +01:00
parent 951a0d89d8
commit dc495e6d62
4 changed files with 253 additions and 183 deletions

View File

@@ -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<OpenClawConfig["channels"]>["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<DispatchDiscordCommandInteractionResult>;

View File

@@ -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<OpenClawConfig["channels"]>["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<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;
}
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<DiscordModelPickerApplyResult> {
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.`,
};
}
}

View File

@@ -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<DispatchDiscordCommandInteractionResult>;
export type SafeDiscordInteractionCall = <T>(
label: string,
fn: () => Promise<T>,
@@ -383,36 +358,6 @@ 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;
@@ -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") {

View File

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