From d775c9499e826fdb7a24366c52f8bbfb95c19ffa Mon Sep 17 00:00:00 2001 From: ted Date: Tue, 17 Mar 2026 10:55:39 -0700 Subject: [PATCH 1/5] Discord: use session model for /think autocomplete --- .../discord/src/monitor/native-command-ui.ts | 50 +++++++- .../native-command.think-autocomplete.test.ts | 109 ++++++++++++++++++ .../discord/src/monitor/native-command.ts | 25 +++- 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 extensions/discord/src/monitor/native-command.think-autocomplete.test.ts 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) From b169d912a1f013caa41d1d9dc9d6b72fc91eb265 Mon Sep 17 00:00:00 2001 From: ted Date: Tue, 17 Mar 2026 11:26:48 -0700 Subject: [PATCH 2/5] Discord: use cached session store in think autocomplete --- .../src/monitor/native-command.think-autocomplete.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index 2ec0aeb2d12..29bb106da03 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -105,5 +105,7 @@ describe("discord native /think autocomplete", () => { const choices = respond.mock.calls[0]?.[0] ?? []; const values = choices.map((choice) => choice.value); expect(values).toContain("xhigh"); + expect(mocks.loadSessionStore).toHaveBeenCalledWith("/tmp/openclaw-sessions.mock.json"); + expect(mocks.loadSessionStore.mock.calls[0]?.[1]).toBeUndefined(); }); }); From 074d08dc76267c4e10cc99c91a51f54aad726790 Mon Sep 17 00:00:00 2001 From: ted Date: Tue, 17 Mar 2026 23:34:38 -0700 Subject: [PATCH 3/5] Discord: align think autocomplete with effective bound route --- .../discord/src/monitor/native-command-ui.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 4391bec9222..b32e7f51d1c 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -13,6 +13,7 @@ import { } from "@buape/carbon"; import { ButtonStyle } from "discord-api-types/v10"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveConfiguredBindingRoute } from "openclaw/plugin-sdk/conversation-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -47,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"; @@ -258,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, @@ -271,7 +272,31 @@ 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, }); } From 2ff1434356538bec489a9264acfed8085073950f Mon Sep 17 00:00:00 2001 From: ted Date: Tue, 17 Mar 2026 23:51:15 -0700 Subject: [PATCH 4/5] Discord: fix think autocomplete route-resolution test mocks --- .../src/monitor/native-command.think-autocomplete.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index 29bb106da03..50826add507 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -7,13 +7,16 @@ import { createNoopThreadBindingManager } from "./thread-bindings.js"; const mocks = vi.hoisted(() => ({ resolveBoundConversationRoute: vi.fn(), + resolveEffectiveRoute: vi.fn((params: { route: { agentId: string; sessionKey: string } }) => { + return params.route; + }), loadSessionStore: vi.fn(), resolveStorePath: vi.fn(), })); vi.mock("./route-resolution.js", () => ({ resolveDiscordBoundConversationRoute: mocks.resolveBoundConversationRoute, - resolveDiscordEffectiveRoute: vi.fn(), + resolveDiscordEffectiveRoute: mocks.resolveEffectiveRoute, })); vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { @@ -28,7 +31,7 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { describe("discord native /think autocomplete", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.resolveBoundConversationRoute.mockResolvedValue({ + mocks.resolveBoundConversationRoute.mockReturnValue({ agentId: "main", sessionKey: "discord:session:1", }); From ca9d043e36ddcdbc3dd9c0ac7a567b333c865251 Mon Sep 17 00:00:00 2001 From: ted Date: Fri, 20 Mar 2026 22:47:08 -0700 Subject: [PATCH 5/5] Discord: stabilize think autocomplete CI coverage --- .../discord/src/monitor/native-command-ui.ts | 2 +- .../native-command.think-autocomplete.test.ts | 141 ++++++++---------- 2 files changed, 65 insertions(+), 78 deletions(-) diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index b32e7f51d1c..69cab68171e 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -13,7 +13,6 @@ import { } from "@buape/carbon"; import { ButtonStyle } from "discord-api-types/v10"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveConfiguredBindingRoute } from "openclaw/plugin-sdk/conversation-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -28,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"; diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index 50826add507..70ae3e32671 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -1,58 +1,48 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; 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 { 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 { createDiscordNativeCommand } from "./native-command.js"; +import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions/store.js"; +import { resolveDiscordNativeChoiceContext } from "./native-command-ui.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; -const mocks = vi.hoisted(() => ({ - resolveBoundConversationRoute: vi.fn(), - resolveEffectiveRoute: vi.fn((params: { route: { agentId: string; sessionKey: string } }) => { - return params.route; - }), - loadSessionStore: vi.fn(), - resolveStorePath: vi.fn(), -})); - -vi.mock("./route-resolution.js", () => ({ - resolveDiscordBoundConversationRoute: mocks.resolveBoundConversationRoute, - resolveDiscordEffectiveRoute: mocks.resolveEffectiveRoute, -})); - -vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadSessionStore: mocks.loadSessionStore, - resolveStorePath: mocks.resolveStorePath, - }; -}); +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(() => { - vi.clearAllMocks(); - mocks.resolveBoundConversationRoute.mockReturnValue({ - 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", - }, - }); + 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", + ); }); - 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; - } + afterEach(() => { + clearSessionStoreCacheForTest(); + try { + fs.unlinkSync(STORE_PATH); + } catch {} + }); + it("uses the session override context for /think choices", async () => { const cfg = { agents: { defaults: { @@ -61,38 +51,15 @@ describe("discord native /think autocomplete", () => { }, }, }, + session: { + store: STORE_PATH, + }, } 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, + respond: async (_choices: Array<{ name: string; value: string }>) => {}, rawData: {}, channel: { id: "D1", type: ChannelType.DM }, user: { id: "U1" }, @@ -102,13 +69,33 @@ describe("discord native /think autocomplete", () => { respond: (choices: Array<{ name: string; value: string }>) => Promise; }; - await levelOption.autocomplete(interaction); + const command = findCommandByNativeName("think", "discord"); + expect(command).toBeTruthy(); + const levelArg = command?.args?.find((entry) => entry.name === "level"); + expect(levelArg).toBeTruthy(); + if (!command || !levelArg) { + return; + } - expect(respond).toHaveBeenCalledTimes(1); - const choices = respond.mock.calls[0]?.[0] ?? []; + 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"); - expect(mocks.loadSessionStore).toHaveBeenCalledWith("/tmp/openclaw-sessions.mock.json"); - expect(mocks.loadSessionStore.mock.calls[0]?.[1]).toBeUndefined(); }); });