From 8ba2dfa76afbc364653caad14d48774c6f234b3f Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sun, 17 May 2026 03:36:14 -0500 Subject: [PATCH] Fix message tool session-key route drift (#83004) * fix message tool session-key route drift * docs changelog for message tool session-key route --- CHANGELOG.md | 1 + src/agents/tools/message-tool.test.ts | 237 ++++++++++++++++++++++++++ src/agents/tools/message-tool.ts | 119 +++++++++++-- 3 files changed, 345 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4859f2789..218458a9f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema. - Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting. - Gateway/protocol: restore Gateway WS protocol v4 and keep `message.action` room-event metadata on the existing `inboundTurnKind` wire field while preserving internal inbound-event classification. +- Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant. - Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width. - Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content. - Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front. diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 64e5a8eb8d0..ec4e421a3bc 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -72,6 +72,7 @@ const mocks = vi.hoisted(() => ({ maybeCollectSecretPath(`channels.${scopedChannel}.token`, scopedConfig.token); maybeCollectSecretPath(`channels.${scopedChannel}.botToken`, scopedConfig.botToken); + maybeCollectSecretPath(`channels.${scopedChannel}.appPassword`, scopedConfig.appPassword); if (scopedAccountId) { const accountRecord = scopedConfig.accounts && @@ -106,6 +107,7 @@ const mocks = vi.hoisted(() => ({ type RunMessageActionInput = { agentId?: string; cfg?: unknown; + defaultAccountId?: string; params?: Record; requesterSenderId?: string; sandboxRoot?: string; @@ -423,6 +425,241 @@ describe("message tool secret scoping", () => { expect(input?.toolContext?.currentChannelProvider).toBe("webchat"); }); + it("uses a non-webchat session key when ambient current channel drifted to webchat", async () => { + mockSendResult(); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + telegram: { + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:telegram:group:-5150615830", + }, + }); + + expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(input?.toolContext?.currentChannelProvider).toBe("telegram"); + expect(input?.toolContext?.currentChannelId).toBe("-5150615830"); + expect(input?.params).toEqual({ action: "send", message: "hi" }); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.telegram.botToken"]); + }); + + it("preserves direct session keys as explicit user targets when ambient channel drifted to webchat", async () => { + mockSendResult({ channel: "discord", to: "user:123456789" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_TOKEN" }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:discord:direct:123456789", + }, + }); + + expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(input?.toolContext?.currentChannelProvider).toBe("discord"); + expect(input?.toolContext?.currentChannelId).toBe("user:123456789"); + expect(input?.params).toEqual({ action: "send", message: "hi" }); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.discord.token"]); + }); + + it("preserves MS Teams DM session keys as explicit user targets when ambient channel drifted to webchat", async () => { + mockSendResult({ channel: "msteams", to: "user:user-1" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + msteams: { + appPassword: { source: "env", provider: "default", id: "MSTEAMS_APP_PASSWORD" }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:msteams:dm:user-1", + }, + }); + + expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(input?.toolContext?.currentChannelProvider).toBe("msteams"); + expect(input?.toolContext?.currentChannelId).toBe("user:user-1"); + expect(input?.params).toEqual({ action: "send", message: "hi" }); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.msteams.appPassword"]); + }); + + it("keeps provider-native direct session targets when ambient channel drifted to webchat", async () => { + mockSendResult({ channel: "telegram", to: "123456789" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + telegram: { + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:telegram:direct:123456789", + }, + }); + + expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(input?.toolContext?.currentChannelProvider).toBe("telegram"); + expect(input?.toolContext?.currentChannelId).toBe("123456789"); + expect(input?.params).toEqual({ action: "send", message: "hi" }); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.telegram.botToken"]); + }); + + it("uses account-scoped session keys for secret and account fallback when ambient channel drifted to webchat", async () => { + mockSendResult({ channel: "discord", to: "user:123456789" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_TOKEN" }, + accounts: { + ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } }, + }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:discord:ops:direct:123456789", + }, + }); + + expect(input?.defaultAccountId).toBe("ops"); + expect(input?.params?.accountId).toBe("ops"); + expect(input?.toolContext?.currentChannelProvider).toBe("discord"); + expect(input?.toolContext?.currentChannelId).toBe("user:123456789"); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual([ + "channels.discord.token", + "channels.discord.accounts.ops.token", + ]); + }); + + it("keeps account-scoped direct keys when account id matches a peer marker", async () => { + mockSendResult({ channel: "discord", to: "user:123456789" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_TOKEN" }, + accounts: { + direct: { + token: { source: "env", provider: "default", id: "DISCORD_DIRECT_TOKEN" }, + }, + }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:discord:direct:direct:123456789", + }, + }); + + expect(input?.defaultAccountId).toBe("direct"); + expect(input?.params?.accountId).toBe("direct"); + expect(input?.toolContext?.currentChannelProvider).toBe("discord"); + expect(input?.toolContext?.currentChannelId).toBe("user:123456789"); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual([ + "channels.discord.token", + "channels.discord.accounts.direct.token", + ]); + }); + + it("handles legacy dm markers when ambient channel drifted to webchat", async () => { + mockSendResult({ channel: "slack", to: "user:u123" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + slack: { + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:slack:dm:u123:thread:171.222", + }, + }); + + expect(input?.toolContext?.currentChannelProvider).toBe("slack"); + expect(input?.toolContext?.currentChannelId).toBe("user:u123"); + expect(input?.toolContext?.currentThreadTs).toBe("171.222"); + expect(input?.toolContext?.replyToMode).toBe("all"); + + const secretResolveCall = latestSecretResolveCall(); + expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.slack.botToken"]); + }); + + it("carries session-key thread suffixes into inferred channel context", async () => { + mockSendResult({ channel: "slack", to: "channel:c1" }); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + config: { + channels: { + slack: { + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + }, + }, + } as never, + sourceReplyDeliveryMode: "message_tool_only", + currentChannelProvider: "webchat", + agentSessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999", + }, + }); + + expect(input?.toolContext?.currentChannelProvider).toBe("slack"); + expect(input?.toolContext?.currentChannelId).toBe("c1"); + expect(input?.toolContext?.currentThreadTs).toBe("1710000000.9999"); + expect(input?.toolContext?.replyToMode).toBe("all"); + }); + it("scopes command-time secret resolution to the selected channel/account", async () => { mockSendResult({ channel: "discord", to: "discord:123" }); mocks.getRuntimeConfig.mockReturnValue({ diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index e1627aba6e5..6daf1328870 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -22,7 +22,11 @@ import { getToolResult, runMessageAction } from "../../infra/outbound/message-ac import { resolveAllowedMessageActions } from "../../infra/outbound/outbound-policy.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; +import { + normalizeAccountId, + parseAgentSessionKey, + parseThreadSessionSuffix, +} from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -595,6 +599,93 @@ type MessageActionDiscoveryInput = Omit= 4 && (parts[2] === "direct" || parts[2] === "dm")) { + const accountId = resolveAgentAccountId(parts[1]); + const to = parts.slice(3).join(":").trim(); + return to + ? { + accountId, + channel, + threadId: parsedThread.threadId, + to: formatSessionDeliveryTarget(channel, parts[2], to), + } + : null; + } + const peerKind = parts[1] ?? ""; + if (SESSION_DELIVERY_PEER_KINDS.has(peerKind)) { + const to = parts.slice(2).join(":").trim(); + return to + ? { + channel, + threadId: parsedThread.threadId, + to: formatSessionDeliveryTarget(channel, peerKind, to), + } + : null; + } + return null; +} + +function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): { + accountId?: string; + currentChannelId?: string; + currentChannelProvider?: string; + currentThreadTs?: string; +} { + const currentChannelProvider = options?.currentChannelProvider; + const currentChannelId = options?.currentChannelId; + const sessionDelivery = inferDeliveryFromSessionKey(options?.agentSessionKey); + const sessionDeliveryChannel = normalizeMessageChannel(sessionDelivery?.channel); + const preferSessionDeliveryContext = + normalizeMessageChannel(currentChannelProvider) === "webchat" && + sessionDeliveryChannel !== undefined && + sessionDeliveryChannel !== "webchat" && + Boolean(sessionDelivery?.to); + + if (!preferSessionDeliveryContext) { + return { currentChannelProvider, currentChannelId }; + } + return { + accountId: sessionDelivery?.accountId, + currentChannelProvider: sessionDeliveryChannel, + currentChannelId: sessionDelivery?.to, + currentThreadTs: sessionDelivery?.threadId, + }; +} + function buildMessageActionDiscoveryInput( params: MessageToolDiscoveryParams, channel?: string, @@ -794,11 +885,15 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const resolveSecretRefsForTool = options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway; const runMessageActionForTool = options?.runMessageAction ?? runMessageAction; - const agentAccountId = resolveAgentAccountId(options?.agentAccountId); + const effectiveCurrentChannel = resolveEffectiveCurrentChannelContext(options); const currentThreadTs = options?.currentThreadTs ?? - (options?.agentThreadId != null ? stringifyRouteThreadId(options.agentThreadId) : undefined); + (options?.agentThreadId != null + ? stringifyRouteThreadId(options.agentThreadId) + : effectiveCurrentChannel.currentThreadTs); const replyToMode = options?.replyToMode ?? (currentThreadTs ? "all" : undefined); + const agentAccountId = + resolveAgentAccountId(options?.agentAccountId) ?? effectiveCurrentChannel.accountId; const resolvedAgentId = options?.agentId ?? (options?.agentSessionKey @@ -810,8 +905,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const schema = options?.config ? buildMessageToolSchema({ cfg: options.config, - currentChannelProvider: options.currentChannelProvider, - currentChannelId: options.currentChannelId, + currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, + currentChannelId: effectiveCurrentChannel.currentChannelId, currentThreadTs, currentMessageId: options.currentMessageId, currentAccountId: agentAccountId, @@ -824,8 +919,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { : MessageToolSchema; const description = buildMessageToolDescription({ config: options?.config, - currentChannel: options?.currentChannelProvider, - currentChannelId: options?.currentChannelId, + currentChannel: effectiveCurrentChannel.currentChannelProvider, + currentChannelId: effectiveCurrentChannel.currentChannelId, currentThreadTs, currentMessageId: options?.currentMessageId, currentAccountId: agentAccountId, @@ -886,7 +981,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { channel: params.channel, target: params.target, targets: params.targets, - fallbackChannel: options?.currentChannelProvider, + fallbackChannel: effectiveCurrentChannel.currentChannelProvider, accountId: params.accountId, fallbackAccountId: agentAccountId, }); @@ -929,15 +1024,15 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { options.currentMessageId.trim().length > 0); const toolContext = - options?.currentChannelId || - options?.currentChannelProvider || + effectiveCurrentChannel.currentChannelId || + effectiveCurrentChannel.currentChannelProvider || currentThreadTs || hasCurrentMessageId || replyToMode || options?.hasRepliedRef ? { - currentChannelId: options?.currentChannelId, - currentChannelProvider: options?.currentChannelProvider, + currentChannelId: effectiveCurrentChannel.currentChannelId, + currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, currentThreadTs, currentMessageId: options?.currentMessageId, replyToMode,