fix: tighten plugin session conversation routing

This commit is contained in:
Gustavo Madeira Santana 2026-03-31 11:33:11 -04:00
parent 42a74bb635
commit c6b3d134f9
17 changed files with 275 additions and 110 deletions

View File

@ -154,7 +154,7 @@
"exportName": "ChannelMessageActionAdapter",
"kind": "type",
"source": {
"line": 529,
"line": 551,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -163,7 +163,7 @@
"exportName": "ChannelMessageActionContext",
"kind": "type",
"source": {
"line": 493,
"line": 515,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1170,7 +1170,7 @@
"exportName": "BaseProbeResult",
"kind": "type",
"source": {
"line": 572,
"line": 594,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1179,7 +1179,7 @@
"exportName": "BaseTokenResolution",
"kind": "type",
"source": {
"line": 578,
"line": 600,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1224,7 +1224,7 @@
"exportName": "ChannelMessageActionAdapter",
"kind": "type",
"source": {
"line": 529,
"line": 551,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1233,7 +1233,7 @@
"exportName": "ChannelMessageActionContext",
"kind": "type",
"source": {
"line": 493,
"line": 515,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1752,7 +1752,7 @@
"exportName": "BaseProbeResult",
"kind": "type",
"source": {
"line": 572,
"line": 594,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1761,7 +1761,7 @@
"exportName": "BaseTokenResolution",
"kind": "type",
"source": {
"line": 578,
"line": 600,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1797,7 +1797,7 @@
"exportName": "ChannelAgentPromptAdapter",
"kind": "type",
"source": {
"line": 466,
"line": 488,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1977,7 +1977,7 @@
"exportName": "ChannelDirectoryEntry",
"kind": "type",
"source": {
"line": 480,
"line": 502,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -1986,7 +1986,7 @@
"exportName": "ChannelDirectoryEntryKind",
"kind": "type",
"source": {
"line": 478,
"line": 500,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -2130,7 +2130,7 @@
"exportName": "ChannelMessageActionAdapter",
"kind": "type",
"source": {
"line": 529,
"line": 551,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -2139,7 +2139,7 @@
"exportName": "ChannelMessageActionContext",
"kind": "type",
"source": {
"line": 493,
"line": 515,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -2274,7 +2274,7 @@
"exportName": "ChannelPollContext",
"kind": "type",
"source": {
"line": 560,
"line": 582,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -2283,7 +2283,7 @@
"exportName": "ChannelPollResult",
"kind": "type",
"source": {
"line": 551,
"line": 573,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -2427,7 +2427,7 @@
"exportName": "ChannelToolSend",
"kind": "type",
"source": {
"line": 522,
"line": 544,
"path": "src/channels/plugins/types.core.ts"
}
},
@ -2683,7 +2683,7 @@
"exportName": "buildCommandsMessage",
"kind": "function",
"source": {
"line": 1075,
"line": 1076,
"path": "src/auto-reply/status.ts"
}
},
@ -2692,7 +2692,7 @@
"exportName": "buildCommandsMessagePaginated",
"kind": "function",
"source": {
"line": 1084,
"line": 1085,
"path": "src/auto-reply/status.ts"
}
},
@ -2728,7 +2728,7 @@
"exportName": "buildHelpMessage",
"kind": "function",
"source": {
"line": 870,
"line": 871,
"path": "src/auto-reply/status.ts"
}
},
@ -3738,7 +3738,7 @@
"exportName": "ChannelMessageActionContext",
"kind": "type",
"source": {
"line": 493,
"line": 515,
"path": "src/channels/plugins/types.core.ts"
}
},

File diff suppressed because one or more lines are too long

View File

@ -36,10 +36,16 @@ 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.
in the plugin with `messaging.resolveSessionConversation(...)`. That is the
canonical hook for mapping `rawId` to the base conversation id, optional thread
id, and any `parentConversationCandidates`.
`messaging.resolveParentConversationCandidates(...)` remains available as a
legacy compatibility fallback when a plugin only needs parent fallbacks on top
of the generic/raw id. If both hooks exist, core uses
`resolveSessionConversation(...).parentConversationCandidates` first and only
falls back to `resolveParentConversationCandidates(...)` when the canonical hook
omits them.
## Approvals and channel capabilities

View File

@ -501,6 +501,7 @@ function resolveDiscordThreadTitleModelRef(params: {
cfg: params.cfg,
channel,
groupId: params.threadId,
groupChatType: "channel",
groupChannel,
groupSubject: groupChannel,
parentSessionKey,

View File

@ -200,17 +200,14 @@ describe("feishuPlugin messaging", () => {
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?.({
feishuPlugin.messaging?.resolveSessionConversation?.({
kind: "group",
rawId: "oc_group_chat:topic:om_topic_root",
}),
).toEqual(["oc_group_chat"]);
).toEqual({
id: "oc_group_chat:topic:om_topic_root",
parentConversationCandidates: ["oc_group_chat"],
});
});
});

View File

@ -1106,8 +1106,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
messaging: {
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
resolveSessionConversation: ({ rawId }) => resolveFeishuSessionConversation(rawId),
resolveParentConversationCandidates: ({ rawId }) =>
resolveFeishuParentConversationCandidates(rawId),
resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeFeishuId,

View File

@ -200,11 +200,15 @@ describe("telegramPlugin messaging", () => {
parentConversationCandidates: ["-1001"],
});
expect(
telegramPlugin.messaging?.resolveParentConversationCandidates?.({
telegramPlugin.messaging?.resolveSessionConversation?.({
kind: "group",
rawId: "-1001:topic:77",
rawId: "-1001:Topic:77",
}),
).toEqual(["-1001"]);
).toEqual({
id: "-1001",
threadId: "77",
parentConversationCandidates: ["-1001"],
});
expect(
telegramPlugin.messaging?.resolveSessionConversation?.({
kind: "group",

View File

@ -543,8 +543,6 @@ 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

@ -43,7 +43,7 @@ export function parseTelegramTopicConversation(params: {
parentConversationId?: string;
}): ParsedTelegramTopicConversation | null {
const conversation = params.conversationId.trim();
const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/);
const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/i);
if (directMatch?.[1] && directMatch[2]) {
const canonicalConversationId = buildTelegramTopicConversationId({
chatId: directMatch[1],

View File

@ -267,6 +267,7 @@ export async function getReplyFromConfig(
: undefined) ??
finalized.Provider,
groupId: groupResolution?.id ?? sessionEntry.groupId,
groupChatType: sessionEntry.chatType ?? sessionCtx.ChatType ?? finalized.ChatType,
groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel,
groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject,
parentSessionKey: sessionCtx.ParentSessionKey,

View File

@ -763,6 +763,7 @@ export function buildStatusMessage(args: StatusArgs): string {
cfg: args.config,
channel: entry.channel ?? entry.origin?.provider,
groupId: entry.groupId,
groupChatType: entry.chatType ?? entry.origin?.chatType,
groupChannel: entry.groupChannel,
groupSubject: entry.subject,
parentSessionKey: args.parentSessionKey,

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js";
import { resolveChannelModelOverride } from "./model-overrides.js";
@ -110,4 +111,60 @@ describe("resolveChannelModelOverride", () => {
expect(resolved?.model).toBe(expected.model);
expect(resolved?.matchKey).toBe(expected.matchKey);
});
it("passes channel kind to plugin-owned parent fallback resolution", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "channel-kind",
source: "test",
plugin: {
id: "channel-kind",
meta: {
id: "channel-kind",
label: "Channel Kind",
selectionLabel: "Channel Kind",
docsPath: "/channels/channel-kind",
blurb: "test stub.",
},
capabilities: { chatTypes: ["group", "channel"] },
messaging: {
resolveSessionConversation: ({
kind,
rawId,
}: {
kind: "group" | "channel";
rawId: string;
}) => ({
id: rawId,
parentConversationCandidates: kind === "channel" ? ["thread-parent"] : [],
}),
},
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
},
},
]),
);
const resolved = resolveChannelModelOverride({
cfg: {
channels: {
modelByChannel: {
"channel-kind": {
"thread-parent": "demo-provider/demo-channel-model",
},
},
},
} as unknown as OpenClawConfig,
channel: "channel-kind",
groupId: "thread-123",
groupChatType: "channel",
});
expect(resolved?.model).toBe("demo-provider/demo-channel-model");
expect(resolved?.matchKey).toBe("thread-parent");
});
});

View File

@ -6,8 +6,9 @@ import {
resolveChannelEntryMatchWithFallback,
type ChannelMatchSource,
} from "./channel-config.js";
import { normalizeChatType } from "./chat-type.js";
import {
resolveParentConversationCandidates,
resolveSessionConversation,
resolveSessionConversationRef,
} from "./plugins/session-conversation.js";
@ -24,6 +25,7 @@ type ChannelModelOverrideParams = {
cfg: OpenClawConfig;
channel?: string | null;
groupId?: string | null;
groupChatType?: string | null;
groupChannel?: string | null;
groupSubject?: string | null;
parentSessionKey?: string | null;
@ -48,13 +50,24 @@ function resolveProviderEntry(
function buildChannelCandidates(
params: Pick<
ChannelModelOverrideParams,
"channel" | "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey"
"channel" | "groupId" | "groupChatType" | "groupChannel" | "groupSubject" | "parentSessionKey"
>,
): { keys: string[]; parentKeys: string[] } {
const normalizedChannel =
normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase();
const groupId = params.groupId?.trim();
const sessionConversation = resolveSessionConversationRef(params.parentSessionKey);
const groupConversationKind =
normalizeChatType(params.groupChatType ?? undefined) === "channel"
? "channel"
: sessionConversation?.kind === "channel"
? "channel"
: "group";
const groupConversation = resolveSessionConversation({
channel: normalizedChannel ?? "",
kind: groupConversationKind,
rawId: groupId ?? "",
});
const groupChannel = params.groupChannel?.trim();
const groupSubject = params.groupSubject?.trim();
const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined;
@ -74,11 +87,7 @@ function buildChannelCandidates(
subjectSlug,
),
parentKeys: buildChannelKeyCandidates(
...resolveParentConversationCandidates({
channel: normalizedChannel ?? "",
kind: "group",
rawId: groupId ?? "",
}),
...(groupConversation?.parentConversationCandidates ?? []),
...(sessionConversation?.parentConversationCandidates ?? []),
),
};

View File

@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
import {
resolveParentConversationCandidates,
resolveSessionConversation,
resolveSessionConversationRef,
resolveSessionParentSessionKey,
resolveSessionThreadInfo,
@ -46,6 +47,20 @@ describe("session conversation routing", () => {
);
});
it("keeps bundled Telegram topic parsing available before registry bootstrap", () => {
resetPluginRuntimeStateForTest();
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"],
});
});
it("lets Feishu own parent fallback candidates", () => {
expect(
resolveSessionConversationRef(
@ -61,17 +76,52 @@ describe("session conversation routing", () => {
"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();
});
it("keeps the legacy parent-candidate hook as a fallback only", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "legacy-parent",
source: "test",
plugin: {
id: "legacy-parent",
meta: {
id: "legacy-parent",
label: "Legacy Parent",
selectionLabel: "Legacy Parent",
docsPath: "/channels/legacy-parent",
blurb: "test stub.",
},
capabilities: { chatTypes: ["group"] },
messaging: {
resolveParentConversationCandidates: ({ rawId }: { rawId: string }) =>
rawId.endsWith(":sender:user") ? [rawId.replace(/:sender:user$/i, "")] : null,
},
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
},
},
]),
);
expect(
resolveSessionConversation({
channel: "legacy-parent",
kind: "group",
rawId: "room:sender:user",
}),
).toEqual({
id: "room:sender:user",
threadId: undefined,
parentConversationCandidates: ["room"],
});
});
});

View File

@ -1,3 +1,4 @@
import { parseTelegramTopicConversation } from "../../acp/conversation-id.js";
import {
parseRawSessionConversationRef,
parseThreadSessionSuffix,
@ -23,7 +24,15 @@ export type ResolvedSessionConversationRef = {
parentConversationCandidates: string[];
};
type SessionConversationResolution = ResolvedSessionConversation;
type SessionConversationHookResult = {
id: string;
threadId?: string | null;
parentConversationCandidates?: string[];
};
type NormalizedSessionConversationResolution = ResolvedSessionConversation & {
hasExplicitParentConversationCandidates: boolean;
};
function normalizeResolvedChannel(channel: string): string {
return (
@ -80,40 +89,82 @@ function buildGenericConversationResolution(rawId: string): ResolvedSessionConve
};
}
function normalizeSessionConversationResolution(
resolved: SessionConversationHookResult | null | undefined,
): NormalizedSessionConversationResolution | null {
if (!resolved?.id?.trim()) {
return null;
}
return {
id: resolved.id.trim(),
threadId: resolved.threadId?.trim() || undefined,
parentConversationCandidates: dedupeConversationIds(
resolved.parentConversationCandidates ?? [],
),
hasExplicitParentConversationCandidates: Object.hasOwn(
resolved,
"parentConversationCandidates",
),
};
}
function resolveBundledSessionConversationFallback(params: {
channel: string;
rawId: string;
}): NormalizedSessionConversationResolution | null {
if (normalizeResolvedChannel(params.channel) !== "telegram") {
return null;
}
const parsed = parseTelegramTopicConversation({ conversationId: params.rawId });
if (!parsed) {
return null;
}
return {
id: parsed.chatId,
threadId: parsed.topicId,
parentConversationCandidates: [parsed.chatId],
hasExplicitParentConversationCandidates: true,
};
}
function resolveSessionConversationResolution(params: {
channel: string;
kind: "group" | "channel";
rawId: string;
}): SessionConversationResolution | null {
}): ResolvedSessionConversation | null {
const rawId = params.rawId.trim();
if (!rawId) {
return null;
}
const messaging = getMessagingAdapter(params.channel);
const pluginResolved = messaging?.resolveSessionConversation?.({
kind: params.kind,
rawId,
});
const pluginResolved = normalizeSessionConversationResolution(
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);
pluginResolved ??
resolveBundledSessionConversationFallback({
channel: params.channel,
rawId,
}) ??
buildGenericConversationResolution(rawId);
if (!resolved) {
return null;
}
const parentConversationCandidates = dedupeConversationIds(
messaging?.resolveParentConversationCandidates?.({
kind: params.kind,
rawId,
}) ?? resolved.parentConversationCandidates,
pluginResolved?.hasExplicitParentConversationCandidates
? resolved.parentConversationCandidates
: (messaging?.resolveParentConversationCandidates?.({
kind: params.kind,
rawId,
}) ?? resolved.parentConversationCandidates),
);
return {
@ -130,14 +181,6 @@ export function resolveSessionConversation(params: {
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}`;
}

View File

@ -398,9 +398,11 @@ export type ChannelThreadingToolContext = {
export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
/**
* Plugin-owned session conversation grammar.
* Canonical 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).
* Return `parentConversationCandidates` here when you can so parsing and
* inheritance stay in one place.
*/
resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => {
id: string;
@ -408,8 +410,10 @@ export type ChannelMessagingAdapter = {
parentConversationCandidates?: string[];
} | null;
/**
* Plugin-owned inheritance chain for channel-specific conversation ids.
* Return broader parent ids in priority order, without repeating `rawId`.
* Legacy compatibility hook for parent fallbacks when a plugin does not need
* to customize `id` or `threadId`. Core only uses this when
* `resolveSessionConversation(...)` does not return
* `parentConversationCandidates`.
*/
resolveParentConversationCandidates?: (params: {
kind: "group" | "channel";

View File

@ -132,8 +132,6 @@ export function createSessionConversationTestRegistry() {
normalizeTarget: (raw: string) => raw.replace(/^group:/, ""),
resolveSessionConversation: ({ rawId }: { rawId: string }) =>
parseTelegramTopicConversation(rawId),
resolveParentConversationCandidates: ({ rawId }: { rawId: string }) =>
parseTelegramTopicConversation(rawId)?.parentConversationCandidates ?? null,
},
config: {
listAccountIds: () => ["default"],
@ -158,8 +156,6 @@ export function createSessionConversationTestRegistry() {
normalizeTarget: (raw: string) => raw.replace(/^group:/, ""),
resolveSessionConversation: ({ rawId }: { rawId: string }) =>
resolveFeishuConversation(rawId),
resolveParentConversationCandidates: ({ rawId }: { rawId: string }) =>
resolveFeishuConversation(rawId)?.parentConversationCandidates ?? null,
},
config: {
listAccountIds: () => ["default"],