diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 314c31f11bf..69cab68171e 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, @@ -25,6 +27,7 @@ import { } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { resolveConfiguredBindingRoute } from "openclaw/plugin-sdk/conversation-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems, withTimeout } from "openclaw/plugin-sdk/text-runtime"; @@ -45,7 +48,10 @@ import { toDiscordModelPickerMessagePayload, type DiscordModelPickerCommandContext, } from "./model-picker.js"; -import { resolveDiscordBoundConversationRoute } from "./route-resolution.js"; +import { + resolveDiscordBoundConversationRoute, + resolveDiscordEffectiveRoute, +} from "./route-resolution.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; @@ -218,7 +224,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; @@ -252,10 +262,7 @@ async function resolveDiscordModelPickerRoute(params: { threadParentId = parentInfo.id; } - const threadBinding = isThreadChannel - ? params.threadBindings.getByThreadId(rawChannelId) - : undefined; - return resolveDiscordBoundConversationRoute({ + const route = resolveDiscordBoundConversationRoute({ cfg, accountId, guildId: interaction.guild?.id ?? undefined, @@ -265,8 +272,74 @@ async function resolveDiscordModelPickerRoute(params: { directUserId: interaction.user?.id ?? rawChannelId, conversationId: rawChannelId, parentConversationId: threadParentId, - boundSessionKey: threadBinding?.targetSessionKey, }); + const threadBinding = isThreadChannel + ? params.threadBindings.getByThreadId(rawChannelId) + : undefined; + const configuredRoute = + threadBinding == null + ? resolveConfiguredBindingRoute({ + cfg, + route, + conversation: { + channel: "discord", + accountId, + conversationId: rawChannelId, + parentConversationId: threadParentId, + }, + }) + : null; + const configuredBinding = configuredRoute?.bindingResolution ?? null; + const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined; + const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey; + return resolveDiscordEffectiveRoute({ + route, + boundSessionKey, + configuredRoute, + matchedBy: configuredBinding ? "binding.channel" : undefined, + }); +} + +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: { 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..70ae3e32671 --- /dev/null +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ChannelType, type AutocompleteInteraction } from "@buape/carbon"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + findCommandByNativeName, + resolveCommandArgChoices, +} from "../../../../src/auto-reply/commands-registry.js"; +import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js"; +import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions/store.js"; +import { resolveDiscordNativeChoiceContext } from "./native-command-ui.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +const STORE_PATH = path.join( + os.tmpdir(), + `openclaw-discord-think-autocomplete-${process.pid}.json`, +); +const SESSION_KEY = "agent:main:main"; + +describe("discord native /think autocomplete", () => { + beforeEach(() => { + clearSessionStoreCacheForTest(); + fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); + fs.writeFileSync( + STORE_PATH, + JSON.stringify({ + [SESSION_KEY]: { + updatedAt: Date.now(), + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + }, + }), + "utf8", + ); + }); + + afterEach(() => { + clearSessionStoreCacheForTest(); + try { + fs.unlinkSync(STORE_PATH); + } catch {} + }); + + it("uses the session override context for /think choices", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4.5", + }, + }, + }, + session: { + store: STORE_PATH, + }, + } as ReturnType; + const interaction = { + options: { + getFocused: () => ({ value: "xh" }), + }, + respond: async (_choices: Array<{ name: string; value: string }>) => {}, + 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; + }; + + const command = findCommandByNativeName("think", "discord"); + expect(command).toBeTruthy(); + const levelArg = command?.args?.find((entry) => entry.name === "level"); + expect(levelArg).toBeTruthy(); + if (!command || !levelArg) { + return; + } + + const context = await resolveDiscordNativeChoiceContext({ + interaction, + cfg, + accountId: "default", + threadBindings: createNoopThreadBindingManager("default"), + }); + expect(context).toEqual({ + provider: "openai-codex", + model: "gpt-5.4", + }); + + const choices = resolveCommandArgChoices({ + command, + arg: levelArg, + cfg, + provider: context?.provider, + model: context?.model, + }); + 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)