Fix message tool session-key route drift (#83004)

* fix message tool session-key route drift

* docs changelog for message tool session-key route
This commit is contained in:
Josh Avant
2026-05-17 03:36:14 -05:00
committed by GitHub
parent 69d588cf2a
commit 8ba2dfa76a
3 changed files with 345 additions and 12 deletions

View File

@@ -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.

View File

@@ -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<string, unknown>;
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({

View File

@@ -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<ChannelMessageActionDiscoveryInput, "cfg
channel?: string;
};
type InferredSessionDelivery = {
accountId?: string;
channel: string;
threadId?: string;
to: string;
};
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
const USER_PREFIXED_DIRECT_TARGET_CHANNELS = new Set(["discord", "mattermost", "msteams", "slack"]);
function formatSessionDeliveryTarget(channel: string, peerKind: string, to: string): string {
return (peerKind === "direct" || peerKind === "dm") &&
USER_PREFIXED_DIRECT_TARGET_CHANNELS.has(channel)
? `user:${to}`
: to;
}
function inferDeliveryFromSessionKey(
sessionKey: string | undefined,
): InferredSessionDelivery | null {
const parsedThread = parseThreadSessionSuffix(sessionKey);
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
const parsed = parseAgentSessionKey(baseSessionKey);
if (!parsed) {
return null;
}
const parts = parsed.rest.split(":").filter(Boolean);
if (parts.length < 3) {
return null;
}
const channel = normalizeMessageChannel(parts[0]);
if (!channel) {
return null;
}
if (parts.length >= 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,