From 42a74bb635e0ca74d7fc2d139137728615f7bbf3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 31 Mar 2026 10:01:19 -0400 Subject: [PATCH] Refactor: move session conversation grammar into channel plugins --- docs/plugins/architecture.md | 2 + docs/plugins/sdk-channel-plugins.md | 11 +- extensions/feishu/src/channel.test.ts | 31 +++ extensions/feishu/src/channel.ts | 40 ++++ extensions/telegram/src/channel.test.ts | 27 +++ extensions/telegram/src/channel.ts | 15 ++ src/agents/pi-tools-agent-config.test.ts | 8 +- src/agents/pi-tools.policy.ts | 4 +- src/agents/tools/gateway-tool.ts | 4 +- .../tools/sessions-send-helpers.test.ts | 117 +---------- src/agents/tools/sessions-send-helpers.ts | 4 +- src/auto-reply/reply/model-selection.test.ts | 8 +- src/auto-reply/reply/model-selection.ts | 4 +- src/channels/model-overrides.test.ts | 8 +- src/channels/model-overrides.ts | 90 +++------ .../plugins/session-conversation.test.ts | 77 +++++++ src/channels/plugins/session-conversation.ts | 191 ++++++++++++++++++ src/channels/plugins/types.core.ts | 18 ++ src/config/sessions/delivery-info.test.ts | 3 + src/config/sessions/delivery-info.ts | 6 +- src/config/sessions/reset.test.ts | 8 +- src/config/sessions/reset.ts | 4 +- src/gateway/server-methods/config.ts | 4 +- src/routing/session-key.test.ts | 16 +- src/sessions/session-key-utils.ts | 55 ++--- .../session-conversation-registry.ts | 171 ++++++++++++++++ 26 files changed, 683 insertions(+), 243 deletions(-) create mode 100644 src/channels/plugins/session-conversation.test.ts create mode 100644 src/channels/plugins/session-conversation.ts create mode 100644 src/test-utils/session-conversation-registry.ts diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index ba47a00d4df..ac574fb9b5d 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -159,6 +159,8 @@ The current boundary is: bookkeeping, and execution dispatch - channel plugins own scoped action discovery, capability discovery, and any channel-specific schema fragments +- channel plugins own provider-specific session conversation grammar, such as + how conversation ids encode thread ids or inherit from parent conversations - channel plugins execute the final action through their action adapter For channel plugins, the SDK surface is diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 27cbc6009ad..bd169c8fd87 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -28,11 +28,18 @@ shared `message` tool in core. Your plugin owns: - **Config** — account resolution and setup wizard - **Security** — DM policy and allowlists - **Pairing** — DM approval flow +- **Session grammar** — how provider-specific conversation ids map to base chats, thread ids, and parent fallbacks - **Outbound** — sending text, media, and polls to the platform - **Threading** — how replies are threaded -Core owns the shared message tool, prompt wiring, session bookkeeping, and -dispatch. +Core owns the shared message tool, prompt wiring, the outer session-key shape, +generic `:thread:` bookkeeping, and dispatch. + +If your platform stores extra scope inside conversation ids, keep that parsing +in the plugin by implementing `messaging.resolveSessionConversation(...)` and +`messaging.resolveParentConversationCandidates(...)`. Use those hooks for +platform-specific suffixes or inheritance rules instead of adding provider +checks to core. ## Approvals and channel capabilities diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 852d42e3f29..7b50e1704d6 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -183,6 +183,37 @@ describe("feishuPlugin.pairing.notifyApproval", () => { }); }); +describe("feishuPlugin messaging", () => { + beforeEach(async () => { + vi.resetModules(); + ({ feishuPlugin } = await import("./channel.js")); + }); + + it("owns sender/topic session inheritance candidates", () => { + expect( + feishuPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }), + ).toEqual({ + id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"], + }); + expect( + feishuPlugin.messaging?.resolveParentConversationCandidates?.({ + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }), + ).toEqual(["oc_group_chat:topic:om_topic_root", "oc_group_chat"]); + expect( + feishuPlugin.messaging?.resolveParentConversationCandidates?.({ + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root", + }), + ).toEqual(["oc_group_chat"]); + }); +}); + describe("feishuPlugin actions", () => { const cfg = { channels: { diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index d50612c15ab..01f20cb01cf 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -275,6 +275,43 @@ function normalizeFeishuAcpConversationId(conversationId: string) { }; } +function resolveFeishuParentConversationCandidates(rawId: string): string[] { + const parsed = parseFeishuConversationId({ conversationId: rawId }); + if (!parsed) { + return []; + } + switch (parsed.scope) { + case "group_topic_sender": + return [ + buildFeishuConversationId({ + chatId: parsed.chatId, + scope: "group_topic", + topicId: parsed.topicId, + }), + parsed.chatId, + ]; + case "group_topic": + case "group_sender": + return [parsed.chatId]; + case "group": + default: + return []; + } +} + +function resolveFeishuSessionConversation(rawId: string) { + const parsed = parseFeishuConversationId({ conversationId: rawId }); + if (!parsed) { + return null; + } + return { + id: parsed.canonicalConversationId, + parentConversationCandidates: resolveFeishuParentConversationCandidates( + parsed.canonicalConversationId, + ), + }; +} + function matchFeishuAcpConversation(params: { bindingConversationId: string; conversationId: string; @@ -1068,6 +1105,9 @@ export const feishuPlugin: ChannelPlugin normalizeFeishuTarget(raw) ?? undefined, + resolveSessionConversation: ({ rawId }) => resolveFeishuSessionConversation(rawId), + resolveParentConversationCandidates: ({ rawId }) => + resolveFeishuParentConversationCandidates(rawId), resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeFeishuId, diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 1fabefdeff8..fcca39f7e75 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -187,6 +187,33 @@ describe("telegramPlugin groups", () => { }); }); +describe("telegramPlugin messaging", () => { + it("owns topic session parsing and parent fallback candidates", () => { + expect( + telegramPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "-1001:topic:77", + }), + ).toEqual({ + id: "-1001", + threadId: "77", + parentConversationCandidates: ["-1001"], + }); + expect( + telegramPlugin.messaging?.resolveParentConversationCandidates?.({ + kind: "group", + rawId: "-1001:topic:77", + }), + ).toEqual(["-1001"]); + expect( + telegramPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "-1001", + }), + ).toBeNull(); + }); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a6897715255..d6cfdfd5830 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -232,6 +232,18 @@ function normalizeTelegramAcpConversationId(conversationId: string) { }; } +function resolveTelegramSessionConversation(rawId: string) { + const parsed = parseTelegramTopicConversation({ conversationId: rawId }); + if (!parsed) { + return null; + } + return { + id: parsed.chatId, + threadId: parsed.topicId, + parentConversationCandidates: [parsed.chatId], + }; +} + function matchTelegramAcpConversation(params: { bindingConversationId: string; conversationId: string; @@ -530,6 +542,9 @@ export const telegramPlugin = createChatChannelPlugin({ }, messaging: { normalizeTarget: normalizeTelegramMessagingTarget, + resolveSessionConversation: ({ rawId }) => resolveTelegramSessionConversation(rawId), + resolveParentConversationCandidates: ({ rawId }) => + resolveTelegramSessionConversation(rawId)?.parentConversationCandidates ?? null, parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw), inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, formatTargetDisplay: ({ target, display, kind }) => { diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index ad7bbaadaca..12696907d15 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -1,9 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import "./test-helpers/fast-coding-tools.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import type { SandboxDockerConfig } from "./sandbox.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -14,6 +16,10 @@ type ToolWithExecute = { }; describe("Agent-specific tool filtering", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + const sandboxFsBridgeStub: SandboxFsBridge = { resolvePath: () => ({ hostPath: "/tmp/sandbox", diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index cc5770015ee..06667a126ed 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -1,10 +1,10 @@ import { getChannelPlugin } from "../channels/plugins/index.js"; +import { resolveSessionParentSessionKey } from "../channels/plugins/session-conversation.js"; import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; @@ -136,7 +136,7 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): { if (!raw) { return {}; } - const base = resolveThreadParentSessionKey(raw) ?? raw; + const base = resolveSessionParentSessionKey(raw) ?? raw; const parts = base.split(":").filter(Boolean); let body = parts[0] === "agent" ? parts.slice(2) : parts; if (body[0] === "subagent") { diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 7166e3968d1..973b81ea7ac 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -167,8 +167,8 @@ export function createGatewayTool(opts?: { : undefined; const note = typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; - // Extract channel + threadId for routing after restart - // Supports both :thread: (most channels) and :topic: (Telegram) + // Extract channel + threadId for routing after restart. + // Uses generic :thread: parsing plus plugin-owned session grammars. const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); const payload: RestartSentinelPayload = { kind: "restart", diff --git a/src/agents/tools/sessions-send-helpers.test.ts b/src/agents/tools/sessions-send-helpers.test.ts index 3dfa3f9ac2d..7e3eedfb9a7 100644 --- a/src/agents/tools/sessions-send-helpers.test.ts +++ b/src/agents/tools/sessions-send-helpers.test.ts @@ -1,124 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; describe("resolveAnnounceTargetFromKey", () => { beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "discord", - source: "test", - plugin: { - id: "discord", - meta: { - id: "discord", - label: "Discord", - selectionLabel: "Discord", - docsPath: "/channels/discord", - blurb: "Discord test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - messaging: { - resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "slack", - source: "test", - plugin: { - id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "Slack test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - messaging: { - resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "matrix", - source: "test", - plugin: { - id: "matrix", - meta: { - id: "matrix", - label: "Matrix", - selectionLabel: "Matrix", - docsPath: "/channels/matrix", - blurb: "Matrix test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - messaging: { - resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "telegram", - source: "test", - plugin: { - id: "telegram", - meta: { - id: "telegram", - label: "Telegram", - selectionLabel: "Telegram", - docsPath: "/channels/telegram", - blurb: "Telegram test stub.", - }, - capabilities: { chatTypes: ["direct", "group", "thread"] }, - messaging: { - normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - 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: () => ({}), - }, - }, - }, - ]), - ); + setActivePluginRegistry(createSessionConversationTestRegistry()); }); it("lets plugins own session-derived target shapes", () => { diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index c9d4e8fd6eb..66d8129ea35 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -2,9 +2,9 @@ import { getChannelPlugin, normalizeChannelId as normalizeAnyChannelId, } from "../../channels/plugins/index.js"; +import { resolveSessionConversationRef } from "../../channels/plugins/session-conversation.js"; import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { parseSessionConversationRef } from "../../sessions/session-key-utils.js"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; const REPLY_SKIP_TOKEN = "REPLY_SKIP"; @@ -19,7 +19,7 @@ export type AnnounceTarget = { }; export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null { - const parsed = parseSessionConversationRef(sessionKey); + const parsed = resolveSessionConversationRef(sessionKey); if (!parsed) { return null; } diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index ea474c6d6a8..21e3e54ee63 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1,8 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MODEL_CONTEXT_TOKEN_CACHE } from "../../agents/context-cache.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { createModelSelectionState, resolveContextTokens } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ @@ -21,6 +23,10 @@ afterEach(() => { MODEL_CONTEXT_TOKEN_CACHE.clear(); }); +beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); +}); + const makeConfiguredModel = (overrides: Record = {}) => ({ id: "gpt-5.4", name: "GPT-5.4", diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index cc12cb935de..7765d925d3d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -14,10 +14,10 @@ import { resolveReasoningDefault, resolveThinkingDefault, } from "../../agents/model-selection.js"; +import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; -import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js"; import type { ThinkLevel } from "./directives.js"; export type ModelDirectiveSelection = { @@ -146,7 +146,7 @@ function resolveParentSessionKeyCandidate(params: { if (explicit && explicit !== params.sessionKey) { return explicit; } - const derived = resolveThreadParentSessionKey(params.sessionKey); + const derived = resolveSessionParentSessionKey(params.sessionKey); if (derived && derived !== params.sessionKey) { return derived; } diff --git a/src/channels/model-overrides.test.ts b/src/channels/model-overrides.test.ts index 6e4d64d0dd4..8d276f16e74 100644 --- a/src/channels/model-overrides.test.ts +++ b/src/channels/model-overrides.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; import { resolveChannelModelOverride } from "./model-overrides.js"; describe("resolveChannelModelOverride", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + it.each([ { name: "matches parent group id when topic suffix is present", diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts index 74093073253..4bc52c12df0 100644 --- a/src/channels/model-overrides.ts +++ b/src/channels/model-overrides.ts @@ -1,8 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { - parseSessionConversationRef, - parseThreadSessionSuffix, -} from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { buildChannelKeyCandidates, @@ -10,6 +6,10 @@ import { resolveChannelEntryMatchWithFallback, type ChannelMatchSource, } from "./channel-config.js"; +import { + resolveParentConversationCandidates, + resolveSessionConversationRef, +} from "./plugins/session-conversation.js"; export type ChannelModelOverride = { channel: string; @@ -45,51 +45,16 @@ function resolveProviderEntry( ); } -function resolveParentGroupId( - groupId: string | undefined, - channelHint?: string | null, -): string | undefined { - const raw = groupId?.trim(); - if (!raw) { - return undefined; - } - 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 { - return parseSessionConversationRef(sessionKey)?.id; -} - function buildChannelCandidates( params: Pick< ChannelModelOverrideParams, "channel" | "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey" >, -) { +): { keys: string[]; parentKeys: string[] } { const normalizedChannel = normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase(); const groupId = params.groupId?.trim(); - const senderParentGroupId = resolveSenderScopedParentGroupId(groupId); - const parentGroupId = resolveParentGroupId(groupId, normalizedChannel); - const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey); - const senderParentGroupIdFromSession = resolveSenderScopedParentGroupId(parentGroupIdFromSession); - const parentGroupIdResolved = - resolveParentGroupId(parentGroupIdFromSession, normalizedChannel) ?? parentGroupIdFromSession; - const senderParentResolved = - resolveParentGroupId(senderParentGroupId, normalizedChannel) ?? senderParentGroupId; - const senderParentFromSessionResolved = - resolveParentGroupId(senderParentGroupIdFromSession, normalizedChannel) ?? - senderParentGroupIdFromSession; + const sessionConversation = resolveSessionConversationRef(params.parentSessionKey); const groupChannel = params.groupChannel?.trim(); const groupSubject = params.groupSubject?.trim(); const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined; @@ -97,22 +62,26 @@ function buildChannelCandidates( const channelSlug = channelBare ? normalizeChannelSlug(channelBare) : undefined; const subjectSlug = subjectBare ? normalizeChannelSlug(subjectBare) : undefined; - return buildChannelKeyCandidates( - groupId, - senderParentGroupId, - senderParentResolved, - parentGroupId, - parentGroupIdFromSession, - senderParentGroupIdFromSession, - senderParentFromSessionResolved, - parentGroupIdResolved, - groupChannel, - channelBare, - channelSlug, - groupSubject, - subjectBare, - subjectSlug, - ); + return { + keys: buildChannelKeyCandidates( + groupId, + sessionConversation?.rawId, + groupChannel, + channelBare, + channelSlug, + groupSubject, + subjectBare, + subjectSlug, + ), + parentKeys: buildChannelKeyCandidates( + ...resolveParentConversationCandidates({ + channel: normalizedChannel ?? "", + kind: "group", + rawId: groupId ?? "", + }), + ...(sessionConversation?.parentConversationCandidates ?? []), + ), + }; } export function resolveChannelModelOverride( @@ -133,13 +102,14 @@ export function resolveChannelModelOverride( return null; } - const candidates = buildChannelCandidates(params); - if (candidates.length === 0) { + const { keys, parentKeys } = buildChannelCandidates(params); + if (keys.length === 0 && parentKeys.length === 0) { return null; } const match = resolveChannelEntryMatchWithFallback({ entries: providerEntries, - keys: candidates, + keys, + parentKeys, wildcardKey: "*", normalizeKey: (value) => value.trim().toLowerCase(), }); diff --git a/src/channels/plugins/session-conversation.test.ts b/src/channels/plugins/session-conversation.test.ts new file mode 100644 index 00000000000..b544ab1df7b --- /dev/null +++ b/src/channels/plugins/session-conversation.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; +import { + resolveParentConversationCandidates, + resolveSessionConversationRef, + resolveSessionParentSessionKey, + resolveSessionThreadInfo, +} from "./session-conversation.js"; + +describe("session conversation routing", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + + it("keeps generic :thread: parsing in core", () => { + expect( + resolveSessionConversationRef("agent:main:slack:channel:general:thread:1699999999.0001"), + ).toEqual({ + channel: "slack", + kind: "channel", + rawId: "general:thread:1699999999.0001", + id: "general", + threadId: "1699999999.0001", + baseSessionKey: "agent:main:slack:channel:general", + parentConversationCandidates: ["general"], + }); + }); + + it("lets Telegram own :topic: session grammar", () => { + expect(resolveSessionConversationRef("agent:main:telegram:group:-100123:topic:77")).toEqual({ + channel: "telegram", + kind: "group", + rawId: "-100123:topic:77", + id: "-100123", + threadId: "77", + baseSessionKey: "agent:main:telegram:group:-100123", + parentConversationCandidates: ["-100123"], + }); + expect(resolveSessionThreadInfo("agent:main:telegram:group:-100123:topic:77")).toEqual({ + baseSessionKey: "agent:main:telegram:group:-100123", + threadId: "77", + }); + expect(resolveSessionParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe( + "agent:main:telegram:group:-100123", + ); + }); + + it("lets Feishu own parent fallback candidates", () => { + expect( + resolveSessionConversationRef( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toEqual({ + channel: "feishu", + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + threadId: undefined, + baseSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"], + }); + expect( + resolveParentConversationCandidates({ + channel: "feishu", + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }), + ).toEqual(["oc_group_chat:topic:om_topic_root", "oc_group_chat"]); + expect( + resolveSessionParentSessionKey( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toBeNull(); + }); +}); diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts new file mode 100644 index 00000000000..24a7ebe694d --- /dev/null +++ b/src/channels/plugins/session-conversation.ts @@ -0,0 +1,191 @@ +import { + parseRawSessionConversationRef, + parseThreadSessionSuffix, + type ParsedThreadSessionSuffix, + type RawSessionConversationRef, +} from "../../sessions/session-key-utils.js"; +import { normalizeChannelId as normalizeChatChannelId } from "../registry.js"; +import { getChannelPlugin, normalizeChannelId as normalizeAnyChannelId } from "./registry.js"; + +export type ResolvedSessionConversation = { + id: string; + threadId: string | undefined; + parentConversationCandidates: string[]; +}; + +export type ResolvedSessionConversationRef = { + channel: string; + kind: "group" | "channel"; + rawId: string; + id: string; + threadId: string | undefined; + baseSessionKey: string; + parentConversationCandidates: string[]; +}; + +type SessionConversationResolution = ResolvedSessionConversation; + +function normalizeResolvedChannel(channel: string): string { + return ( + normalizeAnyChannelId(channel) ?? + normalizeChatChannelId(channel) ?? + channel.trim().toLowerCase() + ); +} + +function getMessagingAdapter(channel: string) { + const normalizedChannel = normalizeResolvedChannel(channel); + try { + return getChannelPlugin(normalizedChannel)?.messaging; + } catch { + return undefined; + } +} + +function dedupeConversationIds(values: Array): string[] { + const seen = new Set(); + const resolved: string[] = []; + for (const value of values) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + resolved.push(trimmed); + } + return resolved; +} + +function buildGenericConversationResolution(rawId: string): ResolvedSessionConversation | null { + const trimmed = rawId.trim(); + if (!trimmed) { + return null; + } + + const parsed = parseThreadSessionSuffix(trimmed); + const id = (parsed.baseSessionKey ?? trimmed).trim(); + if (!id) { + return null; + } + + return { + id, + threadId: parsed.threadId, + parentConversationCandidates: dedupeConversationIds( + parsed.threadId ? [parsed.baseSessionKey] : [], + ), + }; +} + +function resolveSessionConversationResolution(params: { + channel: string; + kind: "group" | "channel"; + rawId: string; +}): SessionConversationResolution | null { + const rawId = params.rawId.trim(); + if (!rawId) { + return null; + } + + const messaging = getMessagingAdapter(params.channel); + const pluginResolved = messaging?.resolveSessionConversation?.({ + kind: params.kind, + rawId, + }); + const resolved = + pluginResolved && pluginResolved.id?.trim() + ? { + id: pluginResolved.id.trim(), + threadId: pluginResolved.threadId?.trim() || undefined, + parentConversationCandidates: dedupeConversationIds( + pluginResolved.parentConversationCandidates ?? [], + ), + } + : buildGenericConversationResolution(rawId); + if (!resolved) { + return null; + } + + const parentConversationCandidates = dedupeConversationIds( + messaging?.resolveParentConversationCandidates?.({ + kind: params.kind, + rawId, + }) ?? resolved.parentConversationCandidates, + ); + + return { + ...resolved, + parentConversationCandidates, + }; +} + +export function resolveSessionConversation(params: { + channel: string; + kind: "group" | "channel"; + rawId: string; +}): ResolvedSessionConversation | null { + return resolveSessionConversationResolution(params); +} + +export function resolveParentConversationCandidates(params: { + channel: string; + kind: "group" | "channel"; + rawId: string; +}): string[] { + return resolveSessionConversationResolution(params)?.parentConversationCandidates ?? []; +} + +function buildBaseSessionKey(raw: RawSessionConversationRef, id: string): string { + return `${raw.prefix}:${id}`; +} + +export function resolveSessionConversationRef( + sessionKey: string | undefined | null, +): ResolvedSessionConversationRef | null { + const raw = parseRawSessionConversationRef(sessionKey); + if (!raw) { + return null; + } + + const resolved = resolveSessionConversation(raw); + if (!resolved) { + return null; + } + + return { + channel: normalizeResolvedChannel(raw.channel), + kind: raw.kind, + rawId: raw.rawId, + id: resolved.id, + threadId: resolved.threadId, + baseSessionKey: buildBaseSessionKey(raw, resolved.id), + parentConversationCandidates: resolved.parentConversationCandidates, + }; +} + +export function resolveSessionThreadInfo( + sessionKey: string | undefined | null, +): ParsedThreadSessionSuffix { + const resolved = resolveSessionConversationRef(sessionKey); + if (!resolved) { + return parseThreadSessionSuffix(sessionKey); + } + + return { + baseSessionKey: resolved.threadId ? resolved.baseSessionKey : sessionKey?.trim() || undefined, + threadId: resolved.threadId, + }; +} + +export function resolveSessionParentSessionKey( + sessionKey: string | undefined | null, +): string | null { + const { baseSessionKey, threadId } = resolveSessionThreadInfo(sessionKey); + if (!threadId) { + return null; + } + return baseSessionKey ?? null; +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 704ebca0387..cdedf51a3dd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -397,6 +397,24 @@ export type ChannelThreadingToolContext = { /** Channel-owned messaging helpers for target parsing, routing, and payload shaping. */ export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + /** + * Plugin-owned session conversation grammar. + * Use this when the provider encodes thread or scoped-conversation semantics + * inside `rawId` (for example Telegram topics or Feishu sender scopes). + */ + resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => { + id: string; + threadId?: string | null; + parentConversationCandidates?: string[]; + } | null; + /** + * Plugin-owned inheritance chain for channel-specific conversation ids. + * Return broader parent ids in priority order, without repeating `rawId`. + */ + resolveParentConversationCandidates?: (params: { + kind: "group" | "channel"; + rawId: string; + }) => string[] | null; resolveSessionTarget?: (params: { kind: "group" | "channel"; id: string; diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index 9f7c8437818..002b40f6479 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -1,4 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import type { SessionEntry } from "./types.js"; const storeState = vi.hoisted(() => ({ @@ -31,6 +33,7 @@ beforeAll(async () => { }); beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); storeState.store = {}; }); diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index e380c969e4e..91295b1814b 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -1,17 +1,17 @@ -import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; +import { resolveSessionThreadInfo } from "../../channels/plugins/session-conversation.js"; import { loadConfig } from "../io.js"; import { resolveStorePath } from "./paths.js"; import { loadSessionStore } from "./store.js"; /** * Extract deliveryContext and threadId from a sessionKey. - * Supports both :thread: (most channels) and :topic: (Telegram). + * Supports generic :thread: suffixes plus plugin-owned thread/session grammars. */ export function parseSessionThreadInfo(sessionKey: string | undefined): { baseSessionKey: string | undefined; threadId: string | undefined; } { - return parseThreadSessionSuffix(sessionKey); + return resolveSessionThreadInfo(sessionKey); } export function extractDeliveryInfo(sessionKey: string | undefined): { diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts index 91bdb3ebcb6..3c6b6aabb7c 100644 --- a/src/config/sessions/reset.test.ts +++ b/src/config/sessions/reset.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { isThreadSessionKey, resolveSessionResetType } from "./reset.js"; describe("session reset thread detection", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + 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"; diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 00681ffcd79..8f53a1b3f8a 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -1,4 +1,4 @@ -import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; +import { resolveSessionThreadInfo } from "../../channels/plugins/session-conversation.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { SessionConfig, SessionResetConfig } from "../types.base.js"; import { DEFAULT_IDLE_MINUTES } from "./types.js"; @@ -24,7 +24,7 @@ export const DEFAULT_RESET_AT_HOUR = 4; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; export function isThreadSessionKey(sessionKey?: string | null): boolean { - return Boolean(parseThreadSessionSuffix(sessionKey).threadId); + return Boolean(resolveSessionThreadInfo(sessionKey).threadId); } export function resolveSessionResetType(params: { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 83414b9a5a3..b561f1f22b7 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -267,8 +267,8 @@ function resolveConfigRestartRequest(params: unknown): { } { const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params); - // Extract deliveryContext + threadId for routing after restart - // Supports both :thread: (most channels) and :topic: (Telegram) + // Extract deliveryContext + threadId for routing after restart. + // Uses generic :thread: parsing plus plugin-owned session grammars. const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); return { diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index fb58dc3bd22..b8f0724f02d 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -104,27 +104,21 @@ describe("thread session suffix parsing", () => { ).toBeNull(); }); - it("still parses telegram topic session suffixes", () => { + it("does not treat telegram :topic: as a generic thread suffix", () => { expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:topic:77")).toEqual({ - baseSessionKey: "agent:main:telegram:group:-100123", - threadId: "77", + baseSessionKey: "agent:main:telegram:group:-100123:topic:77", + threadId: undefined, }); - expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe( - "agent:main:telegram:group:-100123", - ); + expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBeNull(); }); - it("parses mixed-case suffix markers without lowercasing the stored key", () => { + it("parses mixed-case :thread: 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", - }); }); }); diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index 30e1607eb7a..940c22fb957 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -9,11 +9,11 @@ export type ParsedThreadSessionSuffix = { threadId: string | undefined; }; -export type ParsedSessionConversationRef = { +export type RawSessionConversationRef = { channel: string; kind: "group" | "channel"; - id: string; - threadId: string | undefined; + rawId: string; + prefix: string; }; /** @@ -118,40 +118,24 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:")); } -function normalizeThreadSuffixChannelHint(value: string | undefined | null): string | undefined { +function normalizeSessionConversationChannel(value: string | undefined | null): string | undefined { const trimmed = (value ?? "").trim().toLowerCase(); return trimmed || undefined; } -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, - 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 markerIndex = threadIndex; + const marker = threadMarker; const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex); const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length); @@ -160,39 +144,38 @@ export function parseThreadSessionSuffix( return { baseSessionKey, threadId }; } -export function parseSessionConversationRef( +export function parseRawSessionConversationRef( sessionKey: string | undefined | null, -): ParsedSessionConversationRef | null { +): RawSessionConversationRef | null { const raw = (sessionKey ?? "").trim(); if (!raw) { return null; } const rawParts = raw.split(":").filter(Boolean); - const parts = - rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent" - ? rawParts.slice(2) - : rawParts; + const bodyStartIndex = + rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent" ? 2 : 0; + const parts = rawParts.slice(bodyStartIndex); if (parts.length < 3) { return null; } - const channel = normalizeThreadSuffixChannelHint(parts[0]); + const channel = normalizeSessionConversationChannel(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) { + const rawId = parts.slice(2).join(":").trim(); + const prefix = rawParts + .slice(0, bodyStartIndex + 2) + .join(":") + .trim(); + if (!rawId || !prefix) { return null; } - return { channel, kind, id, threadId }; + return { channel, kind, rawId, prefix }; } export function resolveThreadParentSessionKey( diff --git a/src/test-utils/session-conversation-registry.ts b/src/test-utils/session-conversation-registry.ts new file mode 100644 index 00000000000..6aaee6dd35a --- /dev/null +++ b/src/test-utils/session-conversation-registry.ts @@ -0,0 +1,171 @@ +import { createTestRegistry } from "./channel-plugins.js"; + +function parseTelegramTopicConversation(rawId: string) { + const match = /^(-?\d+):topic:(\d+)$/i.exec(rawId.trim()); + if (!match) { + return null; + } + return { + id: match[1], + threadId: match[2], + parentConversationCandidates: [match[1]], + }; +} + +function resolveFeishuConversation(rawId: string) { + const trimmed = rawId.trim(); + if (!trimmed) { + return null; + } + const topicSenderMatch = /^(.+):topic:([^:]+):sender:([^:]+)$/i.exec(trimmed); + if (topicSenderMatch) { + const [, chatId, topicId, senderId] = topicSenderMatch; + return { + id: `${chatId}:topic:${topicId}:sender:${senderId}`, + parentConversationCandidates: [`${chatId}:topic:${topicId}`, chatId], + }; + } + const topicMatch = /^(.+):topic:([^:]+)$/i.exec(trimmed); + if (topicMatch) { + const [, chatId, topicId] = topicMatch; + return { + id: `${chatId}:topic:${topicId}`, + parentConversationCandidates: [chatId], + }; + } + const senderMatch = /^(.+):sender:([^:]+)$/i.exec(trimmed); + if (senderMatch) { + const [, chatId, senderId] = senderMatch; + return { + id: `${chatId}:sender:${senderId}`, + parentConversationCandidates: [chatId], + }; + } + return { + id: trimmed, + parentConversationCandidates: [], + }; +} + +export function createSessionConversationTestRegistry() { + return createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "slack", + source: "test", + plugin: { + id: "slack", + meta: { + id: "slack", + label: "Slack", + selectionLabel: "Slack", + docsPath: "/channels/slack", + blurb: "Slack test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "matrix", + source: "test", + plugin: { + id: "matrix", + meta: { + id: "matrix", + label: "Matrix", + selectionLabel: "Matrix", + docsPath: "/channels/matrix", + blurb: "Matrix test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "telegram", + source: "test", + plugin: { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test stub.", + }, + capabilities: { chatTypes: ["direct", "group", "thread"] }, + messaging: { + normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), + resolveSessionConversation: ({ rawId }: { rawId: string }) => + parseTelegramTopicConversation(rawId), + resolveParentConversationCandidates: ({ rawId }: { rawId: string }) => + parseTelegramTopicConversation(rawId)?.parentConversationCandidates ?? null, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + 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:/, ""), + resolveSessionConversation: ({ rawId }: { rawId: string }) => + resolveFeishuConversation(rawId), + resolveParentConversationCandidates: ({ rawId }: { rawId: string }) => + resolveFeishuConversation(rawId)?.parentConversationCandidates ?? null, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]); +}