Skip to content

Commit 088a9dc

Browse files
fix(discord): refresh model picker after default changes (#104635)
* fix(discord): refresh model picker component config * test(discord): model full hot-reload snapshot state * fix(discord): keep pending model selection stable * fix(discord): stabilize recent model actions --------- Co-authored-by: compoodment <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent da6a24d commit 088a9dc

6 files changed

Lines changed: 319 additions & 29 deletions

File tree

extensions/discord/src/monitor/model-picker.state.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Discord plugin module implements model picker.state behavior.
2+
import { createHash } from "node:crypto";
23
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
34
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
45
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
@@ -56,6 +57,7 @@ export type DiscordModelPickerState = {
5657
page: number;
5758
providerPage?: number;
5859
modelIndex?: number;
60+
modelToken?: string;
5961
recentSlot?: number;
6062
/**
6163
* Letter-range bucket label (e.g. "a-g") when the provider/model count
@@ -76,6 +78,14 @@ export const DISCORD_MODEL_PICKER_BUCKET_THRESHOLD = DISCORD_COMPONENT_MAX_SELEC
7678

7779
/** Target items per alpha bucket. Discord caps selects at 25 options. */
7880
export const DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE = 20;
81+
const DISCORD_MODEL_PICKER_MODEL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{8}$/u;
82+
83+
export function createDiscordModelPickerModelToken(provider: string, model: string): string {
84+
return createHash("sha256")
85+
.update(JSON.stringify([normalizeProviderId(provider), model]), "utf8")
86+
.digest("base64url")
87+
.slice(0, 8);
88+
}
7989

8090
export type DiscordModelPickerBucket = {
8191
/** Stable lowercase id, e.g. "a-g". Used in customId encoding. */
@@ -200,6 +210,7 @@ export function buildDiscordModelPickerCustomId(params: {
200210
page?: number;
201211
providerPage?: number;
202212
modelIndex?: number;
213+
modelToken?: string;
203214
recentSlot?: number;
204215
providerBucket?: string;
205216
modelBucket?: string;
@@ -223,6 +234,10 @@ export function buildDiscordModelPickerCustomId(params: {
223234
typeof params.recentSlot === "number" && Number.isFinite(params.recentSlot)
224235
? Math.max(1, Math.floor(params.recentSlot))
225236
: undefined;
237+
const modelToken = params.modelToken?.trim();
238+
if (modelToken && !DISCORD_MODEL_PICKER_MODEL_TOKEN_PATTERN.test(modelToken)) {
239+
throw new Error("Discord model picker model token is invalid");
240+
}
226241

227242
const parts = [
228243
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdComponent(params.command)}`,
@@ -248,11 +263,17 @@ export function buildDiscordModelPickerCustomId(params: {
248263
if (providerPage) {
249264
parts.push(`pp=${String(providerPage)}`);
250265
}
251-
if (modelIndex) {
252-
parts.push(`mi=${String(modelIndex)}`);
253-
}
254-
if (recentSlot) {
255-
parts.push(`rs=${String(recentSlot)}`);
266+
if (modelToken) {
267+
parts.push(`m=${modelToken}`);
268+
} else {
269+
// Legacy positional state is accepted until the next render. New model
270+
// components use the stable token so catalog reordering cannot retarget them.
271+
if (modelIndex) {
272+
parts.push(`mi=${String(modelIndex)}`);
273+
}
274+
if (recentSlot) {
275+
parts.push(`rs=${String(recentSlot)}`);
276+
}
256277
}
257278
const providerBucket = params.providerBucket?.trim().toLowerCase();
258279
if (providerBucket) {
@@ -312,6 +333,10 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi
312333
const page = parseRawPage(data.g ?? data.pg);
313334
const providerPage = parseRawPositiveInt(data.pp);
314335
const modelIndex = parseRawPositiveInt(data.mi);
336+
const modelTokenRaw = coerceString(data.m).trim();
337+
const modelToken = DISCORD_MODEL_PICKER_MODEL_TOKEN_PATTERN.test(modelTokenRaw)
338+
? modelTokenRaw
339+
: undefined;
315340
const recentSlot = parseRawPositiveInt(data.rs);
316341
const providerBucketRaw = decodeCustomIdComponent(coerceString(data.pb)).trim().toLowerCase();
317342
const modelBucketRaw = decodeCustomIdComponent(coerceString(data.mb)).trim().toLowerCase();
@@ -339,6 +364,7 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi
339364
page,
340365
...(typeof providerPage === "number" ? { providerPage } : {}),
341366
...(typeof modelIndex === "number" ? { modelIndex } : {}),
367+
...(modelToken ? { modelToken } : {}),
342368
...(typeof recentSlot === "number" ? { recentSlot } : {}),
343369
...(providerBucketRaw ? { providerBucket: providerBucketRaw } : {}),
344370
...(modelBucketRaw ? { modelBucket: modelBucketRaw } : {}),

extensions/discord/src/monitor/model-picker.test.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX,
1212
buildDiscordModelPickerCustomId,
1313
computeAlphaBuckets,
14+
createDiscordModelPickerModelToken,
1415
getDiscordModelPickerModelPage,
1516
getDiscordModelPickerProviderPage,
1617
findProviderBucketId,
@@ -276,6 +277,7 @@ describe("Discord model picker custom_id", () => {
276277
});
277278

278279
it("keeps typical submit ids under Discord max length", () => {
280+
const modelToken = createDiscordModelPickerModelToken("azure-openai-responses", "gpt-5.5");
279281
const customId = buildDiscordModelPickerCustomId({
280282
command: "models",
281283
action: "submit",
@@ -284,10 +286,14 @@ describe("Discord model picker custom_id", () => {
284286
page: 1,
285287
providerPage: 1,
286288
modelIndex: 10,
289+
modelToken,
287290
userId: "12345678901234567890",
288291
});
289292

290293
expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS);
294+
const parsed = parseDiscordModelPickerCustomId(customId);
295+
expect(parsed?.modelToken).toBe(modelToken);
296+
expect(parsed?.modelIndex).toBeUndefined();
291297
});
292298
});
293299

@@ -1033,7 +1039,8 @@ describe("Discord model picker rendering", () => {
10331039
const submitState = parseDiscordModelPickerCustomId(navButtons[3]?.custom_id ?? "");
10341040
expect(submitState?.action).toBe("submit");
10351041
expect(submitState?.provider).toBe("openai");
1036-
expect(submitState?.modelIndex).toBe(3);
1042+
expect(submitState?.modelIndex).toBeUndefined();
1043+
expect(submitState?.modelToken).toBe(createDiscordModelPickerModelToken("openai", "o3"));
10371044
});
10381045

10391046
it("defaults the runtime picker to the first effective runtime choice", () => {
@@ -1093,7 +1100,8 @@ describe("Discord model picker rendering", () => {
10931100
const submitState = parseDiscordModelPickerCustomId(navButtons.at(-1)?.custom_id ?? "");
10941101
expect(submitState?.action).toBe("submit");
10951102
expect(submitState?.runtime).toBeUndefined();
1096-
expect(submitState?.modelIndex).toBe(3);
1103+
expect(submitState?.modelIndex).toBeUndefined();
1104+
expect(submitState?.modelToken).toBe(createDiscordModelPickerModelToken("openai", "o3"));
10971105
});
10981106

10991107
it("carries only explicit runtime picker state into model submit ids", () => {
@@ -1284,7 +1292,7 @@ describe("Discord model picker recents view", () => {
12841292
});
12851293
expect(rows).toHaveLength(4);
12861294

1287-
// First row: default model button (slot 1).
1295+
// First row: default model button.
12881296
const defaultBtn = requireValue(
12891297
rows[0]?.components?.[0],
12901298
"recents view should render a default model button",
@@ -1296,9 +1304,10 @@ describe("Discord model picker recents view", () => {
12961304
);
12971305
expect(defaultState.action).toBe("submit");
12981306
expect(defaultState.view).toBe("recents");
1299-
expect(defaultState.recentSlot).toBe(1);
1307+
expect(defaultState.recentSlot).toBeUndefined();
1308+
expect(defaultState.modelToken).toBe(createDiscordModelPickerModelToken("openai", "gpt-4.1"));
13001309

1301-
// Second row: first recent (slot 2).
1310+
// Second row: first recent.
13021311
const recentBtn1 = requireValue(
13031312
rows[1]?.components?.[0],
13041313
"recents view should render first recent button",
@@ -1307,9 +1316,10 @@ describe("Discord model picker recents view", () => {
13071316
parseDiscordModelPickerCustomId(recentBtn1.custom_id ?? ""),
13081317
"first recent custom id should parse",
13091318
);
1310-
expect(recentState1.recentSlot).toBe(2);
1319+
expect(recentState1.recentSlot).toBeUndefined();
1320+
expect(recentState1.modelToken).toBe(createDiscordModelPickerModelToken("openai", "gpt-4o"));
13111321

1312-
// Third row: second recent (slot 3).
1322+
// Third row: second recent.
13131323
const recentBtn2 = requireValue(
13141324
rows[2]?.components?.[0],
13151325
"recents view should render second recent button",
@@ -1318,7 +1328,10 @@ describe("Discord model picker recents view", () => {
13181328
parseDiscordModelPickerCustomId(recentBtn2.custom_id ?? ""),
13191329
"second recent custom id should parse",
13201330
);
1321-
expect(recentState2.recentSlot).toBe(3);
1331+
expect(recentState2.recentSlot).toBeUndefined();
1332+
expect(recentState2.modelToken).toBe(
1333+
createDiscordModelPickerModelToken("anthropic", "claude-sonnet-4-5"),
1334+
);
13221335

13231336
// Fourth row (after divider): Back button.
13241337
const backBtn = requireValue(

extensions/discord/src/monitor/model-picker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
buildDiscordModelPickerCustomId,
44
buildDiscordModelPickerProviderItems,
55
computeAlphaBuckets,
6+
createDiscordModelPickerModelToken,
67
findModelBucketId,
78
findProviderBucketId,
89
findProviderBucketLocation,

extensions/discord/src/monitor/model-picker.view.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "../internal/discord.js";
2020
import {
2121
buildDiscordModelPickerCustomId,
22+
createDiscordModelPickerModelToken,
2223
getDiscordModelPickerModelPage,
2324
getDiscordModelPickerProviderPage,
2425
normalizeModelPickerPage,
@@ -364,6 +365,7 @@ function buildPaginationRow(params: {
364365
runtimeIndex?: number;
365366
providerPage?: number;
366367
modelIndex?: number;
368+
modelToken?: string;
367369
providerBucket?: string;
368370
modelBucket?: string;
369371
}): Row<Button> | null {
@@ -384,6 +386,7 @@ function buildPaginationRow(params: {
384386
page: Math.max(1, params.page - 1),
385387
providerPage: params.providerPage,
386388
modelIndex: params.modelIndex,
389+
modelToken: params.modelToken,
387390
providerBucket: params.providerBucket,
388391
modelBucket: params.modelBucket,
389392
userId: params.userId,
@@ -409,6 +412,7 @@ function buildPaginationRow(params: {
409412
page: Math.min(params.totalPages, params.page + 1),
410413
providerPage: params.providerPage,
411414
modelIndex: params.modelIndex,
415+
modelToken: params.modelToken,
412416
providerBucket: params.providerBucket,
413417
modelBucket: params.modelBucket,
414418
userId: params.userId,
@@ -436,6 +440,9 @@ function buildModelRows(params: {
436440
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
437441
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
438442
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
443+
const pendingModelToken = parsedPendingModel
444+
? createDiscordModelPickerModelToken(parsedPendingModel.provider, parsedPendingModel.model)
445+
: undefined;
439446
const rows: DiscordModelPickerRow[] = [];
440447

441448
const hasQuickModels = (params.quickModels ?? []).length > 0;
@@ -510,6 +517,7 @@ function buildModelRows(params: {
510517
page: params.modelPage.page,
511518
providerPage: providerPage.page,
512519
modelIndex: params.pendingModelIndex,
520+
modelToken: pendingModelToken,
513521
...(params.pendingModelIndex === undefined && activeModelBucket
514522
? { modelBucket: activeModelBucket }
515523
: {}),
@@ -578,6 +586,7 @@ function buildModelRows(params: {
578586
...compactRuntime,
579587
providerPage: providerPage.page,
580588
modelIndex: params.pendingModelIndex,
589+
modelToken: pendingModelToken,
581590
// Model navigation derives providerBucket from provider on interaction;
582591
// carrying it here can exceed Discord's 100-char customId limit.
583592
modelBucket:
@@ -679,6 +688,7 @@ function buildModelRows(params: {
679688
page: params.modelPage.page,
680689
providerPage: providerPage.page,
681690
modelIndex: params.pendingModelIndex,
691+
modelToken: pendingModelToken,
682692
userId: params.userId,
683693
}),
684694
}),
@@ -890,6 +900,11 @@ function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
890900
return trimmed;
891901
}
892902

903+
function createModelRefToken(modelRef: string): string | undefined {
904+
const parsed = parseCurrentModelRef(modelRef);
905+
return parsed ? createDiscordModelPickerModelToken(parsed.provider, parsed.model) : undefined;
906+
}
907+
893908
export function renderDiscordModelPickerRecentsView(
894909
params: DiscordModelPickerRecentsViewParams,
895910
): DiscordModelPickerRenderedView {
@@ -910,6 +925,7 @@ export function renderDiscordModelPickerRecentsView(
910925
action: "submit",
911926
view: "recents",
912927
recentSlot: 1,
928+
modelToken: createModelRefToken(defaultModelRef),
913929
provider: params.provider,
914930
runtime: params.runtime,
915931
runtimeIndex: params.runtimeIndex,
@@ -934,6 +950,7 @@ export function renderDiscordModelPickerRecentsView(
934950
action: "submit",
935951
view: "recents",
936952
recentSlot: i + 2,
953+
modelToken: createModelRefToken(modelRef),
937954
provider: params.provider,
938955
runtime: params.runtime,
939956
runtimeIndex: params.runtimeIndex,

0 commit comments

Comments
 (0)