Refactor: move session conversation grammar into channel plugins

This commit is contained in:
Gustavo Madeira Santana 2026-03-31 10:01:19 -04:00
parent bf0f33db32
commit 42a74bb635
26 changed files with 683 additions and 243 deletions

View File

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

View File

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

View File

@ -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: {

View File

@ -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<ResolvedFeishuAccount, FeishuProbeResul
setupWizard: feishuSetupWizard,
messaging: {
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
resolveSessionConversation: ({ rawId }) => resolveFeishuSessionConversation(rawId),
resolveParentConversationCandidates: ({ rawId }) =>
resolveFeishuParentConversationCandidates(rawId),
resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeFeishuId,

View File

@ -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();

View File

@ -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 }) => {

View File

@ -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",

View File

@ -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") {

View File

@ -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",

View File

@ -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", () => {

View File

@ -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;
}

View File

@ -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<string, unknown> = {}) => ({
id: "gpt-5.4",
name: "GPT-5.4",

View File

@ -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;
}

View File

@ -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",

View File

@ -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(),
});

View File

@ -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();
});
});

View File

@ -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 | undefined | null>): string[] {
const seen = new Set<string>();
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;
}

View File

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

View File

@ -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 = {};
});

View File

@ -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): {

View File

@ -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";

View File

@ -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: {

View File

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

View File

@ -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",
});
});
});

View File

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

View File

@ -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: () => ({}),
},
},
},
]);
}