Skip to content

Commit 7fc241d

Browse files
committed
fix(discord): stabilize recent model actions
1 parent f8481f7 commit 7fc241d

5 files changed

Lines changed: 101 additions & 13 deletions

File tree

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,15 @@ export function buildDiscordModelPickerCustomId(params: {
265265
}
266266
if (modelToken) {
267267
parts.push(`m=${modelToken}`);
268-
} else if (modelIndex) {
269-
// Legacy index-only state is accepted until the next render. New pending
270-
// selections use the stable token so catalog reordering cannot retarget them.
271-
parts.push(`mi=${String(modelIndex)}`);
272-
}
273-
if (recentSlot) {
274-
parts.push(`rs=${String(recentSlot)}`);
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+
}
275277
}
276278
const providerBucket = params.providerBucket?.trim().toLowerCase();
277279
if (providerBucket) {

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,7 +1292,7 @@ describe("Discord model picker recents view", () => {
12921292
});
12931293
expect(rows).toHaveLength(4);
12941294

1295-
// First row: default model button (slot 1).
1295+
// First row: default model button.
12961296
const defaultBtn = requireValue(
12971297
rows[0]?.components?.[0],
12981298
"recents view should render a default model button",
@@ -1304,9 +1304,10 @@ describe("Discord model picker recents view", () => {
13041304
);
13051305
expect(defaultState.action).toBe("submit");
13061306
expect(defaultState.view).toBe("recents");
1307-
expect(defaultState.recentSlot).toBe(1);
1307+
expect(defaultState.recentSlot).toBeUndefined();
1308+
expect(defaultState.modelToken).toBe(createDiscordModelPickerModelToken("openai", "gpt-4.1"));
13081309

1309-
// Second row: first recent (slot 2).
1310+
// Second row: first recent.
13101311
const recentBtn1 = requireValue(
13111312
rows[1]?.components?.[0],
13121313
"recents view should render first recent button",
@@ -1315,9 +1316,10 @@ describe("Discord model picker recents view", () => {
13151316
parseDiscordModelPickerCustomId(recentBtn1.custom_id ?? ""),
13161317
"first recent custom id should parse",
13171318
);
1318-
expect(recentState1.recentSlot).toBe(2);
1319+
expect(recentState1.recentSlot).toBeUndefined();
1320+
expect(recentState1.modelToken).toBe(createDiscordModelPickerModelToken("openai", "gpt-4o"));
13191321

1320-
// Third row: second recent (slot 3).
1322+
// Third row: second recent.
13211323
const recentBtn2 = requireValue(
13221324
rows[2]?.components?.[0],
13231325
"recents view should render second recent button",
@@ -1326,7 +1328,10 @@ describe("Discord model picker recents view", () => {
13261328
parseDiscordModelPickerCustomId(recentBtn2.custom_id ?? ""),
13271329
"second recent custom id should parse",
13281330
);
1329-
expect(recentState2.recentSlot).toBe(3);
1331+
expect(recentState2.recentSlot).toBeUndefined();
1332+
expect(recentState2.modelToken).toBe(
1333+
createDiscordModelPickerModelToken("anthropic", "claude-sonnet-4-5"),
1334+
);
13301335

13311336
// Fourth row (after divider): Back button.
13321337
const backBtn = requireValue(

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,11 @@ function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
900900
return trimmed;
901901
}
902902

903+
function createModelRefToken(modelRef: string): string | undefined {
904+
const parsed = parseCurrentModelRef(modelRef);
905+
return parsed ? createDiscordModelPickerModelToken(parsed.provider, parsed.model) : undefined;
906+
}
907+
903908
export function renderDiscordModelPickerRecentsView(
904909
params: DiscordModelPickerRecentsViewParams,
905910
): DiscordModelPickerRenderedView {
@@ -920,6 +925,7 @@ export function renderDiscordModelPickerRecentsView(
920925
action: "submit",
921926
view: "recents",
922927
recentSlot: 1,
928+
modelToken: createModelRefToken(defaultModelRef),
923929
provider: params.provider,
924930
runtime: params.runtime,
925931
runtimeIndex: params.runtimeIndex,
@@ -944,6 +950,7 @@ export function renderDiscordModelPickerRecentsView(
944950
action: "submit",
945951
view: "recents",
946952
recentSlot: i + 2,
953+
modelToken: createModelRefToken(modelRef),
947954
provider: params.provider,
948955
runtime: params.runtime,
949956
runtimeIndex: params.runtimeIndex,

extensions/discord/src/monitor/native-command-model-picker-interaction.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,20 @@ function resolveSubmittedModelRef(params: {
138138
if (params.parsed.action === "reset") {
139139
return `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
140140
}
141+
if (params.parsed.modelToken) {
142+
return resolveDiscordModelPickerModelRefByToken(params.data, params.parsed.modelToken);
143+
}
141144
if (params.parsed.action === "quick") {
145+
if (params.requireModelToken) {
146+
return null;
147+
}
142148
const slot = params.parsed.recentSlot ?? 0;
143149
return slot >= 1 ? (params.quickModels[slot - 1] ?? null) : null;
144150
}
145151
if (params.parsed.view === "recents") {
152+
if (params.requireModelToken) {
153+
return null;
154+
}
146155
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
147156
const dedupedRecents = params.quickModels.filter((ref) => ref !== defaultModelRef);
148157
const slot = params.parsed.recentSlot ?? 0;
@@ -196,6 +205,21 @@ function listDiscordModelPickerProviderModels(
196205
return [...modelSet].toSorted();
197206
}
198207

208+
function resolveDiscordModelPickerModelRefByToken(
209+
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
210+
modelToken: string,
211+
): string | null {
212+
const matchingRefs: string[] = [];
213+
for (const [provider, models] of data.byProvider) {
214+
for (const model of models) {
215+
if (createDiscordModelPickerModelToken(provider, model) === modelToken) {
216+
matchingRefs.push(`${provider}/${model}`);
217+
}
218+
}
219+
}
220+
return matchingRefs.length === 1 ? (matchingRefs[0] ?? null) : null;
221+
}
222+
199223
function resolveDiscordModelPickerModelIndex(params: {
200224
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
201225
provider: string;

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,56 @@ describe("Discord model picker interactions", () => {
833833
expectDispatchedModelSelection({ dispatchSpy, model: "openai/gpt-4o" });
834834
});
835835

836+
it("keeps a recent model stable when hot reload shifts its slot", async () => {
837+
const context = createModelPickerContext();
838+
const runtimeCfg = { ...context.cfg } as OpenClawConfig;
839+
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfigSnapshot").mockReturnValue(runtimeCfg);
840+
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfigSourceSnapshot").mockReturnValue(
841+
runtimeCfg,
842+
);
843+
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(
844+
createModelsProviderData({ openai: ["a", "b"] }),
845+
);
846+
vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([
847+
"openai/a",
848+
"openai/b",
849+
]);
850+
mockModelCommandPipeline(createModelCommandDefinition());
851+
const dispatchSpy = createDispatchSpy();
852+
853+
await runSubmitButton({
854+
context,
855+
data: {
856+
cmd: "model",
857+
act: "submit",
858+
view: "recents",
859+
u: "owner",
860+
pg: "1",
861+
m: modelPickerModule.createDiscordModelPickerModelToken("openai", "b"),
862+
},
863+
dispatchCommandInteraction: dispatchSpy,
864+
});
865+
expectDispatchedModelSelection({ dispatchSpy, model: "openai/b" });
866+
867+
dispatchSpy.mockClear();
868+
const legacyInteraction = await runSubmitButton({
869+
context,
870+
data: {
871+
cmd: "model",
872+
act: "submit",
873+
view: "recents",
874+
u: "owner",
875+
pg: "1",
876+
rs: "1",
877+
},
878+
dispatchCommandInteraction: dispatchSpy,
879+
});
880+
expect(dispatchSpy).not.toHaveBeenCalled();
881+
expect(
882+
JSON.stringify(firstMockArg(legacyInteraction.editReply, "interaction.editReply")),
883+
).toContain("selection expired");
884+
});
885+
836886
it("does not decode compact recents runtime against another provider", async () => {
837887
const context = createModelPickerContext();
838888
const pickerData = createModelsProviderData({

0 commit comments

Comments
 (0)