mirror of https://github.com/openclaw/openclaw.git
Fix: move bootstrap session grammar into plugin-owned session-key surfaces (#58400)
Merged via squash.
Prepared head SHA: b062b18b03
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
bf0f33db32
commit
bea53d7a3f
|
|
@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
|||
### Changes
|
||||
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
|
||||
- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@
|
|||
"exportName": "ChannelMessageActionAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 529,
|
||||
"line": 556,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -163,7 +163,7 @@
|
|||
"exportName": "ChannelMessageActionContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 493,
|
||||
"line": 520,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1170,7 +1170,7 @@
|
|||
"exportName": "BaseProbeResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 572,
|
||||
"line": 599,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1179,7 +1179,7 @@
|
|||
"exportName": "BaseTokenResolution",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 578,
|
||||
"line": 605,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1224,7 +1224,7 @@
|
|||
"exportName": "ChannelMessageActionAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 529,
|
||||
"line": 556,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1233,7 +1233,7 @@
|
|||
"exportName": "ChannelMessageActionContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 493,
|
||||
"line": 520,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1752,7 +1752,7 @@
|
|||
"exportName": "BaseProbeResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 572,
|
||||
"line": 599,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1761,7 +1761,7 @@
|
|||
"exportName": "BaseTokenResolution",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 578,
|
||||
"line": 605,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1797,7 +1797,7 @@
|
|||
"exportName": "ChannelAgentPromptAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 466,
|
||||
"line": 493,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1977,7 +1977,7 @@
|
|||
"exportName": "ChannelDirectoryEntry",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 480,
|
||||
"line": 507,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1986,7 +1986,7 @@
|
|||
"exportName": "ChannelDirectoryEntryKind",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 478,
|
||||
"line": 505,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2130,7 +2130,7 @@
|
|||
"exportName": "ChannelMessageActionAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 529,
|
||||
"line": 556,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2139,7 +2139,7 @@
|
|||
"exportName": "ChannelMessageActionContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 493,
|
||||
"line": 520,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2274,7 +2274,7 @@
|
|||
"exportName": "ChannelPollContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 560,
|
||||
"line": 587,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2283,7 +2283,7 @@
|
|||
"exportName": "ChannelPollResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 551,
|
||||
"line": 578,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2427,7 +2427,7 @@
|
|||
"exportName": "ChannelToolSend",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 522,
|
||||
"line": 549,
|
||||
"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": 520,
|
||||
"path": "src/channels/plugins/types.core.ts"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,11 +28,31 @@ 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 with `messaging.resolveSessionConversation(...)`. That is the
|
||||
canonical hook for mapping `rawId` to the base conversation id, optional thread
|
||||
id, explicit `baseConversationId`, and any `parentConversationCandidates`.
|
||||
When you return `parentConversationCandidates`, keep them ordered from the
|
||||
narrowest parent to the broadest/base conversation.
|
||||
|
||||
Bundled plugins that need the same parsing before the channel registry boots
|
||||
can also expose a top-level `session-key-api.ts` file with a matching
|
||||
`resolveSessionConversation(...)` export. Core uses that bootstrap-safe surface
|
||||
only when the runtime plugin registry is not available yet.
|
||||
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ function resolveDiscordThreadTitleModelRef(params: {
|
|||
cfg: params.cfg,
|
||||
channel,
|
||||
groupId: params.threadId,
|
||||
groupChatType: "channel",
|
||||
groupChannel,
|
||||
groupSubject: groupChannel,
|
||||
parentSessionKey,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { resolveFeishuSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";
|
||||
|
|
@ -183,6 +183,46 @@ 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",
|
||||
baseConversationId: "oc_group_chat",
|
||||
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
|
||||
});
|
||||
expect(
|
||||
feishuPlugin.messaging?.resolveSessionConversation?.({
|
||||
kind: "group",
|
||||
rawId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "oc_group_chat:topic:om_topic_root",
|
||||
baseConversationId: "oc_group_chat",
|
||||
parentConversationCandidates: ["oc_group_chat"],
|
||||
});
|
||||
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",
|
||||
baseConversationId: "oc_group_chat",
|
||||
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishuPlugin actions", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ import {
|
|||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import {
|
||||
resolveFeishuParentConversationCandidates,
|
||||
resolveFeishuSessionConversation,
|
||||
} from "./session-conversation.js";
|
||||
import { resolveFeishuOutboundSessionRoute } from "./session-route.js";
|
||||
import { feishuSetupAdapter } from "./setup-core.js";
|
||||
import { feishuSetupWizard } from "./setup-surface.js";
|
||||
|
|
@ -1068,6 +1072,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
|||
setupWizard: feishuSetupWizard,
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
|
||||
resolveSessionConversation: ({ kind, rawId }) =>
|
||||
resolveFeishuSessionConversation({ kind, rawId }),
|
||||
resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeFeishuId,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function parseFeishuConversationId(params: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/);
|
||||
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
|
||||
if (topicSenderMatch) {
|
||||
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
|
||||
return {
|
||||
|
|
@ -117,7 +117,7 @@ export function parseFeishuConversationId(params: {
|
|||
};
|
||||
}
|
||||
|
||||
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/);
|
||||
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/i);
|
||||
if (topicMatch) {
|
||||
const [, chatId, topicId] = topicMatch;
|
||||
return {
|
||||
|
|
@ -132,7 +132,7 @@ export function parseFeishuConversationId(params: {
|
|||
};
|
||||
}
|
||||
|
||||
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/);
|
||||
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/i);
|
||||
if (senderMatch) {
|
||||
const [, chatId, senderOpenId] = senderMatch;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js";
|
||||
|
||||
export 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFeishuSessionConversation(params: {
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
}) {
|
||||
const parsed = parseFeishuConversationId({ conversationId: params.rawId });
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: parsed.canonicalConversationId,
|
||||
baseConversationId: parsed.chatId,
|
||||
parentConversationCandidates: resolveFeishuParentConversationCandidates(
|
||||
parsed.canonicalConversationId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { resolveTelegramSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";
|
||||
|
|
@ -187,6 +187,39 @@ 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",
|
||||
baseConversationId: "-1001",
|
||||
parentConversationCandidates: ["-1001"],
|
||||
});
|
||||
expect(
|
||||
telegramPlugin.messaging?.resolveSessionConversation?.({
|
||||
kind: "group",
|
||||
rawId: "-1001:Topic:77",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "-1001",
|
||||
threadId: "77",
|
||||
baseConversationId: "-1001",
|
||||
parentConversationCandidates: ["-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();
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import type { TelegramProbe } from "./probe.js";
|
|||
import { resolveTelegramReactionLevel } from "./reaction-level.js";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
import { sendMessageTelegram, sendPollTelegram, sendTypingTelegram } from "./send.js";
|
||||
import { resolveTelegramSessionConversation } from "./session-conversation.js";
|
||||
import { telegramSetupAdapter } from "./setup-core.js";
|
||||
import { telegramSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
|
|
@ -530,6 +531,8 @@ export const telegramPlugin = createChatChannelPlugin({
|
|||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeTelegramMessagingTarget,
|
||||
resolveSessionConversation: ({ kind, rawId }) =>
|
||||
resolveTelegramSessionConversation({ kind, rawId }),
|
||||
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
|
||||
formatTargetDisplay: ({ target, display, kind }) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram-core";
|
||||
|
||||
export function resolveTelegramSessionConversation(params: {
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
}) {
|
||||
const parsed = parseTelegramTopicConversation({ conversationId: params.rawId });
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: parsed.chatId,
|
||||
threadId: parsed.topicId,
|
||||
baseConversationId: parsed.chatId,
|
||||
parentConversationCandidates: [parsed.chatId],
|
||||
};
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -515,6 +521,31 @@ describe("Agent-specific tool filtering", () => {
|
|||
expect(names).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should resolve feishu group tool policy for sender-scoped session keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
oc_group_chat: {
|
||||
tools: { allow: ["read"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
messageProvider: "feishu",
|
||||
workspaceDir: "/tmp/test-feishu-scoped-group",
|
||||
agentDir: "/tmp/agent-feishu",
|
||||
});
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain("read");
|
||||
expect(names).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should inherit group tool policy for subagents from spawnedBy session keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import {
|
||||
resolveSessionConversationRef,
|
||||
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 +139,18 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
|
|||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const base = resolveThreadParentSessionKey(raw) ?? raw;
|
||||
const resolvedConversation = resolveSessionConversationRef(raw);
|
||||
if (resolvedConversation) {
|
||||
const groupId = resolvedConversation.baseConversationId;
|
||||
if (!groupId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
channel: resolvedConversation.channel,
|
||||
groupId,
|
||||
};
|
||||
}
|
||||
const base = resolveSessionParentSessionKey(raw) ?? raw;
|
||||
const parts = base.split(":").filter(Boolean);
|
||||
let body = parts[0] === "agent" ? parts.slice(2) : parts;
|
||||
if (body[0] === "subagent") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.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 { resolveChannelModelOverride } from "./model-overrides.js";
|
||||
|
||||
describe("resolveChannelModelOverride", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "matches parent group id when topic suffix is present",
|
||||
|
|
@ -104,4 +111,125 @@ 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");
|
||||
});
|
||||
|
||||
it("keeps bundled Feishu parent fallback matching before registry bootstrap", () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
|
||||
const resolved = resolveChannelModelOverride({
|
||||
cfg: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
feishu: {
|
||||
"oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
channel: "feishu",
|
||||
groupId: "unrelated",
|
||||
parentSessionKey:
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
});
|
||||
|
||||
expect(resolved?.model).toBe("demo-provider/demo-feishu-topic-model");
|
||||
expect(resolved?.matchKey).toBe("oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
|
||||
it("keeps mixed-case Feishu scoped markers when matching parent session fallbacks", () => {
|
||||
const resolved = resolveChannelModelOverride({
|
||||
cfg: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
feishu: {
|
||||
"oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
channel: "feishu",
|
||||
groupId: "unrelated",
|
||||
parentSessionKey:
|
||||
"agent:main:feishu:group:oc_group_chat:Topic:om_topic_root:Sender:ou_topic_user",
|
||||
});
|
||||
|
||||
expect(resolved?.model).toBe("demo-provider/demo-feishu-topic-model");
|
||||
expect(resolved?.matchKey).toBe("oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
|
||||
it("prefers parent conversation ids over channel-name fallbacks", () => {
|
||||
const resolved = resolveChannelModelOverride({
|
||||
cfg: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
telegram: {
|
||||
"-100123": "demo-provider/demo-parent-model",
|
||||
"#general": "demo-provider/demo-channel-name-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
channel: "telegram",
|
||||
groupId: "-100123:topic:99",
|
||||
groupChannel: "#general",
|
||||
});
|
||||
|
||||
expect(resolved?.model).toBe("demo-provider/demo-parent-model");
|
||||
expect(resolved?.matchKey).toBe("-100123");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,11 @@ import {
|
|||
resolveChannelEntryMatchWithFallback,
|
||||
type ChannelMatchSource,
|
||||
} from "./channel-config.js";
|
||||
import { normalizeChatType } from "./chat-type.js";
|
||||
import {
|
||||
resolveSessionConversation,
|
||||
resolveSessionConversationRef,
|
||||
} from "./plugins/session-conversation.js";
|
||||
|
||||
export type ChannelModelOverride = {
|
||||
channel: string;
|
||||
|
|
@ -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;
|
||||
|
|
@ -45,51 +47,27 @@ 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"
|
||||
"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 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 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;
|
||||
|
|
@ -97,22 +75,22 @@ 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,
|
||||
...(groupConversation?.parentConversationCandidates ?? []),
|
||||
...(sessionConversation?.parentConversationCandidates ?? []),
|
||||
),
|
||||
parentKeys: buildChannelKeyCandidates(
|
||||
groupChannel,
|
||||
channelBare,
|
||||
channelSlug,
|
||||
groupSubject,
|
||||
subjectBare,
|
||||
subjectSlug,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveChannelModelOverride(
|
||||
|
|
@ -133,13 +111,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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
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 {
|
||||
resolveSessionConversation,
|
||||
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",
|
||||
baseConversationId: "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",
|
||||
baseConversationId: "-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("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",
|
||||
baseConversationId: "-100123",
|
||||
parentConversationCandidates: ["-100123"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bundled Feishu parent fallbacks available before registry bootstrap", () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
|
||||
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",
|
||||
baseConversationId: "oc_group_chat",
|
||||
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
baseConversationId: "oc_group_chat",
|
||||
parentConversationCandidates: ["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,
|
||||
baseConversationId: "room",
|
||||
parentConversationCandidates: ["room"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { loadBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js";
|
||||
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
|
||||
import { resolveBundledPluginPublicSurfacePath } from "../../plugins/bundled-plugin-metadata.js";
|
||||
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;
|
||||
baseConversationId: string;
|
||||
parentConversationCandidates: string[];
|
||||
};
|
||||
|
||||
export type ResolvedSessionConversationRef = {
|
||||
channel: string;
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
id: string;
|
||||
threadId: string | undefined;
|
||||
baseSessionKey: string;
|
||||
baseConversationId: string;
|
||||
parentConversationCandidates: string[];
|
||||
};
|
||||
|
||||
type SessionConversationHookResult = {
|
||||
id: string;
|
||||
threadId?: string | null;
|
||||
baseConversationId?: string | null;
|
||||
parentConversationCandidates?: string[];
|
||||
};
|
||||
|
||||
type SessionConversationResolverParams = {
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
};
|
||||
|
||||
type BundledSessionKeyModule = {
|
||||
resolveSessionConversation?: (
|
||||
params: SessionConversationResolverParams,
|
||||
) => SessionConversationHookResult | null;
|
||||
};
|
||||
|
||||
const OPENCLAW_PACKAGE_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const SESSION_KEY_API_ARTIFACT_BASENAME = "session-key-api.js";
|
||||
|
||||
type NormalizedSessionConversationResolution = ResolvedSessionConversation & {
|
||||
hasExplicitParentConversationCandidates: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
baseConversationId: id,
|
||||
parentConversationCandidates: dedupeConversationIds(
|
||||
parsed.threadId ? [parsed.baseSessionKey] : [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionConversationResolution(
|
||||
resolved: SessionConversationHookResult | null | undefined,
|
||||
): NormalizedSessionConversationResolution | null {
|
||||
if (!resolved?.id?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: resolved.id.trim(),
|
||||
threadId: resolved.threadId?.trim() || undefined,
|
||||
baseConversationId:
|
||||
resolved.baseConversationId?.trim() ||
|
||||
dedupeConversationIds(resolved.parentConversationCandidates ?? []).at(-1) ||
|
||||
resolved.id.trim(),
|
||||
parentConversationCandidates: dedupeConversationIds(
|
||||
resolved.parentConversationCandidates ?? [],
|
||||
),
|
||||
hasExplicitParentConversationCandidates: Object.hasOwn(
|
||||
resolved,
|
||||
"parentConversationCandidates",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBundledSessionConversationFallback(params: {
|
||||
channel: string;
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
}): NormalizedSessionConversationResolution | null {
|
||||
const dirName = normalizeResolvedChannel(params.channel);
|
||||
if (
|
||||
!resolveBundledPluginPublicSurfacePath({
|
||||
rootDir: OPENCLAW_PACKAGE_ROOT,
|
||||
bundledPluginsDir: resolveBundledPluginsDir(),
|
||||
dirName,
|
||||
artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolveSessionConversation =
|
||||
loadBundledPluginPublicSurfaceModuleSync<BundledSessionKeyModule>({
|
||||
dirName,
|
||||
artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME,
|
||||
}).resolveSessionConversation;
|
||||
if (typeof resolveSessionConversation !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSessionConversationResolution(
|
||||
resolveSessionConversation({
|
||||
kind: params.kind,
|
||||
rawId: params.rawId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSessionConversationResolution(params: {
|
||||
channel: string;
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
}): ResolvedSessionConversation | null {
|
||||
const rawId = params.rawId.trim();
|
||||
if (!rawId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messaging = getMessagingAdapter(params.channel);
|
||||
const pluginResolved = normalizeSessionConversationResolution(
|
||||
messaging?.resolveSessionConversation?.({
|
||||
kind: params.kind,
|
||||
rawId,
|
||||
}),
|
||||
);
|
||||
const resolved =
|
||||
pluginResolved ??
|
||||
resolveBundledSessionConversationFallback({
|
||||
channel: params.channel,
|
||||
kind: params.kind,
|
||||
rawId,
|
||||
}) ??
|
||||
buildGenericConversationResolution(rawId);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentConversationCandidates = dedupeConversationIds(
|
||||
pluginResolved?.hasExplicitParentConversationCandidates
|
||||
? resolved.parentConversationCandidates
|
||||
: (messaging?.resolveParentConversationCandidates?.({
|
||||
kind: params.kind,
|
||||
rawId,
|
||||
}) ?? resolved.parentConversationCandidates),
|
||||
);
|
||||
const baseConversationId =
|
||||
parentConversationCandidates.at(-1) ?? resolved.baseConversationId ?? resolved.id;
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
baseConversationId,
|
||||
parentConversationCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionConversation(params: {
|
||||
channel: string;
|
||||
kind: "group" | "channel";
|
||||
rawId: string;
|
||||
}): ResolvedSessionConversation | null {
|
||||
return resolveSessionConversationResolution(params);
|
||||
}
|
||||
|
||||
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),
|
||||
baseConversationId: resolved.baseConversationId,
|
||||
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;
|
||||
}
|
||||
|
|
@ -397,6 +397,33 @@ export type ChannelThreadingToolContext = {
|
|||
/** Channel-owned messaging helpers for target parsing, routing, and payload shaping. */
|
||||
export type ChannelMessagingAdapter = {
|
||||
normalizeTarget?: (raw: string) => string | undefined;
|
||||
/**
|
||||
* 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 `baseConversationId` and `parentConversationCandidates` here when
|
||||
* you can so parsing and inheritance stay in one place.
|
||||
* `parentConversationCandidates`, when present, should be ordered from the
|
||||
* narrowest parent to the broadest/base conversation.
|
||||
* Bundled plugins that need the same grammar before runtime bootstrap can
|
||||
* mirror this contract through a top-level `session-key-api.ts` surface.
|
||||
*/
|
||||
resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => {
|
||||
id: string;
|
||||
threadId?: string | null;
|
||||
baseConversationId?: string | null;
|
||||
parentConversationCandidates?: string[];
|
||||
} | null;
|
||||
/**
|
||||
* 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";
|
||||
rawId: string;
|
||||
}) => string[] | null;
|
||||
resolveSessionTarget?: (params: {
|
||||
kind: "group" | "channel";
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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): {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { loadBundledPluginPublicSurfaceSync } from "./bundled-plugin-public-surface.js";
|
||||
import { createTestRegistry } from "./channel-plugins.js";
|
||||
|
||||
type SessionConversationSurface = {
|
||||
resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => {
|
||||
id: string;
|
||||
threadId?: string | null;
|
||||
baseConversationId?: string | null;
|
||||
parentConversationCandidates?: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
function loadSessionConversationSurface(pluginId: string) {
|
||||
return loadBundledPluginPublicSurfaceSync<SessionConversationSurface>({
|
||||
pluginId,
|
||||
artifactBasename: "session-key-api.js",
|
||||
}).resolveSessionConversation;
|
||||
}
|
||||
|
||||
const resolveTelegramSessionConversation = loadSessionConversationSurface("telegram");
|
||||
const resolveFeishuSessionConversation = loadSessionConversationSurface("feishu");
|
||||
|
||||
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: resolveTelegramSessionConversation,
|
||||
},
|
||||
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: resolveFeishuSessionConversation,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
Loading…
Reference in New Issue