mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 15:44:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user