Merge ca9d043e36ddcdbc3dd9c0ac7a567b333c865251 into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
Ted Li 2026-03-21 05:47:16 +00:00 committed by GitHub
commit d892563468
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 204 additions and 9 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,
@ -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<typeof loadConfig>;
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<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: {

View File

@ -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<typeof loadConfig>;
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<void>;
};
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");
});
});

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)