




















@@ -1,8 +1,16 @@
1+import { mkdtemp, rm } from "node:fs/promises";
2+import os from "node:os";
3+import path from "node:path";
14import { ChannelType } from "discord-api-types/v10";
25import * as commandRegistryModule from "openclaw/plugin-sdk/command-auth";
36import type { ChatCommandDefinition, CommandArgsParsing } from "openclaw/plugin-sdk/command-auth";
47import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
5-import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
8+import {
9+loadSessionStore,
10+resolveStorePath,
11+saveSessionStore,
12+type OpenClawConfig,
13+} from "openclaw/plugin-sdk/config-runtime";
614import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
715import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
816import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -40,6 +48,8 @@ type MockInteraction = {
4048client: object;
4149};
425051+let tempDir: string;
52+4353function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
4454return createBaseModelsProviderData(entries, { defaultProviderOrder: "sorted" });
4555}
@@ -61,6 +71,9 @@ async function waitForCondition(
61716272function createModelPickerContext(): ModelPickerContext {
6373const cfg = {
74+session: {
75+store: path.join(tempDir, "sessions.json"),
76+},
6477channels: {
6578discord: {
6679dm: {
@@ -162,7 +175,7 @@ async function safeInteractionCall<T>(_label: string, fn: () => Promise<T>): Pro
162175}
163176164177function createDispatchSpy() {
165-return vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue();
178+return vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({ accepted: true });
166179}
167180168181function createModelPickerFallbackButton(
@@ -264,13 +277,15 @@ function createBoundThreadBindingManager(params: {
264277}
265278266279describe("Discord model picker interactions", () => {
267-beforeEach(() => {
280+beforeEach(async () => {
281+tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-discord-model-picker-"));
268282vi.useRealTimers();
269283vi.restoreAllMocks();
270284});
271285272-afterEach(() => {
286+afterEach(async () => {
273287vi.useRealTimers();
288+await rm(tempDir, { recursive: true, force: true });
274289});
275290276291it("registers distinct fallback ids for button and select handlers", () => {
@@ -619,6 +634,103 @@ describe("Discord model picker interactions", () => {
619634expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
620635});
621636637+it("persists suffixed LM Studio model overrides when dispatch leaves the routed session stale", async () => {
638+const context = createModelPickerContext();
639+context.threadBindings = createBoundThreadBindingManager({
640+accountId: "default",
641+threadId: "thread-bound",
642+targetSessionKey: "agent:worker:subagent:bound",
643+agentId: "worker",
644+});
645+const pickerData = createModelsProviderData({
646+anthropic: ["claude-sonnet-4-5"],
647+lmstudio: ["unsloth/gemma-4-26b-a4b-it@iq4_xs"],
648+});
649+const modelCommand = createModelCommandDefinition();
650+const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
651+await saveSessionStore(storePath, {
652+"agent:worker:subagent:bound": {
653+updatedAt: Date.now(),
654+sessionId: "bound-session",
655+},
656+});
657+658+vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
659+mockModelCommandPipeline(modelCommand);
660+661+const dispatchSpy = createDispatchSpy();
662+const button = createModelPickerFallbackButton(context, dispatchSpy);
663+const submitInteraction = createInteraction({ userId: "owner" });
664+submitInteraction.channel = {
665+type: ChannelType.PublicThread,
666+id: "thread-bound",
667+};
668+669+await button.run(submitInteraction as unknown as PickerButtonInteraction, {
670+ ...createModelsViewSubmitData(),
671+p: "lmstudio",
672+mi: "1",
673+});
674+675+const store = loadSessionStore(storePath, { skipCache: true });
676+expect(store["agent:worker:subagent:bound"]?.providerOverride).toBe("lmstudio");
677+expect(store["agent:worker:subagent:bound"]?.modelOverride).toBe(
678+"unsloth/gemma-4-26b-a4b-it@iq4_xs",
679+);
680+expect(store["agent:worker:subagent:bound"]?.liveModelSwitchPending).toBe(true);
681+expectDispatchedModelSelection({
682+ dispatchSpy,
683+model: "lmstudio/unsloth/gemma-4-26b-a4b-it@iq4_xs",
684+});
685+expect(JSON.stringify(submitInteraction.followUp.mock.calls[0]?.[0])).toContain(
686+"✅ Model set to lmstudio/unsloth/gemma-4-26b-a4b-it@iq4_xs.",
687+);
688+});
689+690+it("does not write a fallback override when hidden /model dispatch is rejected", async () => {
691+const context = createModelPickerContext();
692+context.threadBindings = createBoundThreadBindingManager({
693+accountId: "default",
694+threadId: "thread-bound",
695+targetSessionKey: "agent:worker:subagent:bound",
696+agentId: "worker",
697+});
698+const pickerData = createDefaultModelPickerData();
699+const modelCommand = createModelCommandDefinition();
700+const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
701+await saveSessionStore(storePath, {
702+"agent:worker:subagent:bound": {
703+updatedAt: Date.now(),
704+sessionId: "bound-session",
705+},
706+});
707+708+vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
709+mockModelCommandPipeline(modelCommand);
710+711+const button = createModelPickerFallbackButton(
712+context,
713+vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({ accepted: false }),
714+);
715+const submitInteraction = createInteraction({ userId: "owner" });
716+submitInteraction.channel = {
717+type: ChannelType.PublicThread,
718+id: "thread-bound",
719+};
720+721+await button.run(
722+submitInteraction as unknown as PickerButtonInteraction,
723+createModelsViewSubmitData(),
724+);
725+726+const store = loadSessionStore(storePath, { skipCache: true });
727+expect(store["agent:worker:subagent:bound"]?.providerOverride).toBeUndefined();
728+expect(store["agent:worker:subagent:bound"]?.modelOverride).toBeUndefined();
729+expect(JSON.stringify(submitInteraction.followUp.mock.calls[0]?.[0])).toContain(
730+"❌ Failed to apply openai/gpt-4o.",
731+);
732+});
733+622734it("loads model picker data from the effective bound route", async () => {
623735const context = createModelPickerContext();
624736context.threadBindings = createBoundThreadBindingManager({
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。