diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ee58ba4a0..83736de43a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,7 @@ Docs: https://docs.openclaw.ai - Exec approvals: unwrap `caffeinate` and `sandbox-exec` before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc. - Matrix/DM threads: keep strict unnamed fresh-invite rooms promotable even when Matrix omits the optional direct hint, preserve repair-failed local DM promotions while still revalidating later room metadata, and keep both bound and thread-isolated Matrix sessions reporting the correct route policy. (#58099) Thanks @gumadeiras. - ClawFlow: add a small flow runtime substrate for authoring layers with persisted wait targets and output bags, plus bundled skills/Lobster examples and richer `flows show` / `doctor` recovery hints for multi-task flow state. (#58336) Thanks @mbelinky. +- Sessions/Feishu: preserve conversation ids that legitimately embed `:topic:` in shared session helper parsing, while keeping Telegram topic session parsing intact. (#58100) Thanks @gumadeiras. ## 2026.3.28 diff --git a/src/agents/tools/sessions-send-helpers.test.ts b/src/agents/tools/sessions-send-helpers.test.ts index b66ce99b42d..3dfa3f9ac2d 100644 --- a/src/agents/tools/sessions-send-helpers.test.ts +++ b/src/agents/tools/sessions-send-helpers.test.ts @@ -95,6 +95,28 @@ describe("resolveAnnounceTargetFromKey", () => { }, }, }, + { + pluginId: "feishu", + source: "test", + plugin: { + id: "feishu", + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + blurb: "Feishu test stub.", + }, + capabilities: { chatTypes: ["direct", "group", "thread"] }, + messaging: { + normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, ]), ); }); @@ -141,4 +163,16 @@ describe("resolveAnnounceTargetFromKey", () => { threadId: "$AbC123:example.org", }); }); + + it("preserves feishu conversation ids that embed :topic: in the base id", () => { + expect( + resolveAnnounceTargetFromKey( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toEqual({ + channel: "feishu", + to: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + threadId: undefined, + }); + }); }); diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index db2d24293aa..c9d4e8fd6eb 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -4,7 +4,7 @@ import { } from "../../channels/plugins/index.js"; import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { parseSessionThreadInfo } from "../../config/sessions/delivery-info.js"; +import { parseSessionConversationRef } from "../../sessions/session-key-utils.js"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; const REPLY_SKIP_TOKEN = "REPLY_SKIP"; @@ -19,40 +19,25 @@ export type AnnounceTarget = { }; export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null { - const rawParts = sessionKey.split(":").filter(Boolean); - const parts = rawParts.length >= 3 && rawParts[0] === "agent" ? rawParts.slice(2) : rawParts; - if (parts.length < 3) { + const parsed = parseSessionConversationRef(sessionKey); + if (!parsed) { return null; } - const [channelRaw, kind, ...rest] = parts; - if (kind !== "group" && kind !== "channel") { - return null; - } - - const restJoined = rest.join(":"); - const { baseSessionKey, threadId } = parseSessionThreadInfo(restJoined); - const id = (baseSessionKey ?? restJoined).trim(); - - if (!id) { - return null; - } - if (!channelRaw) { - return null; - } - const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw); - const channel = normalizedChannel ?? channelRaw.toLowerCase(); + const normalizedChannel = + normalizeAnyChannelId(parsed.channel) ?? normalizeChatChannelId(parsed.channel); + const channel = normalizedChannel ?? parsed.channel; const plugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : null; - const genericTarget = kind === "channel" ? `channel:${id}` : `group:${id}`; + const genericTarget = parsed.kind === "channel" ? `channel:${parsed.id}` : `group:${parsed.id}`; const normalized = plugin?.messaging?.resolveSessionTarget?.({ - kind, - id, - threadId, + kind: parsed.kind, + id: parsed.id, + threadId: parsed.threadId, }) ?? plugin?.messaging?.normalizeTarget?.(genericTarget); return { channel, - to: normalized ?? (normalizedChannel ? genericTarget : id), - threadId, + to: normalized ?? (normalizedChannel ? genericTarget : parsed.id), + threadId: parsed.threadId, }; } diff --git a/src/channels/model-overrides.test.ts b/src/channels/model-overrides.test.ts index ea28fc464f9..6e4d64d0dd4 100644 --- a/src/channels/model-overrides.test.ts +++ b/src/channels/model-overrides.test.ts @@ -10,13 +10,13 @@ describe("resolveChannelModelOverride", () => { cfg: { channels: { modelByChannel: { - "demo-group": { + telegram: { "-100123": "demo-provider/demo-parent-model", }, }, }, } as unknown as OpenClawConfig, - channel: "demo-group", + channel: "telegram", groupId: "-100123:topic:99", }, expected: { model: "demo-provider/demo-parent-model", matchKey: "-100123" }, @@ -27,14 +27,14 @@ describe("resolveChannelModelOverride", () => { cfg: { channels: { modelByChannel: { - "demo-group": { + telegram: { "-100123": "demo-provider/demo-parent-model", "-100123:topic:99": "demo-provider/demo-topic-model", }, }, }, } as unknown as OpenClawConfig, - channel: "demo-group", + channel: "telegram", groupId: "-100123:topic:99", }, expected: { model: "demo-provider/demo-topic-model", matchKey: "-100123:topic:99" }, @@ -57,6 +57,48 @@ describe("resolveChannelModelOverride", () => { }, expected: { model: "demo-provider/demo-parent-model", matchKey: "123" }, }, + { + name: "preserves feishu topic ids for direct matches", + input: { + cfg: { + channels: { + modelByChannel: { + feishu: { + "oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "feishu", + groupId: "oc_group_chat:topic:om_topic_root", + }, + expected: { + model: "demo-provider/demo-feishu-topic-model", + matchKey: "oc_group_chat:topic:om_topic_root", + }, + }, + { + name: "preserves feishu topic ids when falling back from parent session key", + input: { + cfg: { + channels: { + modelByChannel: { + feishu: { + "oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "feishu", + groupId: "unrelated", + parentSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }, + expected: { + model: "demo-provider/demo-feishu-topic-model", + matchKey: "oc_group_chat:topic:om_topic_root", + }, + }, ] as const)("$name", ({ input, expected }) => { const resolved = resolveChannelModelOverride(input); expect(resolved?.model).toBe(expected.model); diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts index be57935f992..74093073253 100644 --- a/src/channels/model-overrides.ts +++ b/src/channels/model-overrides.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "../config/config.js"; -import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { + parseSessionConversationRef, + parseThreadSessionSuffix, +} from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { buildChannelKeyCandidates, @@ -8,8 +11,6 @@ import { type ChannelMatchSource, } from "./channel-config.js"; -const THREAD_SUFFIX_REGEX = /:(?:thread|topic):[^:]+$/i; - export type ChannelModelOverride = { channel: string; model: string; @@ -44,38 +45,51 @@ function resolveProviderEntry( ); } -function resolveParentGroupId(groupId: string | undefined): string | undefined { +function resolveParentGroupId( + groupId: string | undefined, + channelHint?: string | null, +): string | undefined { const raw = groupId?.trim(); - if (!raw || !THREAD_SUFFIX_REGEX.test(raw)) { + if (!raw) { return undefined; } - const parent = raw.replace(THREAD_SUFFIX_REGEX, "").trim(); + const parent = parseThreadSessionSuffix(raw, { channelHint }).baseSessionKey?.trim(); + return parent && parent !== raw ? parent : undefined; +} + +function resolveSenderScopedParentGroupId(groupId: string | undefined): string | undefined { + const raw = groupId?.trim(); + if (!raw) { + return undefined; + } + const parent = raw.replace(/:sender:[^:]+$/i, "").trim(); return parent && parent !== raw ? parent : undefined; } function resolveGroupIdFromSessionKey(sessionKey?: string | null): string | undefined { - const raw = sessionKey?.trim(); - if (!raw) { - return undefined; - } - const parsed = parseAgentSessionKey(raw); - const candidate = parsed?.rest ?? raw; - const match = candidate.match(/(?:^|:)(?:group|channel):([^:]+)(?::|$)/i); - const id = match?.[1]?.trim(); - return id || undefined; + return parseSessionConversationRef(sessionKey)?.id; } function buildChannelCandidates( params: Pick< ChannelModelOverrideParams, - "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey" + "channel" | "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey" >, ) { + const normalizedChannel = + normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase(); const groupId = params.groupId?.trim(); - const parentGroupId = resolveParentGroupId(groupId); + const senderParentGroupId = resolveSenderScopedParentGroupId(groupId); + const parentGroupId = resolveParentGroupId(groupId, normalizedChannel); const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey); + const senderParentGroupIdFromSession = resolveSenderScopedParentGroupId(parentGroupIdFromSession); const parentGroupIdResolved = - resolveParentGroupId(parentGroupIdFromSession) ?? parentGroupIdFromSession; + resolveParentGroupId(parentGroupIdFromSession, normalizedChannel) ?? parentGroupIdFromSession; + const senderParentResolved = + resolveParentGroupId(senderParentGroupId, normalizedChannel) ?? senderParentGroupId; + const senderParentFromSessionResolved = + resolveParentGroupId(senderParentGroupIdFromSession, normalizedChannel) ?? + senderParentGroupIdFromSession; const groupChannel = params.groupChannel?.trim(); const groupSubject = params.groupSubject?.trim(); const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined; @@ -85,7 +99,12 @@ function buildChannelCandidates( return buildChannelKeyCandidates( groupId, + senderParentGroupId, + senderParentResolved, parentGroupId, + parentGroupIdFromSession, + senderParentGroupIdFromSession, + senderParentFromSessionResolved, parentGroupIdResolved, groupChannel, channelBare, diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index 88c01c5a0d1..9f7c8437818 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -52,6 +52,15 @@ describe("extractDeliveryInfo", () => { baseSessionKey: "agent:main:matrix:channel:!room:example.org", threadId: "$AbC123:example.org", }); + expect( + parseSessionThreadInfo( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toEqual({ + baseSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + threadId: undefined, + }); expect(parseSessionThreadInfo("agent:main:telegram:dm:user-1")).toEqual({ baseSessionKey: "agent:main:telegram:dm:user-1", threadId: undefined, diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index b8c5a637c1d..e380c969e4e 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -1,3 +1,4 @@ +import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; import { loadConfig } from "../io.js"; import { resolveStorePath } from "./paths.js"; import { loadSessionStore } from "./store.js"; @@ -10,19 +11,7 @@ export function parseSessionThreadInfo(sessionKey: string | undefined): { baseSessionKey: string | undefined; threadId: string | undefined; } { - if (!sessionKey) { - return { baseSessionKey: undefined, threadId: undefined }; - } - const topicIndex = sessionKey.lastIndexOf(":topic:"); - const threadIndex = sessionKey.lastIndexOf(":thread:"); - const markerIndex = Math.max(topicIndex, threadIndex); - const marker = topicIndex > threadIndex ? ":topic:" : ":thread:"; - - const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex); - const threadIdRaw = - markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length); - const threadId = threadIdRaw?.trim() || undefined; - return { baseSessionKey, threadId }; + return parseThreadSessionSuffix(sessionKey); } export function extractDeliveryInfo(sessionKey: string | undefined): { diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts new file mode 100644 index 00000000000..91bdb3ebcb6 --- /dev/null +++ b/src/config/sessions/reset.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { isThreadSessionKey, resolveSessionResetType } from "./reset.js"; + +describe("session reset thread detection", () => { + it("does not treat feishu conversation ids with embedded :topic: as thread suffixes", () => { + const sessionKey = + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user"; + expect(isThreadSessionKey(sessionKey)).toBe(false); + expect(resolveSessionResetType({ sessionKey })).toBe("group"); + }); + + it("still treats telegram :topic: suffixes as thread sessions", () => { + const sessionKey = "agent:main:telegram:group:-100123:topic:77"; + expect(isThreadSessionKey(sessionKey)).toBe(true); + expect(resolveSessionResetType({ sessionKey })).toBe("thread"); + }); +}); diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 2390f2e1dd3..00681ffcd79 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -1,3 +1,4 @@ +import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { SessionConfig, SessionResetConfig } from "../types.base.js"; import { DEFAULT_IDLE_MINUTES } from "./types.js"; @@ -20,15 +21,10 @@ export type SessionFreshness = { export const DEFAULT_RESET_MODE: SessionResetMode = "daily"; export const DEFAULT_RESET_AT_HOUR = 4; -const THREAD_SESSION_MARKERS = [":thread:", ":topic:"]; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; export function isThreadSessionKey(sessionKey?: string | null): boolean { - const normalized = (sessionKey ?? "").toLowerCase(); - if (!normalized) { - return false; - } - return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker)); + return Boolean(parseThreadSessionSuffix(sessionKey).threadId); } export function resolveSessionResetType(params: { diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index 53990a271d6..fb58dc3bd22 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -3,6 +3,8 @@ import { deriveSessionChatType, getSubagentDepth, isCronSessionKey, + parseThreadSessionSuffix, + resolveThreadParentSessionKey, } from "../sessions/session-key-utils.js"; import { classifySessionKeyShape, @@ -84,6 +86,48 @@ describe("deriveSessionChatType", () => { }); }); +describe("thread session suffix parsing", () => { + it("preserves feishu conversation ids that embed :topic: in the base id", () => { + expect( + parseThreadSessionSuffix( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toEqual({ + baseSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + threadId: undefined, + }); + expect( + resolveThreadParentSessionKey( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toBeNull(); + }); + + it("still parses telegram topic session suffixes", () => { + expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:topic:77")).toEqual({ + baseSessionKey: "agent:main:telegram:group:-100123", + threadId: "77", + }); + expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe( + "agent:main:telegram:group:-100123", + ); + }); + + it("parses mixed-case suffix markers without lowercasing the stored key", () => { + expect( + parseThreadSessionSuffix("agent:main:slack:channel:General:Thread:1699999999.0001"), + ).toEqual({ + baseSessionKey: "agent:main:slack:channel:General", + threadId: "1699999999.0001", + }); + expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:Topic:77")).toEqual({ + baseSessionKey: "agent:main:telegram:group:-100123", + threadId: "77", + }); + }); +}); + describe("session key canonicalization", () => { function expectSessionKeyCanonicalizationCase(params: { run: () => void }) { params.run(); diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index c405df3a5ff..30e1607eb7a 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -4,6 +4,17 @@ export type ParsedAgentSessionKey = { }; export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown"; +export type ParsedThreadSessionSuffix = { + baseSessionKey: string | undefined; + threadId: string | undefined; +}; + +export type ParsedSessionConversationRef = { + channel: string; + kind: "group" | "channel"; + id: string; + threadId: string | undefined; +}; /** * Parse agent-scoped session keys in a canonical, case-insensitive way. @@ -107,26 +118,93 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:")); } -const THREAD_SESSION_MARKERS = [":thread:", ":topic:"]; +function normalizeThreadSuffixChannelHint(value: string | undefined | null): string | undefined { + const trimmed = (value ?? "").trim().toLowerCase(); + return trimmed || undefined; +} -export function resolveThreadParentSessionKey( +function inferThreadSuffixChannelHint(sessionKey: string): string | undefined { + const parts = sessionKey.split(":").filter(Boolean); + if (parts.length === 0) { + return undefined; + } + if ((parts[0] ?? "").trim().toLowerCase() === "agent") { + return normalizeThreadSuffixChannelHint(parts[2]); + } + return normalizeThreadSuffixChannelHint(parts[0]); +} + +export function parseThreadSessionSuffix( sessionKey: string | undefined | null, -): string | null { + options?: { channelHint?: string | null }, +): ParsedThreadSessionSuffix { + const raw = (sessionKey ?? "").trim(); + if (!raw) { + return { baseSessionKey: undefined, threadId: undefined }; + } + + const channelHint = + normalizeThreadSuffixChannelHint(options?.channelHint) ?? inferThreadSuffixChannelHint(raw); + const lowerRaw = raw.toLowerCase(); + const topicMarker = ":topic:"; + const threadMarker = ":thread:"; + const topicIndex = channelHint === "telegram" ? lowerRaw.lastIndexOf(topicMarker) : -1; + const threadIndex = lowerRaw.lastIndexOf(threadMarker); + const markerIndex = Math.max(topicIndex, threadIndex); + const marker = topicIndex > threadIndex ? topicMarker : threadMarker; + + const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex); + const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length); + const threadId = threadIdRaw?.trim() || undefined; + + return { baseSessionKey, threadId }; +} + +export function parseSessionConversationRef( + sessionKey: string | undefined | null, +): ParsedSessionConversationRef | null { const raw = (sessionKey ?? "").trim(); if (!raw) { return null; } - const normalized = raw.toLowerCase(); - let idx = -1; - for (const marker of THREAD_SESSION_MARKERS) { - const candidate = normalized.lastIndexOf(marker); - if (candidate > idx) { - idx = candidate; - } - } - if (idx <= 0) { + + const rawParts = raw.split(":").filter(Boolean); + const parts = + rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent" + ? rawParts.slice(2) + : rawParts; + if (parts.length < 3) { return null; } - const parent = raw.slice(0, idx).trim(); - return parent ? parent : null; + + const channel = normalizeThreadSuffixChannelHint(parts[0]); + const kind = parts[1]?.trim().toLowerCase(); + if (!channel || (kind !== "group" && kind !== "channel")) { + return null; + } + + const joined = parts.slice(2).join(":"); + const { baseSessionKey, threadId } = parseThreadSessionSuffix(joined, { + channelHint: channel, + }); + const id = (baseSessionKey ?? joined).trim(); + if (!id) { + return null; + } + + return { channel, kind, id, threadId }; +} + +export function resolveThreadParentSessionKey( + sessionKey: string | undefined | null, +): string | null { + const { baseSessionKey, threadId } = parseThreadSessionSuffix(sessionKey); + if (!threadId) { + return null; + } + const parent = baseSessionKey?.trim(); + if (!parent) { + return null; + } + return parent; }