From c6770d36946843077b31bf67b53d86fa7732d094 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:30:49 +0100 Subject: [PATCH] fix: align native think menus with session models --- CHANGELOG.md | 1 + docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-subpaths.md | 4 +- docs/tools/slash-commands.md | 2 +- .../discord/src/monitor/native-command-ui.ts | 11 +- .../discord/src/monitor/native-command.ts | 15 ++ extensions/slack/src/monitor/slash.ts | 119 ++++++++++++-- ...ot-native-commands.fixture-test-support.ts | 2 + .../bot-native-commands.session-meta.test.ts | 152 ++++++++++++++++++ .../telegram/src/bot-native-commands.ts | 127 ++++++++++++--- src/auto-reply/commands-registry.shared.ts | 2 +- src/auto-reply/commands-registry.test.ts | 4 + src/auto-reply/commands-registry.ts | 32 +++- src/plugin-sdk/command-auth-native.ts | 2 + src/plugin-sdk/command-auth.ts | 1 + src/plugin-sdk/native-command-registry.ts | 1 + 16 files changed, 430 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 615d991bc4a..a4cc7be2326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai - Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. - Browser/existing-session: keep Chrome MCP status probes transport-only and ephemeral, and retry stale cached Playwright attaches once so idle profile checks no longer poison the next real attach. (#57245) Thanks @josephbergvinson. - Cron/exec: suppress automatic background exec completion wakes only for silent cron jobs with `delivery.mode="none"` while keeping webhook and announce runs observable. (#71391) Thanks @goldmar. +- Native commands: build dynamic `/think` argument menus from the target session model across Telegram, Discord fallback menus, and Slack so per-session `/model` overrides show the correct thinking choices. Fixes #69955. (#71394) Thanks @MonkeyLeeT. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. - Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana. diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index fd91c9f996f..d0ba5177ad9 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -299,7 +299,7 @@ releases. | `plugin-sdk/retry-runtime` | Retry helpers | `RetryConfig`, `retryAsync`, policy runners | | `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` | | `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | - | `plugin-sdk/command-auth` | Command gating and command-surface helpers | `resolveControlCommandGate`, sender-authorization helpers, command registry helpers | + | `plugin-sdk/command-auth` | Command gating and command-surface helpers | `resolveControlCommandGate`, sender-authorization helpers, command registry helpers including dynamic argument menu formatting | | `plugin-sdk/command-status` | Command status/help renderers | `buildCommandsMessage`, `buildCommandsMessagePaginated`, `buildHelpMessage` | | `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | | `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index e6b3a08d309..172c6af777f 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -112,7 +112,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | Subpath | Key exports | | --- | --- | - | `plugin-sdk/command-auth` | `resolveControlCommandGate`, command registry helpers, sender-authorization helpers | + | `plugin-sdk/command-auth` | `resolveControlCommandGate`, command registry helpers including dynamic argument menu formatting, sender-authorization helpers | | `plugin-sdk/command-status` | Command/help message builders such as `buildCommandsMessagePaginated` and `buildHelpMessage` | | `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers | | `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers | @@ -125,7 +125,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/approval-runtime` | Exec/plugin approval payload helpers, native approval routing/runtime helpers, and structured approval display helpers such as `formatApprovalDisplayPath` | | `plugin-sdk/reply-dedupe` | Narrow inbound reply dedupe reset helpers | | `plugin-sdk/channel-contract-testing` | Narrow channel contract test helpers without the broad testing barrel | - | `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers | + | `plugin-sdk/command-auth-native` | Native command auth, dynamic argument menu formatting, and native session-target helpers | | `plugin-sdk/command-detection` | Shared command detection helpers | | `plugin-sdk/command-primitives-runtime` | Lightweight command text predicates for hot channel paths | | `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers | diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 9dd552f1517..2dd44f9bf8e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -209,7 +209,7 @@ Notes: - By default, skill commands are forwarded to the model as a normal request. - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). - Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose). -- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. +- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. Dynamic choices are resolved against the target session model, so model-specific options such as `/think` levels follow that session's `/model` override. ## `/tools` diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 8f42098bd0e..28a0332ebf7 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -15,6 +15,7 @@ import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, + formatCommandArgMenuTitle, listChatCommands, resolveStoredModelOverride, serializeCommandArgs, @@ -54,6 +55,11 @@ import { resolveDiscordNativeInteractionChannelContext } from "./native-interact import type { ThreadBindingManager } from "./thread-bindings.js"; type DiscordConfig = NonNullable["discord"]; +type DiscordNativeChoiceInteraction = + | AutocompleteInteraction + | CommandInteraction + | ButtonInteraction + | StringSelectMenuInteraction; const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg"; @@ -280,7 +286,7 @@ async function resolveDiscordModelPickerRoute(params: { } export async function resolveDiscordNativeChoiceContext(params: { - interaction: AutocompleteInteraction; + interaction: DiscordNativeChoiceInteraction; cfg: ReturnType; accountId: string; threadBindings: ThreadBindingManager; @@ -1066,8 +1072,7 @@ export function buildDiscordCommandArgMenu(params: { ); return new Row(buttons); }); - const content = - menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`; + const content = formatCommandArgMenuTitle({ command, menu }); return { content, components: rows }; } diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index d4ad4dcfa34..e48919ebec5 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -991,10 +991,25 @@ async function dispatchDiscordCommandInteraction(params: { } } + const menuNeedsModelContext = + !(commandArgs?.raw && !commandArgs.values) && + command.args?.some( + (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, + ); + const menuModelContext = menuNeedsModelContext + ? await resolveDiscordNativeChoiceContext({ + interaction: interaction as CommandInteraction, + cfg, + accountId, + threadBindings, + }) + : null; const menu = resolveCommandArgMenu({ command, args: commandArgs, cfg, + provider: menuModelContext?.provider, + model: menuModelContext?.model, }); if (menu) { const menuPayload = buildDiscordCommandArgMenu({ diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index b25b59c562f..20ca5279425 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -1,6 +1,11 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import type { ChatCommandDefinition } from "openclaw/plugin-sdk/command-auth"; +import { + formatCommandArgMenuTitle, + resolveStoredModelOverride, + type ChatCommandDefinition, +} from "openclaw/plugin-sdk/command-auth"; import { type CommandArgs, resolveCommandAuthorizedFromAuthorizers, @@ -9,11 +14,18 @@ import { import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, + loadSessionStore, + resolveStorePath, } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { chunkItems, normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + chunkItems, + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedSlackAccount } from "../accounts.js"; import { truncateSlackText } from "../truncate.js"; import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js"; @@ -74,6 +86,51 @@ function loadSlashSkillCommandsRuntime() { return slashSkillCommandsRuntimePromise; } +function resolveSlackCommandMenuModelContext(params: { + cfg: SlackMonitorContext["cfg"]; + agentId: string; + sessionKey: string; +}): { provider?: string; model?: string } { + if (!params.sessionKey.trim()) { + return {}; + } + try { + const defaultModel = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const store = loadSessionStore(storePath); + const entry = store[params.sessionKey]; + if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { + return { provider: defaultModel.provider, model: defaultModel.model }; + } + const override = resolveStoredModelOverride({ + sessionEntry: entry, + sessionStore: store, + sessionKey: params.sessionKey, + defaultProvider: defaultModel.provider, + }); + if (override?.model) { + return { + provider: override.provider || defaultModel.provider, + model: override.model, + }; + } + const provider = + normalizeOptionalString(entry?.providerOverride) ?? + normalizeOptionalString(entry?.modelProvider); + const model = + normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model); + return { + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + }; + } catch { + return {}; + } +} + type EncodedMenuChoice = SlackExternalArgMenuChoice; const slackExternalArgMenuStore = createSlackExternalArgMenuStore(); @@ -496,17 +553,49 @@ export async function registerSlackMonitorSlashCommands(params: { } } + let resolvedSlashRoute: ResolvedAgentRoute | undefined; + const resolveSlashRoute = async () => { + if (resolvedSlashRoute) { + return resolvedSlashRoute; + } + const { resolveAgentRoute } = await loadSlashDispatchRuntime(); + resolvedSlashRoute = resolveAgentRoute({ + cfg, + channel: "slack", + accountId: account.accountId, + teamId: ctx.teamId || undefined, + peer: { + kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group", + id: isDirectMessage ? command.user_id : command.channel_id, + }, + }); + return resolvedSlashRoute; + }; + if (commandDefinition && supportsInteractiveArgMenus) { const { resolveCommandArgMenu } = await loadSlashCommandsRuntime(); + const menuNeedsModelContext = + !(commandArgs?.raw && !commandArgs.values) && + commandDefinition.args?.some( + (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, + ); + const menuRoute = menuNeedsModelContext ? await resolveSlashRoute() : undefined; + const menuModelContext = menuRoute + ? resolveSlackCommandMenuModelContext({ + cfg, + agentId: menuRoute.agentId, + sessionKey: menuRoute.sessionKey, + }) + : {}; const menu = resolveCommandArgMenu({ command: commandDefinition, args: commandArgs, cfg, + ...menuModelContext, }); if (menu) { const commandLabel = commandDefinition.nativeName ?? commandDefinition.key; - const title = - menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`; + const title = formatCommandArgMenuTitle({ command: commandDefinition, menu }); const blocks = buildSlackCommandArgMenuBlocks({ title, command: commandLabel, @@ -539,16 +628,18 @@ export async function registerSlackMonitorSlashCommands(params: { resolveMarkdownTableMode, } = await loadSlashDispatchRuntime(); - const route = resolveAgentRoute({ - cfg, - channel: "slack", - accountId: account.accountId, - teamId: ctx.teamId || undefined, - peer: { - kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group", - id: isDirectMessage ? command.user_id : command.channel_id, - }, - }); + const route = + resolvedSlashRoute ?? + resolveAgentRoute({ + cfg, + channel: "slack", + accountId: account.accountId, + teamId: ctx.teamId || undefined, + peer: { + kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group", + id: isDirectMessage ? command.user_id : command.channel_id, + }, + }); const { untrustedChannelMetadata, groupSystemPrompt } = resolveSlackRoomContextHints({ isRoomish, diff --git a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts index 13f57407ce1..7a67628e259 100644 --- a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts @@ -68,6 +68,7 @@ export function createTelegramPrivateCommandContext(params?: { chatId?: number; userId?: number; username?: string; + threadId?: number; }) { return { match: params?.match ?? "", @@ -75,6 +76,7 @@ export function createTelegramPrivateCommandContext(params?: { message_id: params?.messageId ?? 1, date: params?.date ?? Math.floor(Date.now() / 1000), chat: { id: params?.chatId ?? 100, type: "private" as const }, + ...(params?.threadId != null ? { message_thread_id: params.threadId } : {}), from: { id: params?.userId ?? 200, username: params?.username ?? "bob" }, }, }; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 801782d4f8b..c815633cc1e 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -42,9 +42,13 @@ const persistentBindingMocks = vi.hoisted(() => ({ })), })); const sessionMocks = vi.hoisted(() => ({ + loadSessionStore: vi.fn(), recordSessionMetaFromInbound: vi.fn(), resolveStorePath: vi.fn(), })); +const commandAuthMocks = vi.hoisted(() => ({ + resolveCommandArgMenu: vi.fn(), +})); const replyMocks = vi.hoisted(() => ({ dispatchReplyWithBufferedBlockDispatcher: vi.fn( async () => dispatchReplyResult, @@ -136,6 +140,26 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { }), }; }); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + loadSessionStore: sessionMocks.loadSessionStore, + resolveStorePath: sessionMocks.resolveStorePath, + }; +}); +vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/command-auth-native", + ); + commandAuthMocks.resolveCommandArgMenu.mockImplementation(actual.resolveCommandArgMenu); + return { + ...actual, + resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, + }; +}); vi.mock("./bot-native-commands.runtime.js", async () => { const actual = await vi.importActual( "./bot-native-commands.runtime.js", @@ -406,6 +430,8 @@ describe("registerTelegramNativeCommands — session metadata", () => { ); persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + commandAuthMocks.resolveCommandArgMenu.mockClear(); + sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); replyMocks.dispatchReplyWithBufferedBlockDispatcher @@ -432,6 +458,132 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(call?.sessionKey).toBe("agent:main:telegram:slash:200"); }); + it("uses the target session model when building native argument menus", async () => { + const cfg: OpenClawConfig = {}; + sessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "anthropic", + )?.[0]; + expect(menuCall).toEqual( + expect.objectContaining({ + provider: "anthropic", + model: "claude-opus-4-7", + }), + ); + expect(sessionMocks.loadSessionStore).toHaveBeenCalledWith("/tmp/openclaw-sessions.json"); + expect(sendMessage).toHaveBeenCalledWith( + 100, + expect.stringContaining("Choose level for /think."), + expect.objectContaining({ reply_markup: expect.any(Object) }), + ); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("inherits the parent session model when building DM thread native argument menus", async () => { + const cfg: OpenClawConfig = {}; + sessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext({ threadId: 77 })); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "anthropic", + )?.[0]; + expect(menuCall).toEqual( + expect.objectContaining({ + provider: "anthropic", + model: "claude-opus-4-7", + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + 100, + expect.stringContaining("Choose level for /think."), + expect.objectContaining({ reply_markup: expect.any(Object) }), + ); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses the configured default model instead of temporary auto fallback overrides", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + } as OpenClawConfig; + sessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "auto", + modelProvider: "anthropic", + model: "claude-opus-4-7", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "openai", + )?.[0]; + expect(menuCall).toEqual( + expect.objectContaining({ + provider: "openai", + model: "gpt-5.5", + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + 100, + expect.stringContaining("Choose level for /think."), + expect.objectContaining({ reply_markup: expect.any(Object) }), + ); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("does not load the session store when a native argument menu is skipped", async () => { + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg: {}, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext({ match: "high" })); + + expect(sessionMocks.loadSessionStore).not.toHaveBeenCalled(); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + }); + it("awaits session metadata persistence before dispatch", async () => { const deferred = createDeferred(); sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 98fb3f5504f..9449e0bc381 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,4 +1,5 @@ import type { Bot, Context } from "grammy"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; import { resolveCommandAuthorization, @@ -8,12 +9,19 @@ import { import { buildCommandTextFromArgs, findCommandByNativeName, + formatCommandArgMenuTitle, listNativeCommandSpecs, listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, + resolveStoredModelOverride, type CommandArgs, } from "openclaw/plugin-sdk/command-auth-native"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, +} from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import type { @@ -29,7 +37,10 @@ import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-sna import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; import { resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js"; @@ -135,6 +146,51 @@ function resolveTelegramProgressPlaceholder(command: { return text ? text : null; } +function resolveTelegramCommandMenuModelContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): { provider?: string; model?: string } { + if (!params.sessionKey.trim()) { + return {}; + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const defaultModel = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }); + const store = loadSessionStore(storePath); + const entry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing; + if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { + return { provider: defaultModel.provider, model: defaultModel.model }; + } + const override = resolveStoredModelOverride({ + sessionEntry: entry, + sessionStore: store, + sessionKey: params.sessionKey, + defaultProvider: defaultModel.provider, + }); + if (override?.model) { + return { + provider: override.provider || defaultModel.provider, + model: override.model, + }; + } + const provider = + normalizeOptionalString(entry?.providerOverride) ?? + normalizeOptionalString(entry?.modelProvider); + const model = + normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model); + return { + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + }; + } catch { + return {}; + } +} + function resolveTelegramNativeReplyChannelData( result: TelegramNativeReplyPayload, ): TelegramNativeReplyChannelData | undefined { @@ -790,17 +846,60 @@ export const registerTelegramNativeCommands = ({ : rawText ? `/${command.name} ${rawText}` : `/${command.name}`; + let cachedTargetSessionKey: string | undefined; + let cachedNativeCommandRuntime: + | Awaited> + | undefined; + const resolveNativeCommandRuntime = async () => { + cachedNativeCommandRuntime ??= await loadTelegramNativeCommandRuntime(); + return cachedNativeCommandRuntime; + }; + const resolveTargetSessionKey = async (): Promise => { + if (cachedTargetSessionKey) { + return cachedTargetSessionKey; + } + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg: runtimeCfg, + route, + chatId, + isGroup, + senderId, + }); + const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; + const threadKeys = + dmThreadId != null + ? (await resolveNativeCommandRuntime()).resolveThreadSessionKeys({ + baseSessionKey, + threadId: `${chatId}:${dmThreadId}`, + }) + : null; + cachedTargetSessionKey = threadKeys?.sessionKey ?? baseSessionKey; + return cachedTargetSessionKey; + }; + const menuNeedsModelContext = + commandDefinition?.argsMenu && + !(commandArgs?.raw && !commandArgs.values) && + commandDefinition.args?.some( + (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, + ); + const menuModelContext = + commandDefinition && menuNeedsModelContext + ? resolveTelegramCommandMenuModelContext({ + cfg: runtimeCfg, + agentId: route.agentId, + sessionKey: await resolveTargetSessionKey(), + }) + : {}; const menu = commandDefinition ? resolveCommandArgMenu({ command: commandDefinition, args: commandArgs, cfg: runtimeCfg, + ...menuModelContext, }) : null; if (menu && commandDefinition) { - const title = - menu.title ?? - `Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`; + const title = formatCommandArgMenuTitle({ command: commandDefinition, menu }); const rows: Array> = []; for (let i = 0; i < menu.choices.length; i += 2) { const slice = menu.choices.slice(i, i + 2); @@ -830,24 +929,8 @@ export const registerTelegramNativeCommands = ({ }); return; } - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ - cfg: runtimeCfg, - route, - chatId, - isGroup, - senderId, - }); - // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) - const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - const threadKeys = - dmThreadId != null - ? nativeCommandRuntime.resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${chatId}:${dmThreadId}`, - }) - : null; - const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; + const nativeCommandRuntime = await resolveNativeCommandRuntime(); + const sessionKey = await resolveTargetSessionKey(); const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ groupConfig, topicConfig, diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 3a8cddbd66c..21be6d60418 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -712,7 +712,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { args: [ { name: "level", - description: "off, minimal, low, medium, high, xhigh", + description: "Thinking level", type: "string", choices: ({ provider, model }) => listThinkingLevels(provider, model), }, diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index a4309e1232e..3bd86d24fee 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -5,6 +5,7 @@ import { buildCommandText, buildCommandTextFromArgs, findCommandByNativeName, + formatCommandArgMenuTitle, getCommandDetection, listChatCommands, listChatCommandsForConfig, @@ -484,6 +485,9 @@ describe("commands registry args", () => { { label: "low", value: "low" }, { label: "high", value: "high" }, ]); + expect(formatCommandArgMenuTitle({ command, menu: menu! })).toBe( + "Choose level for /think.\nOptions: low, high.", + ); const seenChoice = seen as { provider?: string; model?: string; diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 4fb7687b9d9..ba26a0d545b 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -285,8 +285,10 @@ export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs; cfg?: OpenClawConfig; + provider?: string; + model?: string; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null { - const { command, args, cfg } = params; + const { command, args, cfg, provider, model } = params; if (!command.args || !command.argsMenu) { return null; } @@ -296,7 +298,9 @@ export function resolveCommandArgMenu(params: { const argSpec = command.argsMenu; const argName = argSpec === "auto" - ? command.args.find((arg) => resolveCommandArgChoices({ command, arg, cfg }).length > 0)?.name + ? command.args.find( + (arg) => resolveCommandArgChoices({ command, arg, cfg, provider, model }).length > 0, + )?.name : argSpec.arg; if (!argName) { return null; @@ -311,7 +315,7 @@ export function resolveCommandArgMenu(params: { if (!arg) { return null; } - const choices = resolveCommandArgChoices({ command, arg, cfg }); + const choices = resolveCommandArgChoices({ command, arg, cfg, provider, model }); if (choices.length === 0) { return null; } @@ -319,6 +323,28 @@ export function resolveCommandArgMenu(params: { return { arg, choices, title }; } +export function formatCommandArgMenuTitle(params: { + command: ChatCommandDefinition; + menu: NonNullable>; +}): string { + const { command, menu } = params; + if (menu.title) { + return menu.title; + } + const commandLabel = command.nativeName ?? command.key; + if (typeof menu.arg.choices === "function") { + const options = menu.choices + .map((choice) => choice.label.trim()) + .filter(Boolean) + .join(", "); + if (options.length > 0 && options.length <= 160) { + return `Choose ${menu.arg.name} for /${commandLabel}.\nOptions: ${options}.`; + } + return `Choose ${menu.arg.name} for /${commandLabel}.`; + } + return `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`; +} + export function isCommandMessage(raw: string): boolean { const trimmed = normalizeCommandBody(raw); return trimmed.startsWith("/"); diff --git a/src/plugin-sdk/command-auth-native.ts b/src/plugin-sdk/command-auth-native.ts index 67d8e22d295..c032cd85b1e 100644 --- a/src/plugin-sdk/command-auth-native.ts +++ b/src/plugin-sdk/command-auth-native.ts @@ -1,6 +1,7 @@ export { buildCommandTextFromArgs, findCommandByNativeName, + formatCommandArgMenuTitle, listNativeCommandSpecs, listNativeCommandSpecsForConfig, parseCommandArgs, @@ -16,3 +17,4 @@ export { resolveCommandAuthorization, type CommandAuthorization, } from "../auto-reply/command-auth.js"; +export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 2b5ef25a8d8..9f15d842a12 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -23,6 +23,7 @@ export { buildCommandText, buildCommandTextFromArgs, findCommandByNativeName, + formatCommandArgMenuTitle, getCommandDetection, isCommandEnabled, isCommandMessage, diff --git a/src/plugin-sdk/native-command-registry.ts b/src/plugin-sdk/native-command-registry.ts index 4f830af1283..663d5ffd232 100644 --- a/src/plugin-sdk/native-command-registry.ts +++ b/src/plugin-sdk/native-command-registry.ts @@ -1,6 +1,7 @@ export { buildCommandTextFromArgs, findCommandByNativeName, + formatCommandArgMenuTitle, listChatCommands, listNativeCommandSpecsForConfig, parseCommandArgs,