diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 314c31f11bf..4391bec9222 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -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; accountId: string; threadBindings: ThreadBindingManager; @@ -269,6 +275,48 @@ async function resolveDiscordModelPickerRoute(params: { }); } +export async function resolveDiscordNativeChoiceContext(params: { + interaction: AutocompleteInteraction; + cfg: ReturnType; + 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; route: ResolvedAgentRoute; diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts new file mode 100644 index 00000000000..2ec0aeb2d12 --- /dev/null +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -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(); + 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; + const discordConfig = {} as NonNullable["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; + }, + ) => Promise; + } + | 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; + }; + + 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"); + }); +}); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index d00fab280f0..5c15a1b9e50 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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; + 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)