Discord: use session model for /think autocomplete

This commit is contained in:
ted 2026-03-17 10:55:39 -07:00
parent c7134e629c
commit d775c9499e
3 changed files with 181 additions and 3 deletions

View File

@ -5,12 +5,14 @@ import {
Row,
StringSelectMenu,
TextDisplay,
type AutocompleteInteraction,
type ButtonInteraction,
type CommandInteraction,
type ComponentData,
type StringSelectMenuInteraction,
} from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
@ -218,7 +220,11 @@ function buildDiscordModelPickerNoticePayload(message: string): { components: Co
}
async function resolveDiscordModelPickerRoute(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
interaction:
| CommandInteraction
| ButtonInteraction
| StringSelectMenuInteraction
| AutocompleteInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
@ -269,6 +275,48 @@ async function resolveDiscordModelPickerRoute(params: {
});
}
export async function resolveDiscordNativeChoiceContext(params: {
interaction: AutocompleteInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
}): Promise<{ provider?: string; model?: string } | null> {
try {
const route = await resolveDiscordModelPickerRoute({
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
threadBindings: params.threadBindings,
});
const fallback = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: route.agentId,
});
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: route.agentId,
});
const sessionStore = loadSessionStore(storePath);
const sessionEntry = sessionStore[route.sessionKey];
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey: route.sessionKey,
});
if (!override?.model) {
return {
provider: fallback.provider,
model: fallback.model,
};
}
return {
provider: override.provider || fallback.provider,
model: override.model,
};
} catch {
return null;
}
}
function resolveDiscordModelPickerCurrentModel(params: {
cfg: ReturnType<typeof loadConfig>;
route: ResolvedAgentRoute;

View File

@ -0,0 +1,109 @@
import { ChannelType, type AutocompleteInteraction } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listNativeCommandSpecs } from "../../../../src/auto-reply/commands-registry.js";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
const mocks = vi.hoisted(() => ({
resolveBoundConversationRoute: vi.fn(),
loadSessionStore: vi.fn(),
resolveStorePath: vi.fn(),
}));
vi.mock("./route-resolution.js", () => ({
resolveDiscordBoundConversationRoute: mocks.resolveBoundConversationRoute,
resolveDiscordEffectiveRoute: vi.fn(),
}));
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
return {
...actual,
loadSessionStore: mocks.loadSessionStore,
resolveStorePath: mocks.resolveStorePath,
};
});
describe("discord native /think autocomplete", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveBoundConversationRoute.mockResolvedValue({
agentId: "main",
sessionKey: "discord:session:1",
});
mocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.mock.json");
mocks.loadSessionStore.mockReturnValue({
"discord:session:1": {
providerOverride: "openai-codex",
modelOverride: "gpt-5.4",
},
});
});
it("uses bound session model override for /think choices", async () => {
const spec = listNativeCommandSpecs({ provider: "discord" }).find(
(entry) => entry.name === "think",
);
expect(spec).toBeTruthy();
if (!spec) {
return;
}
const cfg = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4.5",
},
},
},
} as ReturnType<typeof loadConfig>;
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
const command = createDiscordNativeCommand({
command: spec,
cfg,
discordConfig,
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const levelOption = command.options?.find((entry) => entry.name === "level") as
| {
autocomplete?: (
interaction: AutocompleteInteraction & {
respond: (choices: Array<{ name: string; value: string }>) => Promise<void>;
},
) => Promise<void>;
}
| undefined;
expect(typeof levelOption?.autocomplete).toBe("function");
if (typeof levelOption?.autocomplete !== "function") {
return;
}
const respond = vi.fn(async (_choices: Array<{ name: string; value: string }>) => {});
const interaction = {
options: {
getFocused: () => ({ value: "xh" }),
},
respond,
rawData: {},
channel: { id: "D1", type: ChannelType.DM },
user: { id: "U1" },
guild: undefined,
client: {},
} as unknown as AutocompleteInteraction & {
respond: (choices: Array<{ name: string; value: string }>) => Promise<void>;
};
await levelOption.autocomplete(interaction);
expect(respond).toHaveBeenCalledTimes(1);
const choices = respond.mock.calls[0]?.[0] ?? [];
const values = choices.map((choice) => choice.value);
expect(values).toContain("xhigh");
});
});

View File

@ -73,6 +73,7 @@ import {
createDiscordModelPickerFallbackButton as createDiscordModelPickerFallbackButtonUi,
createDiscordModelPickerFallbackSelect as createDiscordModelPickerFallbackSelectUi,
replyWithDiscordModelPickerProviders,
resolveDiscordNativeChoiceContext,
shouldOpenDiscordModelPickerFromCommand,
type DiscordCommandArgContext,
type DiscordModelPickerContext,
@ -124,8 +125,11 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
function buildDiscordCommandOptions(params: {
command: ChatCommandDefinition;
cfg: ReturnType<typeof loadConfig>;
resolveChoiceContext?: (
interaction: AutocompleteInteraction,
) => Promise<{ provider?: string; model?: string } | null>;
}): CommandOptions | undefined {
const { command, cfg } = params;
const { command, cfg, resolveChoiceContext } = params;
const args = command.args;
if (!args || args.length === 0) {
return undefined;
@ -158,7 +162,17 @@ function buildDiscordCommandOptions(params: {
const focused = interaction.options.getFocused();
const focusValue =
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const context =
typeof arg.choices === "function" && resolveChoiceContext
? await resolveChoiceContext(interaction)
: null;
const choices = resolveCommandArgChoices({
command,
arg,
cfg,
provider: context?.provider,
model: context?.model,
});
const filtered = focusValue
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices;
@ -297,6 +311,13 @@ export function createDiscordNativeCommand(params: {
const commandOptions = buildDiscordCommandOptions({
command: commandDefinition,
cfg,
resolveChoiceContext: async (interaction) =>
resolveDiscordNativeChoiceContext({
interaction,
cfg,
accountId,
threadBindings,
}),
});
const options = commandOptions
? (commandOptions satisfies CommandOptions)