From f771ba8de94dd5d6acb8ad048cf4f211c8092c3a Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 16:04:07 -0800 Subject: [PATCH 01/69] fix(memory): avoid destructive qmd collection rebinds --- CHANGELOG.md | 1 + src/memory/qmd-manager.test.ts | 24 +++++------------------- src/memory/qmd-manager.ts | 5 +++-- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3a805bf69..0c5bfcdc420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai - Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz. - Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai. - Memory/QMD index isolation: set `QMD_CONFIG_DIR` alongside `XDG_CONFIG_HOME` so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind. +- Memory/QMD collection safety: stop destructive collection rebinds when QMD `collection list` only reports names without path metadata, preventing `memory search` from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna. - Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed `embedQuery` + `embedBatch` concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark. - CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc. - ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 0532dd6099e..43f7c55be50 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -369,7 +369,7 @@ describe("QmdMemoryManager", () => { expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions")); }); - it("rebinds managed collections when qmd only reports collection names", async () => { + it("avoids destructive rebind when qmd only reports collection names", async () => { cfg = { ...cfg, memory: { @@ -401,25 +401,11 @@ describe("QmdMemoryManager", () => { await manager.close(); const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - const removeSessions = commands.find( - (args) => - args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName, - ); - expect(removeSessions).toBeDefined(); - const removeWorkspace = commands.find( - (args) => - args[0] === "collection" && args[1] === "remove" && args[2] === `workspace-${agentId}`, - ); - expect(removeWorkspace).toBeDefined(); + const removeCalls = commands.filter((args) => args[0] === "collection" && args[1] === "remove"); + expect(removeCalls).toHaveLength(0); - const addSessions = commands.find((args) => { - if (args[0] !== "collection" || args[1] !== "add") { - return false; - } - const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === sessionCollectionName; - }); - expect(addSessions).toBeDefined(); + const addCalls = commands.filter((args) => args[0] === "collection" && args[1] === "add"); + expect(addCalls).toHaveLength(0); }); it("migrates unscoped legacy collections before adding scoped names", async () => { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 789b88f6f6c..454cad6833a 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -568,8 +568,9 @@ export class QmdMemoryManager implements MemorySearchManager { private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean { if (!listed.path) { // Older qmd versions may only return names from `collection list --json`. - // Rebind managed collections so stale path bindings cannot survive upgrades. - return true; + // Do not perform destructive rebinds when metadata is incomplete: remove+add + // can permanently drop collections if add fails (for example on timeout). + return false; } if (!this.pathsMatch(listed.path, collection.path)) { return true; From 6dfd39c32f7905d633dd9f41c202cf47cc05d492 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 5 Mar 2026 19:24:43 -0500 Subject: [PATCH 02/69] Harden Telegram poll gating and schema consistency (#36547) Merged via squash. Prepared head SHA: f77824419e3d166f727474a9953a063a2b4547f2 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/automation/poll.md | 19 +- docs/channels/telegram.md | 23 +++ src/agents/channel-tools.test.ts | 37 +++- src/agents/tools/common.params.test.ts | 10 + src/agents/tools/common.ts | 6 +- src/agents/tools/discord-actions-messaging.ts | 41 ++-- src/agents/tools/discord-actions.test.ts | 26 +++ src/agents/tools/message-tool.test.ts | 88 ++++++++- src/agents/tools/message-tool.ts | 53 ++++- src/agents/tools/telegram-actions.test.ts | 90 +++++++++ src/agents/tools/telegram-actions.ts | 67 ++++++- src/channels/plugins/actions/actions.test.ts | 182 ++++++++++++++++++ .../plugins/actions/discord/handle-action.ts | 12 +- src/channels/plugins/actions/telegram.ts | 58 +++++- src/channels/plugins/types.core.ts | 6 + src/commands/message.test.ts | 48 +++++ src/config/telegram-actions-poll.test.ts | 36 ++++ src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + .../outbound/message-action-runner.test.ts | 174 +++++++++++++++++ src/infra/outbound/message-action-runner.ts | 19 +- src/poll-params.test.ts | 60 ++++++ src/poll-params.ts | 89 +++++++++ src/polls.ts | 7 + src/telegram/accounts.test.ts | 21 ++ src/telegram/accounts.ts | 18 ++ 27 files changed, 1129 insertions(+), 65 deletions(-) create mode 100644 src/config/telegram-actions-poll.test.ts create mode 100644 src/poll-params.test.ts create mode 100644 src/poll-params.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5bfcdc420..786eccfabb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,7 @@ Docs: https://docs.openclaw.ai - Feishu/groupPolicy legacy alias compatibility: treat legacy `groupPolicy: "allowall"` as `open` in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when `groupAllowFrom` is empty. (from #36358) Thanks @Sid-Qin. - Mattermost/plugin SDK import policy: replace remaining monolithic `openclaw/plugin-sdk` imports in Mattermost mention-gating paths/tests with scoped subpaths (`openclaw/plugin-sdk/compat` and `openclaw/plugin-sdk/mattermost`) so `pnpm check` passes `lint:plugins:no-monolithic-plugin-sdk-entry-imports` on baseline. (#36480) Thanks @Takhoffman. +- Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (`sendMessage` + `poll`). (#36547) thanks @gumadeiras. - Agents/failover cooldown classification: stop treating generic `cooling down` text as provider `rate_limit` so healthy models no longer show false global cooldown/rate-limit warnings while explicit `model_cooldown` markers still trigger failover. (#32972) thanks @stakeswky. - Agents/failover service-unavailable handling: stop treating bare proxy/CDN `service unavailable` errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode. diff --git a/docs/automation/poll.md b/docs/automation/poll.md index fab0b0e0738..acf03aa2903 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -10,6 +10,7 @@ title: "Polls" ## Supported channels +- Telegram - WhatsApp (web channel) - Discord - MS Teams (Adaptive Cards) @@ -17,6 +18,13 @@ title: "Polls" ## CLI ```bash +# Telegram +openclaw message poll --channel telegram --target 123456789 \ + --poll-question "Ship it?" --poll-option "Yes" --poll-option "No" +openclaw message poll --channel telegram --target -1001234567890:topic:42 \ + --poll-question "Pick a time" --poll-option "10am" --poll-option "2pm" \ + --poll-duration-seconds 300 + # WhatsApp openclaw message poll --target +15555550123 \ --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" @@ -36,9 +44,11 @@ openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv Options: -- `--channel`: `whatsapp` (default), `discord`, or `msteams` +- `--channel`: `whatsapp` (default), `telegram`, `discord`, or `msteams` - `--poll-multi`: allow selecting multiple options - `--poll-duration-hours`: Discord-only (defaults to 24 when omitted) +- `--poll-duration-seconds`: Telegram-only (5-600 seconds) +- `--poll-anonymous` / `--poll-public`: Telegram-only poll visibility ## Gateway RPC @@ -51,11 +61,14 @@ Params: - `options` (string[], required) - `maxSelections` (number, optional) - `durationHours` (number, optional) +- `durationSeconds` (number, optional, Telegram-only) +- `isAnonymous` (boolean, optional, Telegram-only) - `channel` (string, optional, default: `whatsapp`) - `idempotencyKey` (string, required) ## Channel differences +- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls. - WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. - Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. - MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored. @@ -64,6 +77,10 @@ Params: Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `channel`). +For Telegram, the tool also accepts `pollDurationSeconds`, `pollAnonymous`, and `pollPublic`. + +Use `action: "poll"` for poll creation. Poll fields passed with `action: "send"` are rejected. + Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select. Teams polls are rendered as Adaptive Cards and require the gateway to stay online to record votes in `~/.openclaw/msteams-polls.json`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index d3fdeff31ea..58fbe8b9023 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -732,6 +732,28 @@ openclaw message send --channel telegram --target 123456789 --message "hi" openclaw message send --channel telegram --target @name --message "hi" ``` + Telegram polls use `openclaw message poll` and support forum topics: + +```bash +openclaw message poll --channel telegram --target 123456789 \ + --poll-question "Ship it?" --poll-option "Yes" --poll-option "No" +openclaw message poll --channel telegram --target -1001234567890:topic:42 \ + --poll-question "Pick a time" --poll-option "10am" --poll-option "2pm" \ + --poll-duration-seconds 300 --poll-public +``` + + Telegram-only poll flags: + + - `--poll-duration-seconds` (5-600) + - `--poll-anonymous` + - `--poll-public` + - `--thread-id` for forum topics (or use a `:topic:` target) + + Action gating: + + - `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls + - `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled + @@ -813,6 +835,7 @@ Primary reference: - `channels.telegram.tokenFile`: read token from file path. - `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows. +- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`). - `channels.telegram.defaultTo`: default Telegram target used by CLI `--deliver` when no explicit `--reply-to` is provided. - `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`). diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index c9e125ab3ca..26552f81f9f 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -4,7 +4,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { defaultRuntime } from "../runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; -import { __testing, listAllChannelSupportedActions } from "./channel-tools.js"; +import { + __testing, + listAllChannelSupportedActions, + listChannelSupportedActions, +} from "./channel-tools.js"; describe("channel tools", () => { const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); @@ -49,4 +53,35 @@ describe("channel tools", () => { expect(listAllChannelSupportedActions({ cfg })).toEqual([]); expect(errorSpy).toHaveBeenCalledTimes(1); }); + + it("does not infer poll actions from outbound adapters when action discovery omits them", () => { + const plugin: ChannelPlugin = { + id: "polltest", + meta: { + id: "polltest", + label: "Poll Test", + selectionLabel: "Poll Test", + docsPath: "/channels/polltest", + blurb: "poll plugin", + }, + capabilities: { chatTypes: ["direct"], polls: true }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => [], + }, + outbound: { + deliveryMode: "gateway", + sendPoll: async () => ({ channel: "polltest", messageId: "poll-1" }), + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "polltest", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "polltest" })).toEqual([]); + expect(listAllChannelSupportedActions({ cfg })).toEqual([]); + }); }); diff --git a/src/agents/tools/common.params.test.ts b/src/agents/tools/common.params.test.ts index d93038cd606..32eb63d036e 100644 --- a/src/agents/tools/common.params.test.ts +++ b/src/agents/tools/common.params.test.ts @@ -48,6 +48,16 @@ describe("readNumberParam", () => { expect(readNumberParam(params, "messageId")).toBe(42); }); + it("keeps partial parse behavior by default", () => { + const params = { messageId: "42abc" }; + expect(readNumberParam(params, "messageId")).toBe(42); + }); + + it("rejects partial numeric strings when strict is enabled", () => { + const params = { messageId: "42abc" }; + expect(readNumberParam(params, "messageId", { strict: true })).toBeUndefined(); + }); + it("truncates when integer is true", () => { const params = { messageId: "42.9" }; expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index d4b3bc9fc3b..19cca2d7927 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -129,9 +129,9 @@ export function readStringOrNumberParam( export function readNumberParam( params: Record, key: string, - options: { required?: boolean; label?: string; integer?: boolean } = {}, + options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {}, ): number | undefined { - const { required = false, label = key, integer = false } = options; + const { required = false, label = key, integer = false, strict = false } = options; const raw = readParamRaw(params, key); let value: number | undefined; if (typeof raw === "number" && Number.isFinite(raw)) { @@ -139,7 +139,7 @@ export function readNumberParam( } else if (typeof raw === "string") { const trimmed = raw.trim(); if (trimmed) { - const parsed = Number.parseFloat(trimmed); + const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed); if (Number.isFinite(parsed)) { value = parsed; } diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 2846e0879f8..7349e65a3e6 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -26,11 +26,14 @@ import { } from "../../discord/send.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js"; import { resolveDiscordChannelId } from "../../discord/targets.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { assertMediaNotDataUrl } from "../sandbox-paths.js"; import { type ActionGate, jsonResult, + readNumberParam, readReactionParams, readStringArrayParam, readStringParam, @@ -126,9 +129,7 @@ export async function handleDiscordMessagingAction( const messageId = readStringParam(params, "messageId", { required: true, }); - const limitRaw = params.limit; - const limit = - typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; + const limit = readNumberParam(params, "limit"); const reactions = await fetchReactionsDiscord(channelId, messageId, { ...cfgOptions, ...(accountId ? { accountId } : {}), @@ -166,13 +167,9 @@ export async function handleDiscordMessagingAction( required: true, label: "answers", }); - const allowMultiselectRaw = params.allowMultiselect; - const allowMultiselect = - typeof allowMultiselectRaw === "boolean" ? allowMultiselectRaw : undefined; - const durationRaw = params.durationHours; - const durationHours = - typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined; - const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1; + const allowMultiselect = readBooleanParam(params, "allowMultiselect"); + const durationHours = readNumberParam(params, "durationHours"); + const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect); await sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, @@ -226,10 +223,7 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const query = { - limit: - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined, + limit: readNumberParam(params, "limit"), before: readStringParam(params, "before"), after: readStringParam(params, "after"), around: readStringParam(params, "around"), @@ -372,11 +366,7 @@ export async function handleDiscordMessagingAction( const name = readStringParam(params, "name", { required: true }); const messageId = readStringParam(params, "messageId"); const content = readStringParam(params, "content"); - const autoArchiveMinutesRaw = params.autoArchiveMinutes; - const autoArchiveMinutes = - typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw) - ? autoArchiveMinutesRaw - : undefined; + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMinutes"); const appliedTags = readStringArrayParam(params, "appliedTags"); const payload = { name, @@ -398,13 +388,9 @@ export async function handleDiscordMessagingAction( required: true, }); const channelId = readStringParam(params, "channelId"); - const includeArchived = - typeof params.includeArchived === "boolean" ? params.includeArchived : undefined; + const includeArchived = readBooleanParam(params, "includeArchived"); const before = readStringParam(params, "before"); - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined; + const limit = readNumberParam(params, "limit"); const threads = accountId ? await listThreadsDiscord( { @@ -498,10 +484,7 @@ export async function handleDiscordMessagingAction( const channelIds = readStringArrayParam(params, "channelIds"); const authorId = readStringParam(params, "authorId"); const authorIds = readStringArrayParam(params, "authorIds"); - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined; + const limit = readNumberParam(params, "limit"); const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])]; const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])]; const results = accountId diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index cbadb77f564..95f6c7ec4f2 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -61,6 +61,7 @@ const { removeReactionDiscord, searchMessagesDiscord, sendMessageDiscord, + sendPollDiscord, sendVoiceMessageDiscord, setChannelPermissionDiscord, timeoutMemberDiscord, @@ -166,6 +167,31 @@ describe("handleDiscordMessagingAction", () => { ).rejects.toThrow(/Discord reactions are disabled/); }); + it("parses string booleans for poll options", async () => { + await handleDiscordMessagingAction( + "poll", + { + to: "channel:123", + question: "Lunch?", + answers: ["Pizza", "Sushi"], + allowMultiselect: "true", + durationHours: "24", + }, + enableAllActions, + ); + + expect(sendPollDiscord).toHaveBeenCalledWith( + "channel:123", + { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 2, + durationHours: 24, + }, + expect.any(Object), + ); + }); + it("adds normalized timestamps to readMessages payloads", async () => { readMessagesDiscord.mockResolvedValueOnce([ { id: "1", timestamp: "2026-01-15T10:00:00.000Z" }, diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 84e25fd30d2..930f8d95a25 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -45,7 +45,8 @@ function createChannelPlugin(params: { label: string; docsPath: string; blurb: string; - actions: string[]; + actions?: ChannelMessageActionName[]; + listActions?: NonNullable["listActions"]>; supportsButtons?: boolean; messaging?: ChannelPlugin["messaging"]; }): ChannelPlugin { @@ -65,7 +66,11 @@ function createChannelPlugin(params: { }, ...(params.messaging ? { messaging: params.messaging } : {}), actions: { - listActions: () => params.actions as never, + listActions: + params.listActions ?? + (() => { + return (params.actions ?? []) as never; + }), ...(params.supportsButtons ? { supportsButtons: () => true } : {}), }, }; @@ -139,7 +144,7 @@ describe("message tool schema scoping", () => { label: "Telegram", docsPath: "/channels/telegram", blurb: "Telegram test plugin.", - actions: ["send", "react"], + actions: ["send", "react", "poll"], supportsButtons: true, }); @@ -161,6 +166,7 @@ describe("message tool schema scoping", () => { expectComponents: false, expectButtons: true, expectButtonStyle: true, + expectTelegramPollExtras: true, expectedActions: ["send", "react", "poll", "poll-vote"], }, { @@ -168,11 +174,19 @@ describe("message tool schema scoping", () => { expectComponents: true, expectButtons: false, expectButtonStyle: false, + expectTelegramPollExtras: true, expectedActions: ["send", "poll", "poll-vote", "react"], }, ])( "scopes schema fields for $provider", - ({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => { + ({ + provider, + expectComponents, + expectButtons, + expectButtonStyle, + expectTelegramPollExtras, + expectedActions, + }) => { setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", source: "test", plugin: telegramPlugin }, @@ -209,11 +223,75 @@ describe("message tool schema scoping", () => { for (const action of expectedActions) { expect(actionEnum).toContain(action); } + if (expectTelegramPollExtras) { + expect(properties.pollDurationSeconds).toBeDefined(); + expect(properties.pollAnonymous).toBeDefined(); + expect(properties.pollPublic).toBeDefined(); + } else { + expect(properties.pollDurationSeconds).toBeUndefined(); + expect(properties.pollAnonymous).toBeUndefined(); + expect(properties.pollPublic).toBeUndefined(); + } expect(properties.pollId).toBeDefined(); expect(properties.pollOptionIndex).toBeDefined(); expect(properties.pollOptionId).toBeDefined(); }, ); + + it("includes poll in the action enum when the current channel supports poll actions", () => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + }); + const actionEnum = getActionEnum(getToolProperties(tool)); + + expect(actionEnum).toContain("poll"); + }); + + it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => { + const telegramPluginWithConfig = createChannelPlugin({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + listActions: ({ cfg }) => { + const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } }) + .channels?.telegram; + return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"]; + }, + supportsButtons: true, + }); + + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: telegramPluginWithConfig }, + ]), + ); + + const tool = createMessageTool({ + config: { + channels: { + telegram: { + actions: { + poll: false, + }, + }, + }, + } as never, + currentChannelProvider: "telegram", + }); + const properties = getToolProperties(tool); + const actionEnum = getActionEnum(properties); + + expect(actionEnum).not.toContain("poll"); + expect(properties.pollDurationSeconds).toBeUndefined(); + expect(properties.pollAnonymous).toBeUndefined(); + expect(properties.pollPublic).toBeUndefined(); + }); }); describe("message tool description", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 27f72868cdf..96b2702f065 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -17,6 +17,7 @@ import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -271,12 +272,8 @@ function buildFetchSchema() { }; } -function buildPollSchema() { - return { - pollQuestion: Type.Optional(Type.String()), - pollOption: Type.Optional(Type.Array(Type.String())), - pollDurationHours: Type.Optional(Type.Number()), - pollMulti: Type.Optional(Type.Boolean()), +function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { + const props: Record = { pollId: Type.Optional(Type.String()), pollOptionId: Type.Optional( Type.String({ @@ -306,6 +303,27 @@ function buildPollSchema() { ), ), }; + for (const name of POLL_CREATION_PARAM_NAMES) { + const def = POLL_CREATION_PARAM_DEFS[name]; + if (def.telegramOnly && !options?.includeTelegramExtras) { + continue; + } + switch (def.kind) { + case "string": + props[name] = Type.Optional(Type.String()); + break; + case "stringArray": + props[name] = Type.Optional(Type.Array(Type.String())); + break; + case "number": + props[name] = Type.Optional(Type.Number()); + break; + case "boolean": + props[name] = Type.Optional(Type.Boolean()); + break; + } + } + return props; } function buildChannelTargetSchema() { @@ -425,13 +443,14 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean; + includeTelegramPollExtras: boolean; }) { return { ...buildRoutingSchema(), ...buildSendSchema(options), ...buildReactionSchema(), ...buildFetchSchema(), - ...buildPollSchema(), + ...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }), ...buildChannelTargetSchema(), ...buildStickerSchema(), ...buildThreadSchema(), @@ -445,7 +464,12 @@ function buildMessageToolSchemaProps(options: { function buildMessageToolSchemaFromActions( actions: readonly string[], - options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean }, + options: { + includeButtons: boolean; + includeCards: boolean; + includeComponents: boolean; + includeTelegramPollExtras: boolean; + }, ) { const props = buildMessageToolSchemaProps(options); return Type.Object({ @@ -458,6 +482,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeButtons: true, includeCards: true, includeComponents: true, + includeTelegramPollExtras: true, }); type MessageToolOptions = { @@ -519,6 +544,16 @@ function resolveIncludeComponents(params: { return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0; } +function resolveIncludeTelegramPollExtras(params: { + cfg: OpenClawConfig; + currentChannelProvider?: string; +}): boolean { + return listChannelSupportedActions({ + cfg: params.cfg, + channel: "telegram", + }).includes("poll"); +} + function buildMessageToolSchema(params: { cfg: OpenClawConfig; currentChannelProvider?: string; @@ -533,10 +568,12 @@ function buildMessageToolSchema(params: { ? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel }) : supportsChannelMessageCards(params.cfg); const includeComponents = resolveIncludeComponents(params); + const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeButtons, includeCards, includeComponents, + includeTelegramPollExtras, }); } diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 6b4f2314a6b..eeeb7bbf35b 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -8,6 +8,11 @@ const sendMessageTelegram = vi.fn(async () => ({ messageId: "789", chatId: "123", })); +const sendPollTelegram = vi.fn(async () => ({ + messageId: "790", + chatId: "123", + pollId: "poll-1", +})); const sendStickerTelegram = vi.fn(async () => ({ messageId: "456", chatId: "123", @@ -20,6 +25,7 @@ vi.mock("../../telegram/send.js", () => ({ reactMessageTelegram(...args), sendMessageTelegram: (...args: Parameters) => sendMessageTelegram(...args), + sendPollTelegram: (...args: Parameters) => sendPollTelegram(...args), sendStickerTelegram: (...args: Parameters) => sendStickerTelegram(...args), deleteMessageTelegram: (...args: Parameters) => @@ -81,6 +87,7 @@ describe("handleTelegramAction", () => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]); reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); + sendPollTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; @@ -291,6 +298,70 @@ describe("handleTelegramAction", () => { }); }); + it("sends a poll", async () => { + const result = await handleTelegramAction( + { + action: "poll", + to: "@testchannel", + question: "Ready?", + answers: ["Yes", "No"], + allowMultiselect: true, + durationSeconds: 60, + isAnonymous: false, + silent: true, + }, + telegramConfig(), + ); + expect(sendPollTelegram).toHaveBeenCalledWith( + "@testchannel", + { + question: "Ready?", + options: ["Yes", "No"], + maxSelections: 2, + durationSeconds: 60, + durationHours: undefined, + }, + expect.objectContaining({ + token: "tok", + isAnonymous: false, + silent: true, + }), + ); + expect(result.details).toMatchObject({ + ok: true, + messageId: "790", + chatId: "123", + pollId: "poll-1", + }); + }); + + it("parses string booleans for poll flags", async () => { + await handleTelegramAction( + { + action: "poll", + to: "@testchannel", + question: "Ready?", + answers: ["Yes", "No"], + allowMultiselect: "true", + isAnonymous: "false", + silent: "true", + }, + telegramConfig(), + ); + expect(sendPollTelegram).toHaveBeenCalledWith( + "@testchannel", + expect.objectContaining({ + question: "Ready?", + options: ["Yes", "No"], + maxSelections: 2, + }), + expect.objectContaining({ + isAnonymous: false, + silent: true, + }), + ); + }); + it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => { await handleTelegramAction( { @@ -390,6 +461,25 @@ describe("handleTelegramAction", () => { ).rejects.toThrow(/Telegram sendMessage is disabled/); }); + it("respects poll gating", async () => { + const cfg = { + channels: { + telegram: { botToken: "tok", actions: { poll: false } }, + }, + } as OpenClawConfig; + await expect( + handleTelegramAction( + { + action: "poll", + to: "@testchannel", + question: "Lunch?", + answers: ["Pizza", "Sushi"], + }, + cfg, + ), + ).rejects.toThrow(/Telegram polls are disabled/); + }); + it("deletes a message", async () => { const cfg = { channels: { telegram: { botToken: "tok" } }, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 4a9de90725d..30c07530159 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,6 +1,11 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; -import { createTelegramActionGate } from "../../telegram/accounts.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { resolvePollMaxSelections } from "../../polls.js"; +import { + createTelegramActionGate, + resolveTelegramPollActionGateState, +} from "../../telegram/accounts.js"; import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js"; import { resolveTelegramInlineButtonsScope, @@ -13,6 +18,7 @@ import { editMessageTelegram, reactMessageTelegram, sendMessageTelegram, + sendPollTelegram, sendStickerTelegram, } from "../../telegram/send.js"; import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js"; @@ -21,6 +27,7 @@ import { jsonResult, readNumberParam, readReactionParams, + readStringArrayParam, readStringOrNumberParam, readStringParam, } from "./common.js"; @@ -238,8 +245,8 @@ export async function handleTelegramAction( replyToMessageId: replyToMessageId ?? undefined, messageThreadId: messageThreadId ?? undefined, quoteText: quoteText ?? undefined, - asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined, - silent: typeof params.silent === "boolean" ? params.silent : undefined, + asVoice: readBooleanParam(params, "asVoice"), + silent: readBooleanParam(params, "silent"), }); return jsonResult({ ok: true, @@ -248,6 +255,60 @@ export async function handleTelegramAction( }); } + if (action === "poll") { + const pollActionState = resolveTelegramPollActionGateState(isActionEnabled); + if (!pollActionState.sendMessageEnabled) { + throw new Error("Telegram sendMessage is disabled."); + } + if (!pollActionState.pollEnabled) { + throw new Error("Telegram polls are disabled."); + } + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "question", { required: true }); + const answers = readStringArrayParam(params, "answers", { required: true }); + const allowMultiselect = readBooleanParam(params, "allowMultiselect") ?? false; + const durationSeconds = readNumberParam(params, "durationSeconds", { integer: true }); + const durationHours = readNumberParam(params, "durationHours", { integer: true }); + const replyToMessageId = readNumberParam(params, "replyToMessageId", { + integer: true, + }); + const messageThreadId = readNumberParam(params, "messageThreadId", { + integer: true, + }); + const isAnonymous = readBooleanParam(params, "isAnonymous"); + const silent = readBooleanParam(params, "silent"); + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await sendPollTelegram( + to, + { + question, + options: answers, + maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect), + durationSeconds: durationSeconds ?? undefined, + durationHours: durationHours ?? undefined, + }, + { + token, + accountId: accountId ?? undefined, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + isAnonymous: isAnonymous ?? undefined, + silent: silent ?? undefined, + }, + ); + return jsonResult({ + ok: true, + messageId: result.messageId, + chatId: result.chatId, + pollId: result.pollId, + }); + } + if (action === "deleteMessage") { if (!isActionEnabled("deleteMessage")) { throw new Error("Telegram deleteMessage is disabled."); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index eda720dfc93..a6e1e89fc2e 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -329,6 +329,44 @@ describe("handleDiscordMessageAction", () => { answers: ["Yes", "No"], }, }, + { + name: "parses string booleans for discord poll adapter params", + input: { + action: "poll" as const, + params: { + to: "channel:123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + pollMulti: "true", + }, + }, + expected: { + action: "poll", + to: "channel:123", + question: "Ready?", + answers: ["Yes", "No"], + allowMultiselect: true, + }, + }, + { + name: "rejects partially numeric poll duration for discord poll adapter params", + input: { + action: "poll" as const, + params: { + to: "channel:123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + pollDurationHours: "24h", + }, + }, + expected: { + action: "poll", + to: "channel:123", + question: "Ready?", + answers: ["Yes", "No"], + durationHours: undefined, + }, + }, { name: "forwards accountId for thread replies", input: { @@ -496,6 +534,71 @@ describe("handleDiscordMessageAction", () => { }); describe("telegramMessageActions", () => { + it("lists poll when telegram is configured", () => { + const actions = telegramMessageActions.listActions?.({ cfg: telegramCfg() }) ?? []; + + expect(actions).toContain("poll"); + }); + + it("omits poll when sendMessage is disabled", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + actions: { sendMessage: false }, + }, + }, + } as OpenClawConfig; + + const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).not.toContain("poll"); + }); + + it("omits poll when poll actions are disabled", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + actions: { poll: false }, + }, + }, + } as OpenClawConfig; + + const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).not.toContain("poll"); + }); + + it("omits poll when sendMessage and poll are split across accounts", () => { + const cfg = { + channels: { + telegram: { + accounts: { + senderOnly: { + botToken: "tok-send", + actions: { + sendMessage: true, + poll: false, + }, + }, + pollOnly: { + botToken: "tok-poll", + actions: { + sendMessage: false, + poll: true, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).not.toContain("poll"); + }); + it("lists sticker actions only when enabled by config", () => { const cases = [ { @@ -595,6 +698,85 @@ describe("telegramMessageActions", () => { accountId: undefined, }, }, + { + name: "poll maps to telegram poll action", + action: "poll" as const, + params: { + to: "123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + pollMulti: true, + pollDurationSeconds: 60, + pollPublic: true, + replyTo: 55, + threadId: 77, + silent: true, + }, + expectedPayload: { + action: "poll", + to: "123", + question: "Ready?", + answers: ["Yes", "No"], + allowMultiselect: true, + durationHours: undefined, + durationSeconds: 60, + replyToMessageId: 55, + messageThreadId: 77, + isAnonymous: false, + silent: true, + accountId: undefined, + }, + }, + { + name: "poll parses string booleans before telegram action handoff", + action: "poll" as const, + params: { + to: "123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + pollMulti: "true", + pollPublic: "true", + silent: "true", + }, + expectedPayload: { + action: "poll", + to: "123", + question: "Ready?", + answers: ["Yes", "No"], + allowMultiselect: true, + durationHours: undefined, + durationSeconds: undefined, + replyToMessageId: undefined, + messageThreadId: undefined, + isAnonymous: false, + silent: true, + accountId: undefined, + }, + }, + { + name: "poll rejects partially numeric duration strings before telegram action handoff", + action: "poll" as const, + params: { + to: "123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + pollDurationSeconds: "60s", + }, + expectedPayload: { + action: "poll", + to: "123", + question: "Ready?", + answers: ["Yes", "No"], + allowMultiselect: undefined, + durationHours: undefined, + durationSeconds: undefined, + replyToMessageId: undefined, + messageThreadId: undefined, + isAnonymous: undefined, + silent: undefined, + accountId: undefined, + }, + }, { name: "topic-create maps to createForumTopic", action: "topic-create" as const, diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 6f0a701b6b2..5b11246210a 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -7,6 +7,7 @@ import { import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actions-shared.js"; import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js"; import { resolveDiscordChannelId } from "../../../../discord/targets.js"; +import { readBooleanParam } from "../../../../plugin-sdk/boolean-param.js"; import type { ChannelMessageActionContext } from "../../types.js"; import { resolveReactionMessageId } from "../reaction-message-id.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; @@ -38,7 +39,7 @@ export async function handleDiscordMessageAction( if (action === "send") { const to = readStringParam(params, "to", { required: true }); - const asVoice = params.asVoice === true; + const asVoice = readBooleanParam(params, "asVoice") === true; const rawComponents = params.components; const hasComponents = Boolean(rawComponents) && @@ -57,7 +58,7 @@ export async function handleDiscordMessageAction( const replyTo = readStringParam(params, "replyTo"); const rawEmbeds = params.embeds; const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; - const silent = params.silent === true; + const silent = readBooleanParam(params, "silent") === true; const sessionKey = readStringParam(params, "__sessionKey"); const agentId = readStringParam(params, "__agentId"); return await handleDiscordAction( @@ -86,10 +87,11 @@ export async function handleDiscordMessageAction( const question = readStringParam(params, "pollQuestion", { required: true, }); - const answers = readStringArrayParam(params, "pollOption", { required: true }) ?? []; - const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); const durationHours = readNumberParam(params, "pollDurationHours", { integer: true, + strict: true, }); return await handleDiscordAction( { @@ -116,7 +118,7 @@ export async function handleDiscordMessageAction( ); } const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; + const remove = readBooleanParam(params, "remove"); return await handleDiscordAction( { action: "react", diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 4f0f1a85c2d..6e55349698b 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -6,10 +6,13 @@ import { } from "../../../agents/tools/common.js"; import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; import type { TelegramActionConfig } from "../../../config/types.telegram.js"; +import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js"; import { extractToolSend } from "../../../plugin-sdk/tool-send.js"; +import { resolveTelegramPollVisibility } from "../../../poll-params.js"; import { createTelegramActionGate, listEnabledTelegramAccounts, + resolveTelegramPollActionGateState, } from "../../../telegram/accounts.js"; import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; @@ -27,8 +30,8 @@ function readTelegramSendParams(params: Record) { const replyTo = readStringParam(params, "replyTo"); const threadId = readStringParam(params, "threadId"); const buttons = params.buttons; - const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined; - const silent = typeof params.silent === "boolean" ? params.silent : undefined; + const asVoice = readBooleanParam(params, "asVoice"); + const silent = readBooleanParam(params, "silent"); const quoteText = readStringParam(params, "quoteText"); return { to, @@ -78,6 +81,16 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => gate(key, defaultValue); const actions = new Set(["send"]); + const pollEnabledForAnyAccount = accounts.some((account) => { + const accountGate = createTelegramActionGate({ + cfg, + accountId: account.accountId, + }); + return resolveTelegramPollActionGateState(accountGate).enabled; + }); + if (pollEnabledForAnyAccount) { + actions.add("poll"); + } if (isEnabled("reactions")) { actions.add("react"); } @@ -125,7 +138,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (action === "react") { const messageId = resolveReactionMessageId({ args: params, toolContext }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; + const remove = readBooleanParam(params, "remove"); return await handleTelegramAction( { action: "react", @@ -140,6 +153,45 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { required: true }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + strict: true, + }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const pollAnonymous = readBooleanParam(params, "pollAnonymous"); + const pollPublic = readBooleanParam(params, "pollPublic"); + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const silent = readBooleanParam(params, "silent"); + return await handleTelegramAction( + { + action: "poll", + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + durationSeconds: durationSeconds ?? undefined, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + isAnonymous, + silent, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + if (action === "delete") { const chatId = readTelegramChatIdParam(params); const messageId = readTelegramMessageIdParam(params); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 1ef0db815e3..379c6b8c89e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -336,6 +336,12 @@ export type ChannelToolSend = { }; export type ChannelMessageActionAdapter = { + /** + * Advertise agent-discoverable actions for this channel. + * Keep this aligned with any gated capability checks. Poll discovery is + * not inferred from `outbound.sendPoll`, so channels that want agents to + * create polls should include `"poll"` here when enabled. + */ listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean; diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index f5a23298b1a..658eb9fd614 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -166,6 +166,24 @@ const createTelegramSendPluginRegistration = () => ({ }), }); +const createTelegramPollPluginRegistration = () => ({ + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + actions: { + listActions: () => ["poll"], + handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => { + return await handleTelegramAction( + { action, to: params.to, accountId: accountId ?? undefined }, + cfg, + ); + }) as unknown as NonNullable["handleAction"], + }, + }), +}); + const { messageCommand } = await import("./message.js"); describe("messageCommand", () => { @@ -468,4 +486,34 @@ describe("messageCommand", () => { expect.any(Object), ); }); + + it("routes telegram polls through message action", async () => { + await setRegistry( + createTestRegistry([ + { + ...createTelegramPollPluginRegistration(), + }, + ]), + ); + const deps = makeDeps(); + await messageCommand( + { + action: "poll", + channel: "telegram", + target: "123456789", + pollQuestion: "Ship it?", + pollOption: ["Yes", "No"], + pollDurationSeconds: 120, + }, + deps, + runtime, + ); + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + to: "123456789", + }), + expect.any(Object), + ); + }); }); diff --git a/src/config/telegram-actions-poll.test.ts b/src/config/telegram-actions-poll.test.ts new file mode 100644 index 00000000000..0193cab9a69 --- /dev/null +++ b/src/config/telegram-actions-poll.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("telegram poll action config", () => { + it("accepts channels.telegram.actions.poll", () => { + const res = validateConfigObject({ + channels: { + telegram: { + actions: { + poll: false, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts channels.telegram.accounts..actions.poll", () => { + const res = validateConfigObject({ + channels: { + telegram: { + accounts: { + ops: { + actions: { + poll: false, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index a6afe675f83..3867544784e 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -14,6 +14,8 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ export type TelegramActionConfig = { reactions?: boolean; sendMessage?: boolean; + /** Enable poll creation. Requires sendMessage to also be enabled. */ + poll?: boolean; deleteMessage?: boolean; editMessage?: boolean; /** Enable sticker actions (send and search). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 55a98c5f827..55fdc2b06a9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -225,6 +225,7 @@ export const TelegramAccountSchemaBase = z .object({ reactions: z.boolean().optional(), sendMessage: z.boolean().optional(), + poll: z.boolean().optional(), deleteMessage: z.boolean().optional(), sticker: z.boolean().optional(), }) diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index d2db2a60b2d..cc7d68df9d3 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -236,6 +236,72 @@ describe("runMessageAction context isolation", () => { ).rejects.toThrow(/message required/i); }); + it("rejects send actions that include poll creation params", async () => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + message: "hi", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + }, + toolContext: { currentChannelId: "C12345678" }, + }), + ).rejects.toThrow(/use action "poll" instead of "send"/i); + }); + + it("rejects send actions that include string-encoded poll params", async () => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + message: "hi", + pollDurationSeconds: "60", + pollPublic: "true", + }, + toolContext: { currentChannelId: "C12345678" }, + }), + ).rejects.toThrow(/use action "poll" instead of "send"/i); + }); + + it("rejects send actions that include snake_case poll params", async () => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + message: "hi", + poll_question: "Ready?", + poll_option: ["Yes", "No"], + poll_public: "true", + }, + toolContext: { currentChannelId: "C12345678" }, + }), + ).rejects.toThrow(/use action "poll" instead of "send"/i); + }); + + it("allows send when poll booleans are explicitly false", async () => { + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + message: "hi", + pollMulti: false, + pollAnonymous: false, + pollPublic: false, + }, + toolContext: { currentChannelId: "C12345678" }, + }); + + expect(result.kind).toBe("send"); + }); + it("blocks send when target differs from current channel", async () => { const result = await runDrySend({ cfg: slackConfig, @@ -902,6 +968,114 @@ describe("runMessageAction card-only send behavior", () => { }); }); +describe("runMessageAction telegram plugin poll forwarding", () => { + const handleAction = vi.fn(async ({ params }: { params: Record }) => + jsonResult({ + ok: true, + forwarded: { + to: params.to ?? null, + pollQuestion: params.pollQuestion ?? null, + pollOption: params.pollOption ?? null, + pollDurationSeconds: params.pollDurationSeconds ?? null, + pollPublic: params.pollPublic ?? null, + threadId: params.threadId ?? null, + }, + }), + ); + + const telegramPollPlugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram poll forwarding test plugin.", + }, + capabilities: { chatTypes: ["direct"] }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + listActions: () => ["poll"], + supportsAction: ({ action }) => action === "poll", + handleAction, + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: telegramPollPlugin, + }, + ]), + ); + handleAction.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + it("forwards telegram poll params through plugin dispatch", async () => { + const result = await runMessageAction({ + cfg: { + channels: { + telegram: { + botToken: "tok", + }, + }, + } as OpenClawConfig, + action: "poll", + params: { + channel: "telegram", + target: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + threadId: "42", + }, + dryRun: false, + }); + + expect(result.kind).toBe("poll"); + expect(result.handledBy).toBe("plugin"); + expect(handleAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + channel: "telegram", + params: expect.objectContaining({ + to: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + threadId: "42", + }), + }), + ); + expect(result.payload).toMatchObject({ + ok: true, + forwarded: { + to: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + threadId: "42", + }, + }); + }); +}); + describe("runMessageAction components parsing", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index d8ec9419018..c703cd34d24 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,8 @@ import type { } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js"; +import { resolvePollMaxSelections } from "../../polls.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js"; @@ -307,7 +309,7 @@ async function handleBroadcastAction( if (!broadcastEnabled) { throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); } - const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? []; + const rawTargets = readStringArrayParam(params, "targets", { required: true }); if (rawTargets.length === 0) { throw new Error("Broadcast requires at least one target in --targets."); } @@ -571,7 +573,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { + it("does not treat explicit false booleans as poll creation params", () => { + expect( + hasPollCreationParams({ + pollMulti: false, + pollAnonymous: false, + pollPublic: false, + }), + ).toBe(false); + }); + + it.each([{ key: "pollMulti" }, { key: "pollAnonymous" }, { key: "pollPublic" }])( + "treats $key=true as poll creation intent", + ({ key }) => { + expect( + hasPollCreationParams({ + [key]: true, + }), + ).toBe(true); + }, + ); + + it("treats finite numeric poll params as poll creation intent", () => { + expect(hasPollCreationParams({ pollDurationHours: 0 })).toBe(true); + expect(hasPollCreationParams({ pollDurationSeconds: 60 })).toBe(true); + expect(hasPollCreationParams({ pollDurationSeconds: "60" })).toBe(true); + expect(hasPollCreationParams({ pollDurationSeconds: "1e3" })).toBe(true); + expect(hasPollCreationParams({ pollDurationHours: Number.NaN })).toBe(false); + expect(hasPollCreationParams({ pollDurationSeconds: Infinity })).toBe(false); + expect(hasPollCreationParams({ pollDurationSeconds: "60abc" })).toBe(false); + }); + + it("treats string-encoded boolean poll params as poll creation intent when true", () => { + expect(hasPollCreationParams({ pollPublic: "true" })).toBe(true); + expect(hasPollCreationParams({ pollAnonymous: "false" })).toBe(false); + }); + + it("treats string poll options as poll creation intent", () => { + expect(hasPollCreationParams({ pollOption: "Yes" })).toBe(true); + }); + + it("detects snake_case poll fields as poll creation intent", () => { + expect(hasPollCreationParams({ poll_question: "Lunch?" })).toBe(true); + expect(hasPollCreationParams({ poll_option: ["Pizza", "Sushi"] })).toBe(true); + expect(hasPollCreationParams({ poll_duration_seconds: "60" })).toBe(true); + expect(hasPollCreationParams({ poll_public: "true" })).toBe(true); + }); + + it("resolves telegram poll visibility flags", () => { + expect(resolveTelegramPollVisibility({ pollAnonymous: true })).toBe(true); + expect(resolveTelegramPollVisibility({ pollPublic: true })).toBe(false); + expect(resolveTelegramPollVisibility({})).toBeUndefined(); + expect(() => resolveTelegramPollVisibility({ pollAnonymous: true, pollPublic: true })).toThrow( + /mutually exclusive/i, + ); + }); +}); diff --git a/src/poll-params.ts b/src/poll-params.ts new file mode 100644 index 00000000000..88dc6336d32 --- /dev/null +++ b/src/poll-params.ts @@ -0,0 +1,89 @@ +export type PollCreationParamKind = "string" | "stringArray" | "number" | "boolean"; + +export type PollCreationParamDef = { + kind: PollCreationParamKind; + telegramOnly?: boolean; +}; + +export const POLL_CREATION_PARAM_DEFS: Record = { + pollQuestion: { kind: "string" }, + pollOption: { kind: "stringArray" }, + pollDurationHours: { kind: "number" }, + pollMulti: { kind: "boolean" }, + pollDurationSeconds: { kind: "number", telegramOnly: true }, + pollAnonymous: { kind: "boolean", telegramOnly: true }, + pollPublic: { kind: "boolean", telegramOnly: true }, +}; + +export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS; + +export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS); + +function toSnakeCaseKey(key: string): string { + return key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); +} + +function readPollParamRaw(params: Record, key: string): unknown { + if (Object.hasOwn(params, key)) { + return params[key]; + } + const snakeKey = toSnakeCaseKey(key); + if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { + return params[snakeKey]; + } + return undefined; +} + +export function resolveTelegramPollVisibility(params: { + pollAnonymous?: boolean; + pollPublic?: boolean; +}): boolean | undefined { + if (params.pollAnonymous && params.pollPublic) { + throw new Error("pollAnonymous and pollPublic are mutually exclusive"); + } + return params.pollAnonymous ? true : params.pollPublic ? false : undefined; +} + +export function hasPollCreationParams(params: Record): boolean { + for (const key of POLL_CREATION_PARAM_NAMES) { + const def = POLL_CREATION_PARAM_DEFS[key]; + const value = readPollParamRaw(params, key); + if (def.kind === "string" && typeof value === "string" && value.trim().length > 0) { + return true; + } + if (def.kind === "stringArray") { + if ( + Array.isArray(value) && + value.some((entry) => typeof entry === "string" && entry.trim()) + ) { + return true; + } + if (typeof value === "string" && value.trim().length > 0) { + return true; + } + } + if (def.kind === "number") { + if (typeof value === "number" && Number.isFinite(value)) { + return true; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0 && Number.isFinite(Number(trimmed))) { + return true; + } + } + } + if (def.kind === "boolean") { + if (value === true) { + return true; + } + if (typeof value === "string" && value.trim().toLowerCase() === "true") { + return true; + } + } + } + return false; +} diff --git a/src/polls.ts b/src/polls.ts index 7fe3f800e28..c10afd22b64 100644 --- a/src/polls.ts +++ b/src/polls.ts @@ -26,6 +26,13 @@ type NormalizePollOptions = { maxOptions?: number; }; +export function resolvePollMaxSelections( + optionCount: number, + allowMultiselect: boolean | undefined, +): number { + return allowMultiselect ? Math.max(2, optionCount) : 1; +} + export function normalizePollInput( input: PollInput, options: NormalizePollOptions = {}, diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts index 1c0807aaa1a..b77f01e0d67 100644 --- a/src/telegram/accounts.test.ts +++ b/src/telegram/accounts.test.ts @@ -4,6 +4,7 @@ import { withEnv } from "../test-utils/env.js"; import { listTelegramAccountIds, resetMissingDefaultWarnFlag, + resolveTelegramPollActionGateState, resolveDefaultTelegramAccountId, resolveTelegramAccount, } from "./accounts.js"; @@ -308,6 +309,26 @@ describe("resolveTelegramAccount allowFrom precedence", () => { }); }); +describe("resolveTelegramPollActionGateState", () => { + it("requires both sendMessage and poll actions", () => { + const state = resolveTelegramPollActionGateState((key) => key !== "poll"); + expect(state).toEqual({ + sendMessageEnabled: true, + pollEnabled: false, + enabled: false, + }); + }); + + it("returns enabled only when both actions are enabled", () => { + const state = resolveTelegramPollActionGateState(() => true); + expect(state).toEqual({ + sendMessageEnabled: true, + pollEnabled: true, + enabled: true, + }); + }); +}); + describe("resolveTelegramAccount groups inheritance (#30673)", () => { const createMultiAccountGroupsConfig = (): OpenClawConfig => ({ channels: { diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 81de42cd1f1..e3d86ec84b4 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -142,6 +142,24 @@ export function createTelegramActionGate(params: { }); } +export type TelegramPollActionGateState = { + sendMessageEnabled: boolean; + pollEnabled: boolean; + enabled: boolean; +}; + +export function resolveTelegramPollActionGateState( + isActionEnabled: (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean, +): TelegramPollActionGateState { + const sendMessageEnabled = isActionEnabled("sendMessage"); + const pollEnabled = isActionEnabled("poll"); + return { + sendMessageEnabled, + pollEnabled, + enabled: sendMessageEnabled && pollEnabled, + }; +} + export function resolveTelegramAccount(params: { cfg: OpenClawConfig; accountId?: string | null; From 06a229f98f9e029bb4483f6a972d58a2a96c10ef Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 16:36:29 -0800 Subject: [PATCH 03/69] fix(browser): close tracked tabs on session cleanup (#36666) --- CHANGELOG.md | 1 + src/agents/openclaw-tools.ts | 1 + src/agents/tools/browser-tool.test.ts | 43 ++++ src/agents/tools/browser-tool.ts | 20 +- src/browser/session-tab-registry.test.ts | 114 +++++++++++ src/browser/session-tab-registry.ts | 189 ++++++++++++++++++ src/gateway/server-methods/sessions.ts | 16 ++ ...sessions.gateway-server-sessions-a.test.ts | 29 +++ 8 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 src/browser/session-tab-registry.test.ts create mode 100644 src/browser/session-tab-registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 786eccfabb6..54ca35e42c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. - Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin. - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 4373bf83c4b..6dc694c6350 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -129,6 +129,7 @@ export function createOpenClawTools(options?: { createBrowserTool({ sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, allowHostControl: options?.allowHostBrowserControl, + agentSessionKey: options?.agentSessionKey, }), createCanvasTool({ config: options?.config }), createNodesTool({ diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index eaaec53f10c..3c54cb63633 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -82,6 +82,12 @@ const configMocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => configMocks); +const sessionTabRegistryMocks = vi.hoisted(() => ({ + trackSessionBrowserTab: vi.fn(), + untrackSessionBrowserTab: vi.fn(), +})); +vi.mock("../../browser/session-tab-registry.js", () => sessionTabRegistryMocks); + const toolCommonMocks = vi.hoisted(() => ({ imageResultFromFile: vi.fn(), })); @@ -292,6 +298,23 @@ describe("browser tool url alias support", () => { ); }); + it("tracks opened tabs when session context is available", async () => { + browserClientMocks.browserOpenTab.mockResolvedValueOnce({ + targetId: "tab-123", + title: "Example", + url: "https://example.com", + }); + const tool = createBrowserTool({ agentSessionKey: "agent:main:main" }); + await tool.execute?.("call-1", { action: "open", url: "https://example.com" }); + + expect(sessionTabRegistryMocks.trackSessionBrowserTab).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + targetId: "tab-123", + baseUrl: undefined, + profile: undefined, + }); + }); + it("accepts url alias for navigate", async () => { const tool = createBrowserTool(); await tool.execute?.("call-1", { @@ -317,6 +340,26 @@ describe("browser tool url alias support", () => { "targetUrl required", ); }); + + it("untracks explicit tab close for tracked sessions", async () => { + const tool = createBrowserTool({ agentSessionKey: "agent:main:main" }); + await tool.execute?.("call-1", { + action: "close", + targetId: "tab-xyz", + }); + + expect(browserClientMocks.browserCloseTab).toHaveBeenCalledWith( + undefined, + "tab-xyz", + expect.objectContaining({ profile: undefined }), + ); + expect(sessionTabRegistryMocks.untrackSessionBrowserTab).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + targetId: "tab-xyz", + baseUrl: undefined, + profile: undefined, + }); + }); }); describe("browser tool act compatibility", () => { diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 520b21f021c..80faf99a1e4 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -19,6 +19,10 @@ import { import { resolveBrowserConfig } from "../../browser/config.js"; import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js"; import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js"; +import { + trackSessionBrowserTab, + untrackSessionBrowserTab, +} from "../../browser/session-tab-registry.js"; import { loadConfig } from "../../config/config.js"; import { executeActAction, @@ -275,6 +279,7 @@ function resolveBrowserBaseUrl(params: { export function createBrowserTool(opts?: { sandboxBridgeUrl?: string; allowHostControl?: boolean; + agentSessionKey?: string; }): AnyAgentTool { const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host"; const hostHint = @@ -418,7 +423,14 @@ export function createBrowserTool(opts?: { }); return jsonResult(result); } - return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile })); + const opened = await browserOpenTab(baseUrl, targetUrl, { profile }); + trackSessionBrowserTab({ + sessionKey: opts?.agentSessionKey, + targetId: opened.targetId, + baseUrl, + profile, + }); + return jsonResult(opened); } case "focus": { const targetId = readStringParam(params, "targetId", { @@ -455,6 +467,12 @@ export function createBrowserTool(opts?: { } if (targetId) { await browserCloseTab(baseUrl, targetId, { profile }); + untrackSessionBrowserTab({ + sessionKey: opts?.agentSessionKey, + targetId, + baseUrl, + profile, + }); } else { await browserAct(baseUrl, { kind: "close" }, { profile }); } diff --git a/src/browser/session-tab-registry.test.ts b/src/browser/session-tab-registry.test.ts new file mode 100644 index 00000000000..2abdcd34462 --- /dev/null +++ b/src/browser/session-tab-registry.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __countTrackedSessionBrowserTabsForTests, + __resetTrackedSessionBrowserTabsForTests, + closeTrackedBrowserTabsForSessions, + trackSessionBrowserTab, + untrackSessionBrowserTab, +} from "./session-tab-registry.js"; + +describe("session tab registry", () => { + beforeEach(() => { + __resetTrackedSessionBrowserTabsForTests(); + }); + + afterEach(() => { + __resetTrackedSessionBrowserTabsForTests(); + }); + + it("tracks and closes tabs for normalized session keys", async () => { + trackSessionBrowserTab({ + sessionKey: "Agent:Main:Main", + targetId: "tab-a", + baseUrl: "http://127.0.0.1:9222", + profile: "OpenClaw", + }); + trackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "tab-b", + baseUrl: "http://127.0.0.1:9222", + profile: "OpenClaw", + }); + expect(__countTrackedSessionBrowserTabsForTests("agent:main:main")).toBe(2); + + const closeTab = vi.fn(async () => {}); + const closed = await closeTrackedBrowserTabsForSessions({ + sessionKeys: ["agent:main:main"], + closeTab, + }); + + expect(closed).toBe(2); + expect(closeTab).toHaveBeenCalledTimes(2); + expect(closeTab).toHaveBeenNthCalledWith(1, { + targetId: "tab-a", + baseUrl: "http://127.0.0.1:9222", + profile: "openclaw", + }); + expect(closeTab).toHaveBeenNthCalledWith(2, { + targetId: "tab-b", + baseUrl: "http://127.0.0.1:9222", + profile: "openclaw", + }); + expect(__countTrackedSessionBrowserTabsForTests()).toBe(0); + }); + + it("untracks specific tabs", async () => { + trackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "tab-a", + }); + trackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "tab-b", + }); + untrackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "tab-a", + }); + + const closeTab = vi.fn(async () => {}); + const closed = await closeTrackedBrowserTabsForSessions({ + sessionKeys: ["agent:main:main"], + closeTab, + }); + + expect(closed).toBe(1); + expect(closeTab).toHaveBeenCalledTimes(1); + expect(closeTab).toHaveBeenCalledWith({ + targetId: "tab-b", + baseUrl: undefined, + profile: undefined, + }); + }); + + it("deduplicates tabs and ignores expected close errors", async () => { + trackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "tab-a", + }); + trackSessionBrowserTab({ + sessionKey: "main", + targetId: "tab-a", + }); + trackSessionBrowserTab({ + sessionKey: "main", + targetId: "tab-b", + }); + const warnings: string[] = []; + const closeTab = vi + .fn() + .mockRejectedValueOnce(new Error("target not found")) + .mockRejectedValueOnce(new Error("network down")); + + const closed = await closeTrackedBrowserTabsForSessions({ + sessionKeys: ["agent:main:main", "main"], + closeTab, + onWarn: (message) => warnings.push(message), + }); + + expect(closed).toBe(0); + expect(closeTab).toHaveBeenCalledTimes(2); + expect(warnings).toEqual([expect.stringContaining("network down")]); + expect(__countTrackedSessionBrowserTabsForTests()).toBe(0); + }); +}); diff --git a/src/browser/session-tab-registry.ts b/src/browser/session-tab-registry.ts new file mode 100644 index 00000000000..b81ceac3060 --- /dev/null +++ b/src/browser/session-tab-registry.ts @@ -0,0 +1,189 @@ +import { browserCloseTab } from "./client.js"; + +export type TrackedSessionBrowserTab = { + sessionKey: string; + targetId: string; + baseUrl?: string; + profile?: string; + trackedAt: number; +}; + +const trackedTabsBySession = new Map>(); + +function normalizeSessionKey(raw: string): string { + return raw.trim().toLowerCase(); +} + +function normalizeTargetId(raw: string): string { + return raw.trim(); +} + +function normalizeProfile(raw?: string): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim(); + return trimmed ? trimmed.toLowerCase() : undefined; +} + +function normalizeBaseUrl(raw?: string): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim(); + return trimmed ? trimmed : undefined; +} + +function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?: string }): string { + return `${params.targetId}\u0000${params.baseUrl ?? ""}\u0000${params.profile ?? ""}`; +} + +function isIgnorableCloseError(err: unknown): boolean { + const message = String(err).toLowerCase(); + return ( + message.includes("tab not found") || + message.includes("target closed") || + message.includes("target not found") || + message.includes("no such target") + ); +} + +export function trackSessionBrowserTab(params: { + sessionKey?: string; + targetId?: string; + baseUrl?: string; + profile?: string; +}): void { + const sessionKeyRaw = params.sessionKey?.trim(); + const targetIdRaw = params.targetId?.trim(); + if (!sessionKeyRaw || !targetIdRaw) { + return; + } + const sessionKey = normalizeSessionKey(sessionKeyRaw); + const targetId = normalizeTargetId(targetIdRaw); + const baseUrl = normalizeBaseUrl(params.baseUrl); + const profile = normalizeProfile(params.profile); + const tracked: TrackedSessionBrowserTab = { + sessionKey, + targetId, + baseUrl, + profile, + trackedAt: Date.now(), + }; + const trackedId = toTrackedTabId(tracked); + let trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession) { + trackedForSession = new Map(); + trackedTabsBySession.set(sessionKey, trackedForSession); + } + trackedForSession.set(trackedId, tracked); +} + +export function untrackSessionBrowserTab(params: { + sessionKey?: string; + targetId?: string; + baseUrl?: string; + profile?: string; +}): void { + const sessionKeyRaw = params.sessionKey?.trim(); + const targetIdRaw = params.targetId?.trim(); + if (!sessionKeyRaw || !targetIdRaw) { + return; + } + const sessionKey = normalizeSessionKey(sessionKeyRaw); + const trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession) { + return; + } + const trackedId = toTrackedTabId({ + targetId: normalizeTargetId(targetIdRaw), + baseUrl: normalizeBaseUrl(params.baseUrl), + profile: normalizeProfile(params.profile), + }); + trackedForSession.delete(trackedId); + if (trackedForSession.size === 0) { + trackedTabsBySession.delete(sessionKey); + } +} + +function takeTrackedTabsForSessionKeys( + sessionKeys: Array, +): TrackedSessionBrowserTab[] { + const uniqueSessionKeys = new Set(); + for (const key of sessionKeys) { + if (!key?.trim()) { + continue; + } + uniqueSessionKeys.add(normalizeSessionKey(key)); + } + if (uniqueSessionKeys.size === 0) { + return []; + } + const seenTrackedIds = new Set(); + const tabs: TrackedSessionBrowserTab[] = []; + for (const sessionKey of uniqueSessionKeys) { + const trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession || trackedForSession.size === 0) { + continue; + } + trackedTabsBySession.delete(sessionKey); + for (const tracked of trackedForSession.values()) { + const trackedId = toTrackedTabId(tracked); + if (seenTrackedIds.has(trackedId)) { + continue; + } + seenTrackedIds.add(trackedId); + tabs.push(tracked); + } + } + return tabs; +} + +export async function closeTrackedBrowserTabsForSessions(params: { + sessionKeys: Array; + closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; + onWarn?: (message: string) => void; +}): Promise { + const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys); + if (tabs.length === 0) { + return 0; + } + const closeTab = + params.closeTab ?? + (async (tab: { targetId: string; baseUrl?: string; profile?: string }) => { + await browserCloseTab(tab.baseUrl, tab.targetId, { + profile: tab.profile, + }); + }); + let closed = 0; + for (const tab of tabs) { + try { + await closeTab({ + targetId: tab.targetId, + baseUrl: tab.baseUrl, + profile: tab.profile, + }); + closed += 1; + } catch (err) { + if (!isIgnorableCloseError(err)) { + params.onWarn?.(`failed to close tracked browser tab ${tab.targetId}: ${String(err)}`); + } + } + } + return closed; +} + +export function __resetTrackedSessionBrowserTabsForTests(): void { + trackedTabsBySession.clear(); +} + +export function __countTrackedSessionBrowserTabsForTests(sessionKey?: string): number { + if (typeof sessionKey === "string" && sessionKey.trim()) { + return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0; + } + let count = 0; + for (const tracked of trackedTabsBySession.values()) { + count += tracked.size; + } + return count; +} diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 69d49aab348..523e6655d71 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -6,6 +6,7 @@ import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js"; import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js"; import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue.js"; +import { closeTrackedBrowserTabsForSessions } from "../../browser/session-tab-registry.js"; import { loadConfig } from "../../config/config.js"; import { loadSessionStore, @@ -186,6 +187,19 @@ async function ensureSessionRuntimeCleanup(params: { target: ReturnType; sessionId?: string; }) { + const closeTrackedBrowserTabs = async () => { + const closeKeys = new Set([ + params.key, + params.target.canonicalKey, + ...params.target.storeKeys, + params.sessionId ?? "", + ]); + return await closeTrackedBrowserTabsForSessions({ + sessionKeys: [...closeKeys], + onWarn: (message) => logVerbose(message), + }); + }; + const queueKeys = new Set(params.target.storeKeys); queueKeys.add(params.target.canonicalKey); if (params.sessionId) { @@ -195,11 +209,13 @@ async function ensureSessionRuntimeCleanup(params: { clearBootstrapSnapshot(params.target.canonicalKey); stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: params.target.canonicalKey }); if (!params.sessionId) { + await closeTrackedBrowserTabs(); return undefined; } abortEmbeddedPiRun(params.sessionId); const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000); if (ended) { + await closeTrackedBrowserTabs(); return undefined; } return errorShape( diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 90b8e656b7e..3780174cee0 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -44,6 +44,9 @@ const acpRuntimeMocks = vi.hoisted(() => ({ getAcpRuntimeBackend: vi.fn(), requireAcpRuntimeBackend: vi.fn(), })); +const browserSessionTabMocks = vi.hoisted(() => ({ + closeTrackedBrowserTabsForSessions: vi.fn(async () => 0), +})); vi.mock("../auto-reply/reply/queue.js", async () => { const actual = await vi.importActual( @@ -111,6 +114,14 @@ vi.mock("../acp/runtime/registry.js", async (importOriginal) => { }; }); +vi.mock("../browser/session-tab-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + closeTrackedBrowserTabsForSessions: browserSessionTabMocks.closeTrackedBrowserTabsForSessions, + }; +}); + installGatewayTestHooks({ scope: "suite" }); let harness: GatewayServerHarness; @@ -205,6 +216,8 @@ describe("gateway server sessions", () => { acpRuntimeMocks.requireAcpRuntimeBackend.mockImplementation((backendId?: string) => acpRuntimeMocks.getAcpRuntimeBackend(backendId), ); + browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mockClear(); + browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mockResolvedValue(0); }); test("lists and patches session store via sessions.* RPC", async () => { @@ -694,6 +707,15 @@ describe("gateway server sessions", () => { ["discord:group:dev", "agent:main:discord:group:dev", "sess-active"], "sess-active", ); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledTimes(1); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith({ + sessionKeys: expect.arrayContaining([ + "discord:group:dev", + "agent:main:discord:group:dev", + "sess-active", + ]), + onWarn: expect.any(Function), + }); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith( { @@ -925,6 +947,11 @@ describe("gateway server sessions", () => { ["main", "agent:main:main", "sess-main"], "sess-main", ); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledTimes(1); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith({ + sessionKeys: expect.arrayContaining(["main", "agent:main:main", "sess-main"]), + onWarn: expect.any(Function), + }); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith( { @@ -1153,6 +1180,7 @@ describe("gateway server sessions", () => { ["main", "agent:main:main", "sess-main"], "sess-main", ); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled(); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, @@ -1194,6 +1222,7 @@ describe("gateway server sessions", () => { ["discord:group:dev", "agent:main:discord:group:dev", "sess-active"], "sess-active", ); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled(); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, From 1a67cf57e3186b453423473118d920eb94bf4ed5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 5 Mar 2026 19:46:39 -0500 Subject: [PATCH 04/69] Diffs: restore system prompt guidance (#36904) Merged via squash. Prepared head SHA: 1b3be3c87957c068473d5c86b9efba4a1a8503f2 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/tools/diffs.md | 27 ++++++++++++++++++++++++- extensions/diffs/README.md | 2 +- extensions/diffs/index.test.ts | 11 ++++++++-- extensions/diffs/index.ts | 4 ++++ extensions/diffs/src/prompt-guidance.ts | 7 +++++++ 6 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 extensions/diffs/src/prompt-guidance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ca35e42c1..456c56c3f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin. - Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant. - Plugins/hook policy: add `plugins.entries..hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras. +- Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras. ### Breaking diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md index eb9706338f8..6207366034e 100644 --- a/docs/tools/diffs.md +++ b/docs/tools/diffs.md @@ -10,7 +10,7 @@ read_when: # Diffs -`diffs` is an optional plugin tool and companion skill that turns change content into a read-only diff artifact for agents. +`diffs` is an optional plugin tool with short built-in system guidance and a companion skill that turns change content into a read-only diff artifact for agents. It accepts either: @@ -23,6 +23,8 @@ It can return: - a rendered file path (PNG or PDF) for message delivery - both outputs in one call +When enabled, the plugin prepends concise usage guidance into system-prompt space and also exposes a detailed skill for cases where the agent needs fuller instructions. + ## Quick start 1. Enable the plugin. @@ -44,6 +46,29 @@ It can return: } ``` +## Disable built-in system guidance + +If you want to keep the `diffs` tool enabled but disable its built-in system-prompt guidance, set `plugins.entries.diffs.hooks.allowPromptInjection` to `false`: + +```json5 +{ + plugins: { + entries: { + diffs: { + enabled: true, + hooks: { + allowPromptInjection: false, + }, + }, + }, + }, +} +``` + +This blocks the diffs plugin's `before_prompt_build` hook while keeping the plugin, tool, and companion skill available. + +If you want to disable both the guidance and the tool, disable the plugin instead. + ## Typical agent workflow 1. Agent calls `diffs`. diff --git a/extensions/diffs/README.md b/extensions/diffs/README.md index 028835cf561..f1af1792cb8 100644 --- a/extensions/diffs/README.md +++ b/extensions/diffs/README.md @@ -16,7 +16,7 @@ The tool can return: - `details.filePath`: a local rendered artifact path when file rendering is requested - `details.fileFormat`: the rendered file format (`png` or `pdf`) -When the plugin is enabled, it also ships a companion skill from `skills/` that guides when to use `diffs`. This guidance is delivered through normal skill loading, not unconditional prompt-hook injection on every turn. +When the plugin is enabled, it also ships a companion skill from `skills/` and prepends stable tool-usage guidance into system-prompt space via `before_prompt_build`. The hook uses `prependSystemContext`, so the guidance stays out of user-prompt space while still being available every turn. This means an agent can: diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index 6c7e2555b58..84ce5d9fe87 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -4,7 +4,7 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons import plugin from "./index.js"; describe("diffs plugin registration", () => { - it("registers the tool and http route", () => { + it("registers the tool, http route, and system-prompt guidance hook", async () => { const registerTool = vi.fn(); const registerHttpRoute = vi.fn(); const on = vi.fn(); @@ -43,7 +43,14 @@ describe("diffs plugin registration", () => { auth: "plugin", match: "prefix", }); - expect(on).not.toHaveBeenCalled(); + expect(on).toHaveBeenCalledTimes(1); + expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build"); + const beforePromptBuild = on.mock.calls[0]?.[1]; + const result = await beforePromptBuild?.({}, {}); + expect(result).toMatchObject({ + prependSystemContext: expect.stringContaining("prefer the `diffs` tool"), + }); + expect(result?.prependContext).toBeUndefined(); }); it("applies plugin-config defaults through registered tool and viewer handler", async () => { diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index 8b038b42fcc..b1547b1087d 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -7,6 +7,7 @@ import { resolveDiffsPluginSecurity, } from "./src/config.js"; import { createDiffsHttpHandler } from "./src/http.js"; +import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js"; import { DiffArtifactStore } from "./src/store.js"; import { createDiffsTool } from "./src/tool.js"; @@ -34,6 +35,9 @@ const plugin = { allowRemoteViewer: security.allowRemoteViewer, }), }); + api.on("before_prompt_build", async () => ({ + prependSystemContext: DIFFS_AGENT_GUIDANCE, + })); }, }; diff --git a/extensions/diffs/src/prompt-guidance.ts b/extensions/diffs/src/prompt-guidance.ts new file mode 100644 index 00000000000..37cbd501261 --- /dev/null +++ b/extensions/diffs/src/prompt-guidance.ts @@ -0,0 +1,7 @@ +export const DIFFS_AGENT_GUIDANCE = [ + "When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.", + "It accepts either `before` + `after` text or a unified `patch`.", + "`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.", + "If you need to send the rendered file, use the `message` tool with `path` or `filePath`.", + "Include `path` when you know the filename, and omit presentation overrides unless needed.", +].join("\n"); From c260e207b299fc8bac45558f19215622108961a6 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 16:49:24 -0800 Subject: [PATCH 05/69] fix(routing): avoid full binding rescans in resolveAgentRoute (#36915) --- CHANGELOG.md | 1 + src/routing/resolve-route.test.ts | 43 +++++++++- src/routing/resolve-route.ts | 136 ++++++++++++++++++++++-------- 3 files changed, 145 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 456c56c3f07..218dd90ffab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. - Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin. - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 5d23303e3ca..00bc55c350c 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import type { ChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/config.js"; +import * as routingBindings from "./bindings.js"; import { resolveAgentRoute } from "./resolve-route.js"; describe("resolveAgentRoute", () => { @@ -768,3 +769,43 @@ describe("role-based agent routing", () => { }); }); }); + +describe("binding evaluation cache scalability", () => { + test("does not rescan full bindings after channel/account cache rollover (#36915)", () => { + const bindingCount = 2_205; + const cfg: OpenClawConfig = { + bindings: Array.from({ length: bindingCount }, (_, idx) => ({ + agentId: `agent-${idx}`, + match: { + channel: "dingtalk", + accountId: `acct-${idx}`, + peer: { kind: "direct", id: `user-${idx}` }, + }, + })), + }; + const listBindingsSpy = vi.spyOn(routingBindings, "listBindings"); + try { + for (let idx = 0; idx < bindingCount; idx += 1) { + const route = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: `acct-${idx}`, + peer: { kind: "direct", id: `user-${idx}` }, + }); + expect(route.agentId).toBe(`agent-${idx}`); + expect(route.matchedBy).toBe("binding.peer"); + } + + const repeated = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-0", + peer: { kind: "direct", id: "user-0" }, + }); + expect(repeated.agentId).toBe("agent-0"); + expect(listBindingsSpy).toHaveBeenCalledTimes(1); + } finally { + listBindingsSpy.mockRestore(); + } + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index b2310e20ae8..29a7d9c1152 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -72,17 +72,6 @@ function normalizeId(value: unknown): string { return ""; } -function matchesAccountId(match: string | undefined, actual: string): boolean { - const trimmed = (match ?? "").trim(); - if (!trimmed) { - return actual === DEFAULT_ACCOUNT_ID; - } - if (trimmed === "*") { - return true; - } - return normalizeAccountId(trimmed) === actual; -} - export function buildAgentSessionKey(params: { agentId: string; channel: string; @@ -160,17 +149,6 @@ export function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): return lookup.fallbackDefaultAgentId; } -function matchesChannel( - match: { channel?: string | undefined } | undefined, - channel: string, -): boolean { - const key = normalizeToken(match?.channel); - if (!key) { - return false; - } - return key === channel; -} - type NormalizedPeerConstraint = | { state: "none" } | { state: "invalid" } @@ -187,6 +165,7 @@ type NormalizedBindingMatch = { type EvaluatedBinding = { binding: ReturnType[number]; match: NormalizedBindingMatch; + order: number; }; type BindingScope = { @@ -198,6 +177,7 @@ type BindingScope = { type EvaluatedBindingsCache = { bindingsRef: OpenClawConfig["bindings"]; + byChannel: Map; byChannelAccount: Map; byChannelAccountIndex: Map; }; @@ -224,6 +204,101 @@ type EvaluatedBindingsIndex = { byChannel: EvaluatedBinding[]; }; +type EvaluatedBindingsByChannel = { + byAccount: Map; + byAnyAccount: EvaluatedBinding[]; +}; + +function resolveAccountPatternKey(accountPattern: string): string { + if (!accountPattern.trim()) { + return DEFAULT_ACCOUNT_ID; + } + return normalizeAccountId(accountPattern); +} + +function buildEvaluatedBindingsByChannel( + cfg: OpenClawConfig, +): Map { + const byChannel = new Map(); + let order = 0; + for (const binding of listBindings(cfg)) { + if (!binding || typeof binding !== "object") { + continue; + } + const channel = normalizeToken(binding.match?.channel); + if (!channel) { + continue; + } + const match = normalizeBindingMatch(binding.match); + const evaluated: EvaluatedBinding = { + binding, + match, + order, + }; + order += 1; + let bucket = byChannel.get(channel); + if (!bucket) { + bucket = { + byAccount: new Map(), + byAnyAccount: [], + }; + byChannel.set(channel, bucket); + } + if (match.accountPattern === "*") { + bucket.byAnyAccount.push(evaluated); + continue; + } + const accountKey = resolveAccountPatternKey(match.accountPattern); + const existing = bucket.byAccount.get(accountKey); + if (existing) { + existing.push(evaluated); + continue; + } + bucket.byAccount.set(accountKey, [evaluated]); + } + return byChannel; +} + +function mergeEvaluatedBindingsInSourceOrder( + accountScoped: EvaluatedBinding[], + anyAccount: EvaluatedBinding[], +): EvaluatedBinding[] { + if (accountScoped.length === 0) { + return anyAccount; + } + if (anyAccount.length === 0) { + return accountScoped; + } + const merged: EvaluatedBinding[] = []; + let accountIdx = 0; + let anyIdx = 0; + while (accountIdx < accountScoped.length && anyIdx < anyAccount.length) { + const accountBinding = accountScoped[accountIdx]; + const anyBinding = anyAccount[anyIdx]; + if ( + (accountBinding?.order ?? Number.MAX_SAFE_INTEGER) <= + (anyBinding?.order ?? Number.MAX_SAFE_INTEGER) + ) { + if (accountBinding) { + merged.push(accountBinding); + } + accountIdx += 1; + continue; + } + if (anyBinding) { + merged.push(anyBinding); + } + anyIdx += 1; + } + if (accountIdx < accountScoped.length) { + merged.push(...accountScoped.slice(accountIdx)); + } + if (anyIdx < anyAccount.length) { + merged.push(...anyAccount.slice(anyIdx)); + } + return merged; +} + function pushToIndexMap( map: Map, key: string | null, @@ -331,6 +406,7 @@ function getEvaluatedBindingsForChannelAccount( ? existing : { bindingsRef, + byChannel: buildEvaluatedBindingsByChannel(cfg), byChannelAccount: new Map(), byChannelAccountIndex: new Map(), }; @@ -344,18 +420,10 @@ function getEvaluatedBindingsForChannelAccount( return hit; } - const evaluated: EvaluatedBinding[] = listBindings(cfg).flatMap((binding) => { - if (!binding || typeof binding !== "object") { - return []; - } - if (!matchesChannel(binding.match, channel)) { - return []; - } - if (!matchesAccountId(binding.match?.accountId, accountId)) { - return []; - } - return [{ binding, match: normalizeBindingMatch(binding.match) }]; - }); + const channelBindings = cache.byChannel.get(channel); + const accountScoped = channelBindings?.byAccount.get(accountId) ?? []; + const anyAccount = channelBindings?.byAnyAccount ?? []; + const evaluated = mergeEvaluatedBindingsInSourceOrder(accountScoped, anyAccount); cache.byChannelAccount.set(cacheKey, evaluated); cache.byChannelAccountIndex.set(cacheKey, buildEvaluatedBindingsIndex(evaluated)); From d86a12eb6223a97e5614bbb18804e1509609d1fc Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 17:04:26 -0800 Subject: [PATCH 06/69] fix(gateway): honor insecure ws override for remote hostnames --- CHANGELOG.md | 1 + src/commands/onboard-remote.test.ts | 21 +++++++++++++++++++++ src/gateway/call.test.ts | 17 +++++++++++++++++ src/gateway/client.test.ts | 15 +++++++++++++++ src/gateway/net.test.ts | 9 +++++++++ src/gateway/net.ts | 11 ++++++++++- 6 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 218dd90ffab..6e4e8d3e616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. - Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin. diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index 984f9c0fc47..58a2715c3a3 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -132,6 +132,27 @@ describe("promptRemoteGatewayConfig", () => { expect(next.gateway?.remote?.token).toBeUndefined(); }); + it("allows ws:// hostname remote URLs when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", async () => { + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + expect(params.validate?.("ws://openclaw-gateway.ai:18789")).toBeUndefined(); + expect(params.validate?.("ws://1.1.1.1:18789")).toContain("Use wss://"); + return "ws://openclaw-gateway.ai:18789"; + } + return ""; + }) as WizardPrompter["text"]; + + const { next } = await runRemotePrompt({ + text, + confirm: false, + selectResponses: { "Gateway auth": "off" }, + }); + + expect(next.gateway?.mode).toBe("remote"); + expect(next.gateway?.remote?.url).toBe("ws://openclaw-gateway.ai:18789"); + }); + it("supports storing remote auth as an external env secret ref", async () => { process.env.OPENCLAW_GATEWAY_TOKEN = "remote-token-value"; const text: WizardPrompter["text"] = vi.fn(async (params) => { diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index d810121d351..7ab4cf7b231 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -468,6 +468,23 @@ describe("buildGatewayConnectionDetails", () => { expect(details.urlSource).toBe("config gateway.remote.url"); }); + it("allows ws:// hostname remote URLs when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + bind: "loopback", + remote: { url: "ws://openclaw-gateway.ai:18789" }, + }, + }); + resolveGatewayPort.mockReturnValue(18789); + + const details = buildGatewayConnectionDetails(); + + expect(details.url).toBe("ws://openclaw-gateway.ai:18789"); + expect(details.urlSource).toBe("config gateway.remote.url"); + }); + it("allows ws:// for loopback addresses in local mode", () => { setLocalLoopbackGatewayConfig(); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index e6e38693e56..c69cbef39ee 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -228,6 +228,21 @@ describe("GatewayClient security checks", () => { expect(wsInstances.length).toBe(1); client.stop(); }); + + it("allows ws:// hostnames with OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + const onConnectError = vi.fn(); + const client = new GatewayClient({ + url: "ws://openclaw-gateway.ai:18789", + onConnectError, + }); + + client.start(); + + expect(onConnectError).not.toHaveBeenCalled(); + expect(wsInstances.length).toBe(1); + client.stop(); + }); }); describe("GatewayClient close handling", () => { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 3ab82c85a52..1faf727a856 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -457,6 +457,7 @@ describe("isSecureWebSocketUrl", () => { "ws://169.254.10.20:18789", "ws://[fc00::1]:18789", "ws://[fe80::1]:18789", + "ws://gateway.private.example:18789", ]; for (const input of allowedWhenOptedIn) { @@ -464,6 +465,14 @@ describe("isSecureWebSocketUrl", () => { } }); + it("still rejects ws:// public IP literals when opt-in is enabled", () => { + const publicIpWsUrls = ["ws://1.1.1.1:18789", "ws://8.8.8.8:18789", "ws://203.0.113.10:18789"]; + + for (const input of publicIpWsUrls) { + expect(isSecureWebSocketUrl(input, { allowPrivateWs: true }), input).toBe(false); + } + }); + it("still rejects non-unicast IPv6 ws:// even when opt-in is enabled", () => { const disallowedWhenOptedIn = [ "ws://[::]:18789", diff --git a/src/gateway/net.ts b/src/gateway/net.ts index b4d647a487e..d57915fdcc0 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -435,7 +435,16 @@ export function isSecureWebSocketUrl( } // Optional break-glass for trusted private-network overlays. if (opts?.allowPrivateWs) { - return isPrivateOrLoopbackHost(parsed.hostname); + if (isPrivateOrLoopbackHost(parsed.hostname)) { + return true; + } + // Hostnames may resolve to private networks (for example in VPN/Tailnet DNS), + // but resolution is not available in this synchronous validator. + const hostForIpCheck = + parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") + ? parsed.hostname.slice(1, -1) + : parsed.hostname; + return net.isIP(hostForIpCheck) === 0; } return false; } From 3cd4978a09c47222233dfb8a150032f983c80024 Mon Sep 17 00:00:00 2001 From: vignesh07 Date: Thu, 5 Mar 2026 17:16:14 -0800 Subject: [PATCH 07/69] fix(llm-task): load runEmbeddedPiAgent from dist/extensionAPI in installs --- extensions/llm-task/src/llm-task-tool.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index cf0c0250d0a..869e9f8351e 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -25,11 +25,14 @@ async function loadRunEmbeddedPiAgent(): Promise { } // Bundled install (built) - const mod = await import("../../../src/agents/pi-embedded-runner.js"); - if (typeof mod.runEmbeddedPiAgent !== "function") { + // NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint. + const mod = await import("../../../dist/extensionAPI.js"); + // oxlint-disable-next-line typescript/no-explicit-any + const fn = (mod as any).runEmbeddedPiAgent; + if (typeof fn !== "function") { throw new Error("Internal error: runEmbeddedPiAgent not available"); } - return mod.runEmbeddedPiAgent as RunEmbeddedPiAgentFn; + return fn as RunEmbeddedPiAgentFn; } function stripCodeFences(s: string): string { From 92b48921274bea999daf0a643ee90ae69d8d5343 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 17:16:14 -0800 Subject: [PATCH 08/69] fix(auth): harden openai-codex oauth login path --- CHANGELOG.md | 1 + src/commands/models/auth.test.ts | 182 ++++++++++++++++++++++++ src/commands/models/auth.ts | 65 ++++++++- src/commands/openai-codex-oauth.test.ts | 69 ++++++++- src/commands/openai-codex-oauth.ts | 47 ++++++ 5 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 src/commands/models/auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4e8d3e616..d330d2f2675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- OpenAI Codex OAuth/login hardening: fail OAuth completion early when the returned token is missing `api.responses.write`, and allow `openclaw models auth login --provider openai-codex` to use the built-in OAuth path even when no provider plugins are installed. (#36660) Thanks @driesvints. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts new file mode 100644 index 00000000000..c05c1480096 --- /dev/null +++ b/src/commands/models/auth.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + resolveDefaultAgentId: vi.fn(), + resolveAgentDir: vi.fn(), + resolveAgentWorkspaceDir: vi.fn(), + resolveDefaultAgentWorkspaceDir: vi.fn(), + resolvePluginProviders: vi.fn(), + createClackPrompter: vi.fn(), + loginOpenAICodexOAuth: vi.fn(), + writeOAuthCredentials: vi.fn(), + loadValidConfigOrThrow: vi.fn(), + updateConfig: vi.fn(), + logConfigUpdated: vi.fn(), + openUrl: vi.fn(), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: mocks.resolveDefaultAgentId, + resolveAgentDir: mocks.resolveAgentDir, + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, +})); + +vi.mock("../../agents/workspace.js", () => ({ + resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir, +})); + +vi.mock("../../plugins/providers.js", () => ({ + resolvePluginProviders: mocks.resolvePluginProviders, +})); + +vi.mock("../../wizard/clack-prompter.js", () => ({ + createClackPrompter: mocks.createClackPrompter, +})); + +vi.mock("../openai-codex-oauth.js", () => ({ + loginOpenAICodexOAuth: mocks.loginOpenAICodexOAuth, +})); + +vi.mock("../onboard-auth.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + writeOAuthCredentials: mocks.writeOAuthCredentials, + }; +}); + +vi.mock("./shared.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + loadValidConfigOrThrow: mocks.loadValidConfigOrThrow, + updateConfig: mocks.updateConfig, + }; +}); + +vi.mock("../../config/logging.js", () => ({ + logConfigUpdated: mocks.logConfigUpdated, +})); + +vi.mock("../onboard-helpers.js", () => ({ + openUrl: mocks.openUrl, +})); + +const { modelsAuthLoginCommand } = await import("./auth.js"); + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +function withInteractiveStdin() { + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; + const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); + const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); + Object.defineProperty(stdin, "isTTY", { + configurable: true, + enumerable: true, + get: () => true, + }); + return () => { + if (previousIsTTYDescriptor) { + Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); + } else if (!hadOwnIsTTY) { + delete (stdin as { isTTY?: boolean }).isTTY; + } + }; +} + +describe("modelsAuthLoginCommand", () => { + let restoreStdin: (() => void) | null = null; + let currentConfig: OpenClawConfig; + let lastUpdatedConfig: OpenClawConfig | null; + + beforeEach(() => { + vi.clearAllMocks(); + restoreStdin = withInteractiveStdin(); + currentConfig = {}; + lastUpdatedConfig = null; + + mocks.resolveDefaultAgentId.mockReturnValue("main"); + mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main"); + mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace"); + mocks.resolveDefaultAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace"); + mocks.loadValidConfigOrThrow.mockImplementation(async () => currentConfig); + mocks.updateConfig.mockImplementation( + async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => { + lastUpdatedConfig = mutator(currentConfig); + currentConfig = lastUpdatedConfig; + return lastUpdatedConfig; + }, + ); + mocks.createClackPrompter.mockReturnValue({ + note: vi.fn(async () => {}), + select: vi.fn(), + }); + mocks.loginOpenAICodexOAuth.mockResolvedValue({ + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }); + mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com"); + mocks.resolvePluginProviders.mockReturnValue([]); + }); + + afterEach(() => { + restoreStdin?.(); + restoreStdin = null; + }); + + it("supports built-in openai-codex login without provider plugins", async () => { + const runtime = createRuntime(); + + await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + + expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); + expect(mocks.writeOAuthCredentials).toHaveBeenCalledWith( + "openai-codex", + expect.any(Object), + "/tmp/openclaw/agents/main", + { syncSiblingAgents: true }, + ); + expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); + expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({ + provider: "openai-codex", + mode: "oauth", + }); + expect(runtime.log).toHaveBeenCalledWith( + "Auth profile: openai-codex:user@example.com (openai-codex/oauth)", + ); + expect(runtime.log).toHaveBeenCalledWith( + "Default model available: openai-codex/gpt-5.3-codex (use --set-default to apply)", + ); + }); + + it("applies openai-codex default model when --set-default is used", async () => { + const runtime = createRuntime(); + + await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime); + + expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ + primary: "openai-codex/gpt-5.3-codex", + }); + expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.3-codex"); + }); + + it("keeps existing plugin error behavior for non built-in providers", async () => { + const runtime = createRuntime(); + + await expect(modelsAuthLoginCommand({ provider: "anthropic" }, runtime)).rejects.toThrow( + "No provider plugins found.", + ); + }); +}); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 60fd8ed58ab..16fda7985e6 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -19,8 +19,13 @@ import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { validateAnthropicSetupToken } from "../auth-token.js"; import { isRemoteEnvironment } from "../oauth-env.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; -import { applyAuthProfileConfig } from "../onboard-auth.js"; +import { applyAuthProfileConfig, writeOAuthCredentials } from "../onboard-auth.js"; import { openUrl } from "../onboard-helpers.js"; +import { + applyOpenAICodexModelDefault, + OPENAI_CODEX_DEFAULT_MODEL, +} from "../openai-codex-model-default.js"; +import { loginOpenAICodexOAuth } from "../openai-codex-oauth.js"; import { applyDefaultModel, mergeConfigPatch, @@ -272,6 +277,51 @@ function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" return "oauth"; } +async function runBuiltInOpenAICodexLogin(params: { + opts: LoginOptions; + runtime: RuntimeEnv; + prompter: ReturnType; + agentDir: string; +}) { + const creds = await loginOpenAICodexOAuth({ + prompter: params.prompter, + runtime: params.runtime, + isRemote: isRemoteEnvironment(), + openUrl: async (url) => { + await openUrl(url); + }, + localBrowserMessage: "Complete sign-in in browser…", + }); + if (!creds) { + throw new Error("OpenAI Codex OAuth did not return credentials."); + } + + const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir, { + syncSiblingAgents: true, + }); + await updateConfig((cfg) => { + let next = applyAuthProfileConfig(cfg, { + profileId, + provider: "openai-codex", + mode: "oauth", + }); + if (params.opts.setDefault) { + next = applyOpenAICodexModelDefault(next).next; + } + return next; + }); + + logConfigUpdated(params.runtime); + params.runtime.log(`Auth profile: ${profileId} (openai-codex/oauth)`); + if (params.opts.setDefault) { + params.runtime.log(`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`); + } else { + params.runtime.log( + `Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`, + ); + } +} + export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { if (!process.stdin.isTTY) { throw new Error("models auth login requires an interactive TTY."); @@ -282,6 +332,18 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim const agentDir = resolveAgentDir(config, defaultAgentId); const workspaceDir = resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); + const requestedProviderId = normalizeProviderId(String(opts.provider ?? "")); + const prompter = createClackPrompter(); + + if (requestedProviderId === "openai-codex") { + await runBuiltInOpenAICodexLogin({ + opts, + runtime, + prompter, + agentDir, + }); + return; + } const providers = resolvePluginProviders({ config, workspaceDir }); if (providers.length === 0) { @@ -290,7 +352,6 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim ); } - const prompter = createClackPrompter(); const requestedProvider = resolveRequestedLoginProviderOrThrow(providers, opts.provider); const selectedProvider = requestedProvider ?? diff --git a/src/commands/openai-codex-oauth.test.ts b/src/commands/openai-codex-oauth.test.ts index b3b3846f9ee..3bbdb82551b 100644 --- a/src/commands/openai-codex-oauth.test.ts +++ b/src/commands/openai-codex-oauth.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -56,10 +56,30 @@ async function runCodexOAuth(params: { isRemote: boolean }) { } describe("loginOpenAICodexOAuth", () => { + let restoreFetch: (() => void) | null = null; + beforeEach(() => { vi.clearAllMocks(); mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: true }); mocks.formatOpenAIOAuthTlsPreflightFix.mockReturnValue("tls fix"); + + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn( + async () => + new Response('{"error":{"message":"model is required"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + restoreFetch = () => { + globalThis.fetch = originalFetch; + }; + }); + + afterEach(() => { + restoreFetch?.(); + restoreFetch = null; }); it("returns credentials on successful oauth login", async () => { @@ -136,6 +156,53 @@ describe("loginOpenAICodexOAuth", () => { expect(runtime.error).not.toHaveBeenCalledWith("tls fix"); expect(prompter.note).not.toHaveBeenCalledWith("tls fix", "OAuth prerequisites"); }); + + it("fails with actionable error when token is missing api.responses.write scope", async () => { + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ + onAuth: vi.fn(), + onPrompt: vi.fn(), + }); + mocks.loginOpenAICodex.mockResolvedValue({ + provider: "openai-codex" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }); + globalThis.fetch = vi.fn( + async () => + new Response('{"error":{"message":"Missing scopes: api.responses.write"}}', { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) as unknown as typeof fetch; + + await expect(runCodexOAuth({ isRemote: false })).rejects.toThrow( + "missing required scope: api.responses.write", + ); + }); + + it("does not fail oauth completion when scope probe is unavailable", async () => { + const creds = { + provider: "openai-codex" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }; + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ + onAuth: vi.fn(), + onPrompt: vi.fn(), + }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + globalThis.fetch = vi.fn(async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + + const { result } = await runCodexOAuth({ isRemote: false }); + expect(result).toEqual(creds); + }); + it("fails early with actionable message when TLS preflight fails", async () => { mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: false, diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index a9fbc1849c8..342e2c6cc91 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -8,6 +8,41 @@ import { runOpenAIOAuthTlsPreflight, } from "./oauth-tls-preflight.js"; +const OPENAI_RESPONSES_ENDPOINT = "https://api.openai.com/v1/responses"; +const OPENAI_RESPONSES_WRITE_SCOPE = "api.responses.write"; + +function extractResponsesScopeErrorMessage(status: number, bodyText: string): string | null { + if (status !== 401) { + return null; + } + const normalized = bodyText.toLowerCase(); + if ( + normalized.includes("missing scope") && + normalized.includes(OPENAI_RESPONSES_WRITE_SCOPE.toLowerCase()) + ) { + return bodyText.trim() || `Missing scopes: ${OPENAI_RESPONSES_WRITE_SCOPE}`; + } + return null; +} + +async function detectMissingResponsesWriteScope(accessToken: string): Promise { + try { + const response = await fetch(OPENAI_RESPONSES_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: "{}", + }); + const bodyText = await response.text(); + return extractResponsesScopeErrorMessage(response.status, bodyText); + } catch { + // Best effort only: network/TLS issues should not block successful OAuth completion. + return null; + } +} + export async function loginOpenAICodexOAuth(params: { prompter: WizardPrompter; runtime: RuntimeEnv; @@ -55,6 +90,18 @@ export async function loginOpenAICodexOAuth(params: { onPrompt, onProgress: (msg) => spin.update(msg), }); + if (creds?.access) { + const scopeError = await detectMissingResponsesWriteScope(creds.access); + if (scopeError) { + throw new Error( + [ + `OpenAI OAuth token is missing required scope: ${OPENAI_RESPONSES_WRITE_SCOPE}.`, + `Provider response: ${scopeError}`, + "Re-authenticate with OpenAI Codex OAuth or use OPENAI_API_KEY with openai/* models.", + ].join(" "), + ); + } + } spin.stop("OpenAI OAuth complete"); return creds ?? null; } catch (err) { From d58dafae8836d970799a35a64589e03911f681e8 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 5 Mar 2026 20:17:50 -0500 Subject: [PATCH 09/69] feat(telegram/acp): Topic Binding, Pin Binding Message, Fix Spawn Param Parsing (#36683) * fix(acp): normalize unicode flags and Telegram topic binding * feat(telegram/acp): restore topic-bound ACP and session bindings * fix(acpx): clarify permission-denied guidance * feat(telegram/acp): pin spawn bind notice in topics * docs(telegram): document ACP topic thread binding behavior * refactor(reply): share Telegram conversation-id resolver * fix(telegram/acp): preserve bound session routing semantics * fix(telegram): respect binding persistence and expiry reporting * refactor(telegram): simplify binding lifecycle persistence * fix(telegram): bind acp spawns in direct messages * fix: document telegram ACP topic binding changelog (#36683) (thanks @huntharo) --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/telegram.md | 7 + docs/tools/acp-agents.md | 9 +- .../src/runtime-internals/test-fixtures.ts | 4 + extensions/acpx/src/runtime.test.ts | 36 + extensions/acpx/src/runtime.ts | 30 +- src/auto-reply/commands-registry.data.ts | 5 +- ...{discord-context.ts => channel-context.ts} | 20 +- src/auto-reply/reply/commands-acp.test.ts | 159 +++- .../reply/commands-acp/context.test.ts | 18 + src/auto-reply/reply/commands-acp/context.ts | 30 +- .../reply/commands-acp/lifecycle.ts | 54 +- .../reply/commands-acp/shared.test.ts | 22 + src/auto-reply/reply/commands-acp/shared.ts | 50 +- .../reply/commands-session-lifecycle.test.ts | 148 +++- src/auto-reply/reply/commands-session.ts | 226 ++++-- .../reply/commands-subagents-focus.test.ts | 340 +++----- src/auto-reply/reply/commands-subagents.ts | 2 +- .../reply/commands-subagents/action-agents.ts | 86 +- .../reply/commands-subagents/action-focus.ts | 137 +++- .../commands-subagents/action-unfocus.ts | 76 +- .../reply/commands-subagents/shared.ts | 18 +- src/auto-reply/reply/telegram-context.test.ts | 47 ++ src/auto-reply/reply/telegram-context.ts | 41 + src/config/schema.help.ts | 10 + src/config/schema.labels.ts | 5 + src/config/types.telegram.ts | 3 + src/config/zod-schema.providers-core.ts | 10 + ...bot-message-context.thread-binding.test.ts | 116 +++ src/telegram/bot-message-context.ts | 34 +- src/telegram/bot.ts | 33 + src/telegram/bot/delivery.replies.ts | 127 ++- src/telegram/bot/delivery.test.ts | 39 + src/telegram/thread-bindings.test.ts | 166 ++++ src/telegram/thread-bindings.ts | 741 ++++++++++++++++++ 35 files changed, 2397 insertions(+), 453 deletions(-) rename src/auto-reply/reply/{discord-context.ts => channel-context.ts} (59%) create mode 100644 src/auto-reply/reply/commands-acp/shared.test.ts create mode 100644 src/auto-reply/reply/telegram-context.test.ts create mode 100644 src/auto-reply/reply/telegram-context.ts create mode 100644 src/telegram/bot-message-context.thread-binding.test.ts create mode 100644 src/telegram/thread-bindings.test.ts create mode 100644 src/telegram/thread-bindings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d330d2f2675..60f3ca8bbfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant. - Plugins/hook policy: add `plugins.entries..hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras. - Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras. +- Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in `/acp spawn`, support Telegram topic thread binding (`--thread here|auto`), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo. ### Breaking diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 58fbe8b9023..817ae1d51d4 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -524,6 +524,13 @@ curl "https://api.telegram.org/bot/getUpdates" This is currently scoped to forum topics in groups and supergroups. + **Thread-bound ACP spawn from chat**: + + - `/acp spawn --thread here|auto` can bind the current Telegram topic to a new ACP session. + - Follow-up topic messages route to the bound ACP session directly (no `/acp steer` required). + - OpenClaw pins the spawn confirmation message in-topic after a successful bind. + - Requires `channels.telegram.threadBindings.spawnAcpSessions=true`. + Template context includes: - `MessageThreadId` diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 2003758cc1d..aa51e986552 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -79,11 +79,14 @@ Required feature flags for thread-bound ACP: - `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch) - Channel-adapter ACP thread-spawn flag enabled (adapter-specific) - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` + - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` ### Thread supporting channels - Any channel adapter that exposes session/thread binding capability. -- Current built-in support: Discord. +- Current built-in support: + - Discord threads/channels + - Telegram topics (forum topics in groups/supergroups and DM topics) - Plugin channels can add support through the same binding interface. ## Channel specific settings @@ -303,7 +306,9 @@ If no target resolves, OpenClaw returns a clear error (`Unable to resolve sessio Notes: - On non-thread binding surfaces, default behavior is effectively `off`. -- Thread-bound spawn requires channel policy support (for Discord: `channels.discord.threadBindings.spawnAcpSessions=true`). +- Thread-bound spawn requires channel policy support: + - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` + - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` ## ACP controls diff --git a/extensions/acpx/src/runtime-internals/test-fixtures.ts b/extensions/acpx/src/runtime-internals/test-fixtures.ts index f5d79122546..5d333f709dd 100644 --- a/extensions/acpx/src/runtime-internals/test-fixtures.ts +++ b/extensions/acpx/src/runtime-internals/test-fixtures.ts @@ -223,6 +223,10 @@ if (command === "prompt") { process.exit(1); } + if (stdinText.includes("permission-denied")) { + process.exit(5); + } + if (stdinText.includes("split-spacing")) { emitUpdate(sessionFromOption, { sessionUpdate: "agent_message_chunk", diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 5e4baf7f3cb..4fe92fc9090 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -224,6 +224,42 @@ describe("AcpxRuntime", () => { }); }); + it("maps acpx permission-denied exits to actionable guidance", async () => { + const runtime = sharedFixture?.runtime; + expect(runtime).toBeDefined(); + if (!runtime) { + throw new Error("shared runtime fixture missing"); + } + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:permission-denied", + agent: "codex", + mode: "persistent", + }); + + const events = []; + for await (const event of runtime.runTurn({ + handle, + text: "permission-denied", + mode: "prompt", + requestId: "req-perm", + })) { + events.push(event); + } + + expect(events).toContainEqual( + expect.objectContaining({ + type: "error", + message: expect.stringContaining("Permission denied by ACP runtime (acpx)."), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "error", + message: expect.stringContaining("approve-reads, approve-all, deny-all"), + }), + ); + }); + it("supports cancel and close using encoded runtime handle state", async () => { const { runtime, logPath, config } = await createMockRuntimeFixture(); const handle = await runtime.ensureSession({ diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 8a7783a704c..5fe3c36c70d 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -42,10 +42,30 @@ export const ACPX_BACKEND_ID = "acpx"; const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:"; const DEFAULT_AGENT_FALLBACK = "codex"; +const ACPX_EXIT_CODE_PERMISSION_DENIED = 5; const ACPX_CAPABILITIES: AcpRuntimeCapabilities = { controls: ["session/set_mode", "session/set_config_option", "session/status"], }; +function formatPermissionModeGuidance(): string { + return "Configure plugins.entries.acpx.config.permissionMode to one of: approve-reads, approve-all, deny-all."; +} + +function formatAcpxExitMessage(params: { + stderr: string; + exitCode: number | null | undefined; +}): string { + const stderr = params.stderr.trim(); + if (params.exitCode === ACPX_EXIT_CODE_PERMISSION_DENIED) { + return [ + stderr || "Permission denied by ACP runtime (acpx).", + "ACPX blocked a write/exec permission request in a non-interactive session.", + formatPermissionModeGuidance(), + ].join(" "); + } + return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`; +} + export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string { const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`; @@ -333,7 +353,10 @@ export class AcpxRuntime implements AcpRuntime { if ((exit.code ?? 0) !== 0 && !sawError) { yield { type: "error", - message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`, + message: formatAcpxExitMessage({ + stderr, + exitCode: exit.code, + }), }; return; } @@ -639,7 +662,10 @@ export class AcpxRuntime implements AcpRuntime { if ((result.code ?? 0) !== 0) { throw new AcpRuntimeError( params.fallbackCode, - result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`, + formatAcpxExitMessage({ + stderr: result.stderr, + exitCode: result.code, + }), ); } return events; diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 19c1a7d3746..6a2bf205ffd 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -354,7 +354,8 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "focus", nativeName: "focus", - description: "Bind this Discord thread (or a new one) to a session target.", + description: + "Bind this thread (Discord) or topic/conversation (Telegram) to a session target.", textAlias: "/focus", category: "management", args: [ @@ -369,7 +370,7 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "unfocus", nativeName: "unfocus", - description: "Remove the current Discord thread binding.", + description: "Remove the current thread (Discord) or topic/conversation (Telegram) binding.", textAlias: "/unfocus", category: "management", }), diff --git a/src/auto-reply/reply/discord-context.ts b/src/auto-reply/reply/channel-context.ts similarity index 59% rename from src/auto-reply/reply/discord-context.ts rename to src/auto-reply/reply/channel-context.ts index 2eb810d5e1d..d8ffb261eb8 100644 --- a/src/auto-reply/reply/discord-context.ts +++ b/src/auto-reply/reply/channel-context.ts @@ -17,19 +17,29 @@ type DiscordAccountParams = { }; export function isDiscordSurface(params: DiscordSurfaceParams): boolean { + return resolveCommandSurfaceChannel(params) === "discord"; +} + +export function isTelegramSurface(params: DiscordSurfaceParams): boolean { + return resolveCommandSurfaceChannel(params) === "telegram"; +} + +export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string { const channel = params.ctx.OriginatingChannel ?? params.command.channel ?? params.ctx.Surface ?? params.ctx.Provider; - return ( - String(channel ?? "") - .trim() - .toLowerCase() === "discord" - ); + return String(channel ?? "") + .trim() + .toLowerCase(); } export function resolveDiscordAccountId(params: DiscordAccountParams): string { + return resolveChannelAccountId(params); +} + +export function resolveChannelAccountId(params: DiscordAccountParams): string { const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : ""; return accountId || "default"; } diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 444aec7f84c..5850e003b5a 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -118,7 +118,7 @@ type FakeBinding = { targetSessionKey: string; targetKind: "subagent" | "session"; conversation: { - channel: "discord"; + channel: "discord" | "telegram"; accountId: string; conversationId: string; parentConversationId?: string; @@ -242,7 +242,11 @@ function createSessionBindingCapabilities() { type AcpBindInput = { targetSessionKey: string; - conversation: { accountId: string; conversationId: string }; + conversation: { + channel?: "discord" | "telegram"; + accountId: string; + conversationId: string; + }; placement: "current" | "child"; metadata?: Record; }; @@ -251,14 +255,22 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding { const nextConversationId = input.placement === "child" ? "thread-created" : input.conversation.conversationId; const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1"; + const channel = input.conversation.channel ?? "discord"; return createSessionBinding({ targetSessionKey: input.targetSessionKey, - conversation: { - channel: "discord", - accountId: input.conversation.accountId, - conversationId: nextConversationId, - parentConversationId: "parent-1", - }, + conversation: + channel === "discord" + ? { + channel: "discord", + accountId: input.conversation.accountId, + conversationId: nextConversationId, + parentConversationId: "parent-1", + } + : { + channel: "telegram", + accountId: input.conversation.accountId, + conversationId: nextConversationId, + }, metadata: { boundBy, webhookId: "wh-1" }, }); } @@ -297,6 +309,31 @@ function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) return params; } +function createTelegramTopicParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:-1003841603622", + AccountId: "default", + MessageThreadId: "498", + }); + params.command.senderId = "user-1"; + return params; +} + +function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:123456789", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { return handleAcpCommand(createDiscordParams(commandBody, cfg), true); } @@ -305,6 +342,14 @@ async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = ba return handleAcpCommand(createThreadParams(commandBody, cfg), true); } +async function runTelegramAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createTelegramTopicParams(commandBody, cfg), true); +} + +async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true); +} + describe("/acp command", () => { beforeEach(() => { acpManagerTesting.resetAcpSessionManagerForTests(); @@ -448,10 +493,70 @@ describe("/acp command", () => { expect(seededWithoutEntry?.runtimeSessionName).toContain(":runtime"); }); + it("accepts unicode dash option prefixes in /acp spawn args", async () => { + const result = await runThreadAcpCommand( + "/acp spawn codex \u2014mode oneshot \u2014thread here \u2014cwd /home/bob/clawd \u2014label jeerreview", + ); + + expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); + expect(result?.reply?.text).toContain("Bound this thread to"); + expect(hoisted.ensureSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + agent: "codex", + mode: "oneshot", + cwd: "/home/bob/clawd", + }), + ); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + metadata: expect.objectContaining({ + label: "jeerreview", + }), + }), + ); + }); + + it("binds Telegram topic ACP spawns to full conversation ids", async () => { + const result = await runTelegramAcpCommand("/acp spawn codex --thread here"); + + expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); + expect(result?.reply?.text).toContain("Bound this conversation to"); + expect(result?.reply?.channelData).toEqual({ telegram: { pin: true } }); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + conversation: expect.objectContaining({ + channel: "telegram", + accountId: "default", + conversationId: "-1003841603622:topic:498", + }), + }), + ); + }); + + it("binds Telegram DM ACP spawns to the DM conversation id", async () => { + const result = await runTelegramDmAcpCommand("/acp spawn codex --thread here"); + + expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); + expect(result?.reply?.text).toContain("Bound this conversation to"); + expect(result?.reply?.channelData).toBeUndefined(); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + conversation: expect.objectContaining({ + channel: "telegram", + accountId: "default", + conversationId: "123456789", + }), + }), + ); + }); + it("requires explicit ACP target when acp.defaultAgent is not configured", async () => { const result = await runDiscordAcpCommand("/acp spawn"); - expect(result?.reply?.text).toContain("ACP target agent is required"); + expect(result?.reply?.text).toContain("ACP target harness id is required"); expect(hoisted.ensureSessionMock).not.toHaveBeenCalled(); }); @@ -528,6 +633,42 @@ describe("/acp command", () => { expect(result?.reply?.text).toContain("Applied steering."); }); + it("resolves bound Telegram topic ACP sessions for /acp steer without explicit target", async () => { + hoisted.sessionBindingResolveByConversationMock.mockImplementation( + (ref: { channel?: string; accountId?: string; conversationId?: string }) => + ref.channel === "telegram" && + ref.accountId === "default" && + ref.conversationId === "-1003841603622:topic:498" + ? createSessionBinding({ + targetSessionKey: defaultAcpSessionKey, + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1003841603622:topic:498", + }, + }) + : null, + ); + hoisted.readAcpSessionEntryMock.mockReturnValue(createAcpSessionEntry()); + hoisted.runTurnMock.mockImplementation(async function* () { + yield { type: "text_delta", text: "Viewed diver package." }; + yield { type: "done" }; + }); + + const result = await runTelegramAcpCommand("/acp steer use npm to view package diver"); + + expect(hoisted.runTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + handle: expect.objectContaining({ + sessionKey: defaultAcpSessionKey, + }), + mode: "steer", + text: "use npm to view package diver", + }), + ); + expect(result?.reply?.text).toContain("Viewed diver package."); + }); + it("blocks /acp steer when ACP dispatch is disabled by policy", async () => { const cfg = { ...baseCfg, diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 9ba70225de6..18136b67b03 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -108,4 +108,22 @@ describe("commands-acp context", () => { }); expect(resolveAcpCommandConversationId(params)).toBe("-1001234567890:topic:42"); }); + + it("resolves Telegram DM conversation ids from telegram targets", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:123456789", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "telegram", + accountId: "default", + threadId: undefined, + conversationId: "123456789", + parentConversationId: "123456789", + }); + expect(resolveAcpCommandConversationId(params)).toBe("123456789"); + }); }); diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 78e2e7a32a9..16291713fda 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -6,6 +6,7 @@ import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-binding import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; +import { resolveTelegramConversationId } from "../telegram-context.js"; function normalizeString(value: unknown): string { if (typeof value === "string") { @@ -40,19 +41,28 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined { const channel = resolveAcpCommandChannel(params); if (channel === "telegram") { + const telegramConversationId = resolveTelegramConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + if (telegramConversationId) { + return telegramConversationId; + } const threadId = resolveAcpCommandThreadId(params); const parentConversationId = resolveAcpCommandParentConversationId(params); if (threadId && parentConversationId) { - const canonical = buildTelegramTopicConversationId({ - chatId: parentConversationId, - topicId: threadId, - }); - if (canonical) { - return canonical; - } - } - if (threadId) { - return threadId; + return ( + buildTelegramTopicConversationId({ + chatId: parentConversationId, + topicId: threadId, + }) ?? threadId + ); } } return resolveConversationIdFromTargets({ diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 3362cd237b0..feab0b60e24 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -37,7 +37,7 @@ import type { CommandHandlerResult, HandleCommandsParams } from "../commands-typ import { resolveAcpCommandAccountId, resolveAcpCommandBindingContext, - resolveAcpCommandThreadId, + resolveAcpCommandConversationId, } from "./context.js"; import { ACP_STEER_OUTPUT_LIMIT, @@ -123,25 +123,27 @@ async function bindSpawnedAcpSessionToThread(params: { } const currentThreadId = bindingContext.threadId ?? ""; - - if (threadMode === "here" && !currentThreadId) { + const currentConversationId = bindingContext.conversationId?.trim() || ""; + const requiresThreadIdForHere = channel !== "telegram"; + if ( + threadMode === "here" && + ((requiresThreadIdForHere && !currentThreadId) || + (!requiresThreadIdForHere && !currentConversationId)) + ) { return { ok: false, error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`, }; } - const threadId = currentThreadId || undefined; - const placement = threadId ? "current" : "child"; + const placement = channel === "telegram" ? "current" : currentThreadId ? "current" : "child"; if (!capabilities.placements.includes(placement)) { return { ok: false, error: `Thread bindings do not support ${placement} placement for ${channel}.`, }; } - const channelId = placement === "child" ? bindingContext.conversationId : undefined; - - if (placement === "child" && !channelId) { + if (!currentConversationId) { return { ok: false, error: `Could not resolve a ${channel} conversation for ACP thread spawn.`, @@ -149,11 +151,11 @@ async function bindSpawnedAcpSessionToThread(params: { } const senderId = commandParams.command.senderId?.trim() || ""; - if (threadId) { + if (placement === "current") { const existingBinding = bindingService.resolveByConversation({ channel: spawnPolicy.channel, accountId: spawnPolicy.accountId, - conversationId: threadId, + conversationId: currentConversationId, }); const boundBy = typeof existingBinding?.metadata?.boundBy === "string" @@ -162,19 +164,13 @@ async function bindSpawnedAcpSessionToThread(params: { if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) { return { ok: false, - error: `Only ${boundBy} can rebind this thread.`, + error: `Only ${boundBy} can rebind this ${channel === "telegram" ? "conversation" : "thread"}.`, }; } } const label = params.label || params.agentId; - const conversationId = threadId || channelId; - if (!conversationId) { - return { - ok: false, - error: `Could not resolve a ${channel} conversation for ACP thread spawn.`, - }; - } + const conversationId = currentConversationId; try { const binding = await bindingService.bind({ @@ -344,12 +340,13 @@ export async function handleAcpSpawnAction( `✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`, ]; if (binding) { - const currentThreadId = resolveAcpCommandThreadId(params) ?? ""; + const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || ""; const boundConversationId = binding.conversation.conversationId.trim(); - if (currentThreadId && boundConversationId === currentThreadId) { - parts.push(`Bound this thread to ${sessionKey}.`); + const placementLabel = binding.conversation.channel === "telegram" ? "conversation" : "thread"; + if (currentConversationId && boundConversationId === currentConversationId) { + parts.push(`Bound this ${placementLabel} to ${sessionKey}.`); } else { - parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`); + parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`); } } else { parts.push("Session is unbound (use /focus to bind this thread/conversation)."); @@ -360,6 +357,19 @@ export async function handleAcpSpawnAction( parts.push(`ℹ️ ${dispatchNote}`); } + const shouldPinBindingNotice = + binding?.conversation.channel === "telegram" && + binding.conversation.conversationId.includes(":topic:"); + if (shouldPinBindingNotice) { + return { + shouldContinue: false, + reply: { + text: parts.join(" "), + channelData: { telegram: { pin: true } }, + }, + }; + } + return stopWithText(parts.join(" ")); } diff --git a/src/auto-reply/reply/commands-acp/shared.test.ts b/src/auto-reply/reply/commands-acp/shared.test.ts new file mode 100644 index 00000000000..39d55744092 --- /dev/null +++ b/src/auto-reply/reply/commands-acp/shared.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { parseSteerInput } from "./shared.js"; + +describe("parseSteerInput", () => { + it("preserves non-option instruction tokens while normalizing unicode-dash flags", () => { + const parsed = parseSteerInput([ + "\u2014session", + "agent:codex:acp:s1", + "\u2014briefly", + "summarize", + "this", + ]); + + expect(parsed).toEqual({ + ok: true, + value: { + sessionToken: "agent:codex:acp:s1", + instruction: "\u2014briefly summarize this", + }, + }); + }); +}); diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index dfc88c4b9ec..2fe4710ce76 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -11,7 +11,7 @@ export { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./i export const COMMAND = "/acp"; export const ACP_SPAWN_USAGE = - "Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd ] [--label