refactor: enforce plugin-owned channel boundaries

This commit is contained in:
Peter Steinberger
2026-04-18 22:45:03 +01:00
parent e89e214516
commit 8bfa06e992
26 changed files with 546 additions and 388 deletions

View File

@@ -1264,6 +1264,7 @@ jobs:
run_check "plugin-extension-boundary" pnpm run lint:plugins:no-extension-imports
run_check "lint:tmp:no-random-messaging" pnpm run lint:tmp:no-random-messaging
run_check "lint:tmp:channel-agnostic-boundaries" pnpm run lint:tmp:channel-agnostic-boundaries
run_check "lint:tmp:tsgo-core-boundary" pnpm run lint:tmp:tsgo-core-boundary
run_check "lint:tmp:no-raw-channel-fetch" pnpm run lint:tmp:no-raw-channel-fetch
run_check "lint:agent:ingress-owner" pnpm run lint:agent:ingress-owner
run_check "lint:plugins:no-register-http-handler" pnpm run lint:plugins:no-register-http-handler

View File

@@ -130,10 +130,11 @@
- TypeScript checks are split by architecture boundary:
- `pnpm tsgo` / `pnpm tsgo:core`: core production roots (`src/`, `ui/`, `packages/`; no `extensions/` include roots).
- `pnpm tsgo:core:test`: core colocated tests.
- `pnpm tsgo:core:test:agents` / `pnpm tsgo:core:test:non-agents`: core test slices for isolating the heavy agent graph while debugging type-check performance.
- `pnpm tsgo:extensions`: bundled extension production graph.
- `pnpm tsgo:extensions:test`: bundled extension colocated tests.
- `pnpm tsgo:all`: every TypeScript graph above; this is what `pnpm check` runs.
- `pnpm tsgo:profile [core-test|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`; if a core graph lists `extensions/<id>/`, treat that as boundary/perf debt from imports (usually plugin-sdk facades or shared helpers pulling extension sources).
- `pnpm tsgo:profile [core-test|core-test-agents|core-test-non-agents|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`; if a core graph lists `extensions/<id>/`, treat that as boundary/perf debt from imports (usually plugin-sdk facades or shared helpers pulling extension sources).
- Narrow aliases remain for local loops: `pnpm tsgo:test:src`, `pnpm tsgo:test:ui`, `pnpm tsgo:test:packages`.
- Do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes for repo type checking. Use `tsgo` graphs. `tsc` is allowed only when emitting declaration/package-boundary compatibility artifacts that `tsgo` does not replace.
- Boundary rule: core must not know extension implementation details. Extensions hook into core through manifests, registries, capabilities, and public `openclaw/plugin-sdk/*` contracts. If you find core production code naming a specific extension, or a core test that is really testing extension-owned behavior, call it out and prefer moving coverage/logic to the owning extension or a generic contract test.

View File

@@ -288,6 +288,13 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
messaging: {
defaultMarkdownTableMode: "off",
normalizeTarget: normalizeMattermostMessagingTarget,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
return parent && parent !== child
? { to: `channel:${parent}`, threadId: child }
: { to: normalizeMattermostMessagingTarget(`channel:${child}`) };
},
resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,

View File

@@ -333,6 +333,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
return parent && parent !== child
? { to: `channel:${parent}`, threadId: child }
: { to: normalizeSlackMessagingTarget(`channel:${child}`) };
},
resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session-contract.js";
import {
canonicalizeLegacySessionKey,
deriveLegacySessionChatType,
isLegacyGroupSessionKey,
} from "./session-contract.js";
describe("whatsapp legacy session contract", () => {
it("canonicalizes legacy WhatsApp group keys to channel-qualified agent keys", () => {
@@ -16,6 +20,12 @@ describe("whatsapp legacy session contract", () => {
it("does not claim generic non-WhatsApp group keys", () => {
expect(isLegacyGroupSessionKey("group:abc")).toBe(false);
expect(deriveLegacySessionChatType("group:abc")).toBeUndefined();
expect(canonicalizeLegacySessionKey({ key: "group:abc", agentId: "main" })).toBeNull();
});
it("derives chat type for legacy WhatsApp group keys", () => {
expect(deriveLegacySessionChatType("123@g.us")).toBe("group");
expect(deriveLegacySessionChatType("whatsapp:123@g.us")).toBe("group");
});
});

View File

@@ -28,6 +28,10 @@ export function isLegacyGroupSessionKey(key: string): boolean {
return extractLegacyWhatsAppGroupId(key) !== null;
}
export function deriveLegacySessionChatType(key: string): "group" | undefined {
return isLegacyGroupSessionKey(key) ? "group" : undefined;
}
export function canonicalizeLegacySessionKey(params: {
key: string;
agentId: string;

View File

@@ -32,7 +32,11 @@ import {
unsupportedSecretRefSurfacePatterns,
} from "./security-contract.js";
import { applyWhatsAppSecurityConfigFixes } from "./security-fix.js";
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session-contract.js";
import {
canonicalizeLegacySessionKey,
deriveLegacySessionChatType,
isLegacyGroupSessionKey,
} from "./session-contract.js";
export const WHATSAPP_CHANNEL = "whatsapp" as const;
@@ -245,6 +249,7 @@ export function createWhatsAppPluginBase(params: {
config: base.config!,
messaging: {
defaultMarkdownTableMode: "bullets",
deriveLegacySessionChatType,
resolveLegacyGroupSessionKey,
isLegacyGroupSessionKey,
canonicalizeLegacySessionKey: (params) =>

View File

@@ -1321,6 +1321,7 @@
"lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs",
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",
"lint:web-search-provider-boundaries": "node scripts/check-web-search-provider-boundaries.mjs",
@@ -1473,6 +1474,8 @@
"tsgo:all": "pnpm tsgo:core && pnpm tsgo:core:test && pnpm tsgo:extensions && pnpm tsgo:extensions:test",
"tsgo:core": "node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core.tsbuildinfo",
"tsgo:core:test": "node scripts/run-tsgo.mjs -p tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo",
"tsgo:core:test:agents": "node scripts/run-tsgo.mjs -p tsconfig.core.test.agents.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test-agents.tsbuildinfo",
"tsgo:core:test:non-agents": "node scripts/run-tsgo.mjs -p tsconfig.core.test.non-agents.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test-non-agents.tsbuildinfo",
"tsgo:extensions": "node scripts/run-tsgo.mjs -p tsconfig.extensions.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions.tsbuildinfo",
"tsgo:extensions:test": "node scripts/run-tsgo.mjs -p tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo",
"tsgo:prod": "pnpm tsgo:core && pnpm tsgo:extensions",

View File

@@ -23,6 +23,9 @@ const acpCoreProtectedSources = [
const channelCoreProtectedSources = [
path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"),
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
path.join(repoRoot, "src", "sessions", "send-policy.ts"),
path.join(repoRoot, "src", "sessions", "session-chat-type-shared.ts"),
path.join(repoRoot, "src", "utils", "delivery-context.ts"),
];
const acpUserFacingTextSources = [
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
@@ -41,11 +44,18 @@ const channelIds = [
"imessage",
"irc",
"line",
"mattermost",
"matrix",
"msteams",
"nextcloud-talk",
"nostr",
"qqbot",
"signal",
"slack",
"synology-chat",
"telegram",
"tlon",
"twitch",
"web",
"whatsapp",
"zalo",

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
const tsgoPath = path.join(repoRoot, "node_modules", ".bin", "tsgo");
const coreGraphs = [
{ name: "core", config: "tsconfig.core.json" },
{ name: "core-test", config: "tsconfig.core.test.json" },
{ name: "core-test-agents", config: "tsconfig.core.test.agents.json" },
{ name: "core-test-non-agents", config: "tsconfig.core.test.non-agents.json" },
];
function normalizeFilePath(filePath) {
const normalized = filePath.trim().replaceAll("\\", "/");
const normalizedRoot = repoRoot.replaceAll("\\", "/");
if (normalized.startsWith(`${normalizedRoot}/`)) {
return normalized.slice(normalizedRoot.length + 1);
}
return normalized;
}
function listGraphFiles(graph) {
const result = spawnSync(tsgoPath, ["-p", graph.config, "--pretty", "false", "--listFilesOnly"], {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
shell: process.platform === "win32",
});
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(`${graph.name} file listing failed with exit code ${result.status}\n${output}`);
}
return (result.stdout ?? "").split(/\r?\n/u).map(normalizeFilePath).filter(Boolean);
}
const violations = [];
for (const graph of coreGraphs) {
const extensionFiles = listGraphFiles(graph).filter((file) => file.startsWith("extensions/"));
for (const file of extensionFiles) {
violations.push(`${graph.name}: ${file}`);
}
}
if (violations.length > 0) {
console.error("Core tsgo graphs must not include bundled extension files:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error(
"Move extension-owned behavior behind plugin SDK contracts, public artifacts, or extension-local tests.",
);
process.exit(1);
}

View File

@@ -22,6 +22,14 @@ const GRAPH_DEFINITIONS = {
config: "tsconfig.core.test.json",
description: "core colocated test graph",
},
"core-test-agents": {
config: "tsconfig.core.test.agents.json",
description: "core agent colocated test graph",
},
"core-test-non-agents": {
config: "tsconfig.core.test.non-agents.json",
description: "core non-agent colocated test graph",
},
extensions: {
config: "tsconfig.extensions.json",
description: "bundled extension production graph",

View File

@@ -305,6 +305,15 @@ function expectAgentGatewayCall(overrides: AgentCallParams): void {
expect(agentCall?.params?.threadId).toBe(overrides.threadId);
}
function resolveMatrixRoomTargetForTest(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const normalized = trimmed.replace(/^(?:matrix:)?(?:channel:|room:)/iu, "").trim();
return normalized || undefined;
}
function enableMatrixAcpThreadBindings(): void {
replaceSpawnConfig({
...hoisted.state.cfg,
@@ -318,6 +327,42 @@ function enableMatrixAcpThreadBindings(): void {
},
},
});
const matrixPlugin = {
messaging: {
resolveDeliveryTarget: ({
conversationId,
parentConversationId,
}: {
conversationId: string;
parentConversationId?: string;
}) => {
const parent = resolveMatrixRoomTargetForTest(parentConversationId);
const child = conversationId.trim();
return parent ? { to: `room:${parent}`, threadId: child } : { to: `room:${child}` };
},
resolveInboundConversation: ({
to,
threadId,
}: {
to?: string;
threadId?: string | number;
}) => {
const parent = resolveMatrixRoomTargetForTest(to);
const thread = threadId != null ? String(threadId).trim() : "";
return thread && parent
? { conversationId: thread, parentConversationId: parent }
: parent
? { conversationId: parent }
: undefined;
},
},
};
hoisted.getChannelPluginMock.mockImplementation((channelId: string) =>
channelId === "matrix" ? matrixPlugin : undefined,
);
hoisted.getLoadedChannelPluginMock.mockImplementation((channelId: string) =>
channelId === "matrix" ? matrixPlugin : undefined,
);
registerSessionBindingAdapter({
channel: "matrix",
accountId: "default",
@@ -342,6 +387,28 @@ function enableLineCurrentConversationBindings(): void {
},
},
});
const linePlugin = {
messaging: {
resolveInboundConversation: ({
conversationId,
to,
}: {
conversationId?: string;
to?: string;
}) => {
const source = (conversationId ?? to ?? "").trim();
const normalized =
source.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1]?.trim() ?? source;
return normalized ? { conversationId: normalized } : undefined;
},
},
};
hoisted.getChannelPluginMock.mockImplementation((channelId: string) =>
channelId === "line" ? linePlugin : undefined,
);
hoisted.getLoadedChannelPluginMock.mockImplementation((channelId: string) =>
channelId === "line" ? linePlugin : undefined,
);
registerSessionBindingAdapter({
channel: "line",
accountId: "default",
@@ -369,10 +436,45 @@ function enableTelegramCurrentConversationBindings(): void {
},
},
});
const telegramPlugin = {
messaging: {
resolveInboundConversation: ({
conversationId,
to,
threadId,
}: {
conversationId?: string;
to?: string;
threadId?: string | number;
}) => {
const source = (conversationId ?? to ?? "").trim();
const normalized = source.replace(/^telegram:(?:group:|channel:|direct:)?/i, "");
const explicitThreadId = threadId == null ? "" : String(threadId).trim();
if (/^-?\d+$/.test(normalized) && /^\d+$/.test(explicitThreadId)) {
return { conversationId: `${normalized}:topic:${explicitThreadId}` };
}
const topicMatch = /^(-?\d+):topic:(\d+)$/i.exec(normalized);
if (topicMatch?.[1] && topicMatch[2]) {
return { conversationId: `${topicMatch[1]}:topic:${topicMatch[2]}` };
}
return /^-?\d+$/.test(normalized) ? { conversationId: normalized } : undefined;
},
},
};
hoisted.getChannelPluginMock.mockImplementation((channelId: string) =>
channelId === "telegram" ? telegramPlugin : undefined,
);
hoisted.getLoadedChannelPluginMock.mockImplementation((channelId: string) =>
channelId === "telegram" ? telegramPlugin : undefined,
);
registerSessionBindingAdapter({
channel: "telegram",
accountId: "default",
capabilities: createSessionBindingCapabilities(),
capabilities: {
bindSupported: true,
unbindSupported: true,
placements: ["current"] satisfies SessionBindingPlacement[],
},
bind: async (input) => await hoisted.sessionBindingBindMock(input),
listBySession: (targetSessionKey) => hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref),

View File

@@ -225,65 +225,11 @@ type AcpSpawnBootstrapDeliveryPlan = {
};
function resolvePlacementWithoutChannelPlugin(params: {
channel: string;
capabilities: { placements: Array<"current" | "child"> };
}): "current" | "child" {
switch (params.channel) {
case "discord":
case "matrix":
return params.capabilities.placements.includes("child") ? "child" : "current";
case "line":
case "telegram":
return "current";
}
return params.capabilities.placements.includes("child") ? "child" : "current";
}
function normalizeLineConversationIdFallback(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value) ?? "";
if (!trimmed) {
return undefined;
}
const normalized = trimmed.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1]?.trim() ?? trimmed;
return normalized ? normalized : undefined;
}
function normalizeTelegramConversationIdFallback(params: {
to?: string;
threadId?: string | number;
groupId?: string;
}): string | undefined {
const explicitGroupId = normalizeOptionalString(params.groupId);
const explicitThreadId =
params.threadId != null ? normalizeOptionalString(String(params.threadId)) : undefined;
if (
explicitGroupId &&
explicitThreadId &&
/^-?\d+$/.test(explicitGroupId) &&
/^\d+$/.test(explicitThreadId)
) {
return `${explicitGroupId}:topic:${explicitThreadId}`;
}
const trimmed = normalizeOptionalString(params.to) ?? "";
if (!trimmed) {
return undefined;
}
const normalized = trimmed.replace(/^telegram:(?:group:|channel:|direct:)?/i, "");
const topicMatch = /^(-?\d+):topic:(\d+)$/i.exec(normalized);
if (topicMatch?.[1] && topicMatch[2]) {
return `${topicMatch[1]}:topic:${topicMatch[2]}`;
}
return /^-?\d+$/.test(normalized) ? normalized : undefined;
}
const threadBindingFallbackConversationResolvers = {
line: (params: { to?: string; groupId?: string }) =>
normalizeLineConversationIdFallback(params.groupId ?? params.to),
telegram: (params: { to?: string; threadId?: string | number; groupId?: string }) =>
normalizeTelegramConversationIdFallback(params),
} as const;
function resolvePluginConversationRefForThreadBinding(params: {
channelId: string;
to?: string;
@@ -559,7 +505,6 @@ function resolveConversationRefForThreadBinding(params: {
}): { conversationId: string; parentConversationId?: string } | null {
const channel = normalizeOptionalLowercaseString(params.channel);
const normalizedChannelId = channel ? normalizeChannelId(channel) : null;
const channelKey = normalizedChannelId ?? channel ?? null;
const pluginResolvedConversation = normalizedChannelId
? resolvePluginConversationRefForThreadBinding({
channelId: normalizedChannelId,
@@ -571,15 +516,6 @@ function resolveConversationRefForThreadBinding(params: {
if (pluginResolvedConversation) {
return pluginResolvedConversation;
}
const compatibilityConversationId =
channelKey && Object.hasOwn(threadBindingFallbackConversationResolvers, channelKey)
? threadBindingFallbackConversationResolvers[
channelKey as keyof typeof threadBindingFallbackConversationResolvers
](params)
: undefined;
if (compatibilityConversationId) {
return normalizeConversationTargetRef({ conversationId: compatibilityConversationId });
}
const parentConversationId = resolveConversationIdFromTargets({
targets: [params.to],
});
@@ -682,7 +618,6 @@ function prepareAcpThreadBinding(params: {
const placementToUse =
pluginPlacement ??
resolvePlacementWithoutChannelPlugin({
channel: policy.channel,
capabilities,
});
if (!capabilities.bindSupported || !capabilities.placements.includes(placementToUse)) {

View File

@@ -99,19 +99,19 @@ import {
} from "./plugin-install.js";
const baseEntry: ChannelPluginCatalogEntry = {
id: "zalo",
pluginId: "zalo",
id: "bundled-chat",
pluginId: "bundled-chat",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
docsPath: "/channels/zalo",
docsLabel: "zalo",
id: "bundled-chat",
label: "Bundled Chat",
selectionLabel: "Bundled Chat",
docsPath: "/channels/bundled-chat",
docsLabel: "bundled chat",
blurb: "Test",
},
install: {
npmSpec: "@openclaw/zalo",
localPath: bundledPluginRoot("zalo"),
npmSpec: "@openclaw/bundled-chat",
localPath: bundledPluginRoot("bundled-chat"),
},
};
@@ -132,7 +132,10 @@ beforeEach(() => {
function mockRepoLocalPathExists() {
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`);
return (
raw.endsWith(`${path.sep}.git`) ||
raw.endsWith(`${path.sep}extensions${path.sep}bundled-chat`)
);
});
}
@@ -157,7 +160,7 @@ async function runInitialValueForChannel(channel: "dev" | "beta") {
function expectPluginLoadedFromLocalPath(
result: Awaited<ReturnType<typeof ensureChannelSetupPluginInstalled>>,
) {
const expectedPath = path.resolve(process.cwd(), bundledPluginRoot("zalo"));
const expectedPath = path.resolve(process.cwd(), bundledPluginRoot("bundled-chat"));
expect(result.installed).toBe(true);
expect(result.cfg.plugins?.load?.paths).toContain(expectedPath);
}
@@ -172,8 +175,8 @@ describe("ensureChannelSetupPluginInstalled", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "zalo",
targetDir: "/tmp/zalo",
pluginId: "bundled-chat",
targetDir: "/tmp/bundled-chat",
extensions: [],
});
@@ -185,13 +188,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
});
expect(result.installed).toBe(true);
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toContain("zalo");
expect(result.cfg.plugins?.installs?.zalo?.source).toBe("npm");
expect(result.cfg.plugins?.installs?.zalo?.spec).toBe("@openclaw/zalo");
expect(result.cfg.plugins?.installs?.zalo?.installPath).toBe("/tmp/zalo");
expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toContain("bundled-chat");
expect(result.cfg.plugins?.installs?.["bundled-chat"]?.source).toBe("npm");
expect(result.cfg.plugins?.installs?.["bundled-chat"]?.spec).toBe("@openclaw/bundled-chat");
expect(result.cfg.plugins?.installs?.["bundled-chat"]?.installPath).toBe("/tmp/bundled-chat");
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@openclaw/zalo" }),
expect.objectContaining({ spec: "@openclaw/bundled-chat" }),
);
});
@@ -211,7 +214,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
});
expectPluginLoadedFromLocalPath(result);
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true);
});
it("uses the catalog plugin id for local-path installs", async () => {
@@ -226,16 +229,16 @@ describe("ensureChannelSetupPluginInstalled", () => {
cfg,
entry: {
...baseEntry,
id: "teams",
pluginId: "@openclaw/msteams-plugin",
id: "external-chat",
pluginId: "@vendor/external-chat-plugin",
},
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(result.pluginId).toBe("@openclaw/msteams-plugin");
expect(result.cfg.plugins?.entries?.["@openclaw/msteams-plugin"]?.enabled).toBe(true);
expect(result.pluginId).toBe("@vendor/external-chat-plugin");
expect(result.cfg.plugins?.entries?.["@vendor/external-chat-plugin"]?.enabled).toBe(true);
});
it("defaults to local on dev channel when local path exists", async () => {
@@ -255,11 +258,11 @@ describe("ensureChannelSetupPluginInstalled", () => {
resolveBundledPluginSources.mockReturnValue(
new Map([
[
"zalo",
"bundled-chat",
{
pluginId: "zalo",
localPath: bundledPluginRootAt("/opt/openclaw", "zalo"),
npmSpec: "@openclaw/zalo",
pluginId: "bundled-chat",
localPath: bundledPluginRootAt("/opt/openclaw", "bundled-chat"),
npmSpec: "@openclaw/bundled-chat",
},
],
]),
@@ -278,7 +281,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
options: expect.arrayContaining([
expect.objectContaining({
value: "local",
hint: bundledPluginRootAt("/opt/openclaw", "zalo"),
hint: bundledPluginRootAt("/opt/openclaw", "bundled-chat"),
}),
]),
}),
@@ -294,11 +297,11 @@ describe("ensureChannelSetupPluginInstalled", () => {
resolveBundledPluginSources.mockReturnValue(
new Map([
[
"whatsapp",
"bundled-chat",
{
pluginId: "whatsapp",
localPath: bundledPluginRootAt("/opt/openclaw", "whatsapp"),
npmSpec: "@openclaw/whatsapp",
pluginId: "bundled-chat",
localPath: bundledPluginRootAt("/opt/openclaw", "bundled-chat"),
npmSpec: "@openclaw/bundled-chat",
},
],
]),
@@ -307,16 +310,16 @@ describe("ensureChannelSetupPluginInstalled", () => {
await ensureChannelSetupPluginInstalled({
cfg,
entry: {
id: "whatsapp",
id: "bundled-chat",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
id: "bundled-chat",
label: "Bundled Chat",
selectionLabel: "Bundled Chat",
docsPath: "/channels/bundled-chat",
blurb: "Test",
},
install: {
npmSpec: "@vendor/whatsapp-fork",
npmSpec: "@vendor/bundled-chat-fork",
},
},
prompter,
@@ -329,7 +332,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
options: [
expect.objectContaining({
value: "npm",
label: "Download from npm (@vendor/whatsapp-fork)",
label: "Download from npm (@vendor/bundled-chat-fork)",
}),
expect.objectContaining({
value: "skip",
@@ -397,13 +400,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {},
channels: { telegram: { enabled: true } } as never,
channels: { "external-chat": { enabled: true } } as never,
};
const autoEnabledConfig = {
...cfg,
plugins: {
entries: {
telegram: { enabled: true },
"external-chat": { enabled: true },
},
},
} as OpenClawConfig;
@@ -435,12 +438,12 @@ describe("ensureChannelSetupPluginInstalled", () => {
it("scopes channel reloads when setup starts from an empty registry", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" });
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@vendor/external-chat-plugin" });
reloadChannelSetupPluginRegistryForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -451,11 +454,11 @@ describe("ensureChannelSetupPluginInstalled", () => {
autoEnabledReasons: {},
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["@openclaw/telegram-plugin"],
onlyPluginIds: ["@vendor/external-chat-plugin"],
includeSetupOnlyChannelPlugins: true,
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", {
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", {
workspaceDir: "/tmp/openclaw-workspace",
});
});
@@ -478,7 +481,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
reloadChannelSetupPluginRegistryForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -492,7 +495,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
it("scopes channel reloads when the global registry is populated but the pinned channel registry is empty", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" });
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@vendor/external-chat-plugin" });
const activeRegistry = createEmptyPluginRegistry();
activeRegistry.plugins.push(
createPluginRecord({
@@ -510,7 +513,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
reloadChannelSetupPluginRegistryForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
} finally {
@@ -521,7 +524,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
expect.objectContaining({
activationSourceConfig: cfg,
autoEnabledReasons: {},
onlyPluginIds: ["@openclaw/telegram-plugin"],
onlyPluginIds: ["@vendor/external-chat-plugin"],
}),
);
});
@@ -529,12 +532,12 @@ describe("ensureChannelSetupPluginInstalled", () => {
it("can load a channel-scoped snapshot without activating the global registry", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" });
getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@vendor/external-chat-plugin" });
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -545,12 +548,12 @@ describe("ensureChannelSetupPluginInstalled", () => {
autoEnabledReasons: {},
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["@openclaw/telegram-plugin"],
onlyPluginIds: ["@vendor/external-chat-plugin"],
includeSetupOnlyChannelPlugins: true,
activate: false,
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", {
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", {
workspaceDir: "/tmp/openclaw-workspace",
});
});
@@ -559,25 +562,25 @@ describe("ensureChannelSetupPluginInstalled", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry
.mockReturnValueOnce({ pluginId: "evil-telegram-shadow", origin: "workspace" })
.mockReturnValueOnce({ pluginId: "@openclaw/telegram-plugin", origin: "bundled" });
.mockReturnValueOnce({ pluginId: "evil-external-chat-shadow", origin: "workspace" })
.mockReturnValueOnce({ pluginId: "@vendor/external-chat-plugin", origin: "bundled" });
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["@openclaw/telegram-plugin"],
onlyPluginIds: ["@vendor/external-chat-plugin"],
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "telegram", {
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "external-chat", {
workspaceDir: "/tmp/openclaw-workspace",
});
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "telegram", {
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "external-chat", {
workspaceDir: "/tmp/openclaw-workspace",
excludeWorkspace: true,
});
@@ -588,24 +591,24 @@ describe("ensureChannelSetupPluginInstalled", () => {
const cfg: OpenClawConfig = {
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
allow: ["trusted-external-chat-shadow"],
},
};
getChannelPluginCatalogEntry.mockReturnValue({
pluginId: "trusted-telegram-shadow",
pluginId: "trusted-external-chat-shadow",
origin: "workspace",
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["trusted-telegram-shadow"],
onlyPluginIds: ["trusted-external-chat-shadow"],
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1);
@@ -618,7 +621,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -633,14 +636,14 @@ describe("ensureChannelSetupPluginInstalled", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "custom-telegram-plugin", channels: ["telegram"] }],
plugins: [{ id: "custom-external-chat-plugin", channels: ["external-chat"] }],
diagnostics: [],
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -651,7 +654,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
autoEnabledReasons: {},
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["custom-telegram-plugin"],
onlyPluginIds: ["custom-external-chat-plugin"],
includeSetupOnlyChannelPlugins: true,
activate: false,
}),
@@ -664,10 +667,10 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
id: "custom-external-chat-plugin",
channels: [],
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -677,13 +680,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
onlyPluginIds: ["custom-external-chat-plugin"],
}),
);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith(
@@ -699,10 +702,10 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
id: "custom-external-chat-plugin",
channels: [],
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -712,7 +715,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -730,11 +733,11 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "evil-telegram-shadow",
id: "evil-external-chat-shadow",
channels: [],
origin: "workspace",
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -744,13 +747,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["evil-telegram-shadow"],
onlyPluginIds: ["evil-external-chat-shadow"],
}),
);
expect(
@@ -769,11 +772,11 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
id: "custom-external-chat-plugin",
channels: [],
origin: "bundled",
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -783,13 +786,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
onlyPluginIds: ["custom-external-chat-plugin"],
}),
);
expect(
@@ -802,17 +805,17 @@ describe("ensureChannelSetupPluginInstalled", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {
deny: ["custom-telegram-plugin"],
deny: ["custom-external-chat-plugin"],
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
id: "custom-external-chat-plugin",
channels: [],
origin: "bundled",
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -822,13 +825,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
onlyPluginIds: ["custom-external-chat-plugin"],
}),
);
expect(
@@ -842,20 +845,20 @@ describe("ensureChannelSetupPluginInstalled", () => {
const cfg: OpenClawConfig = {
plugins: {
enabled: true,
allow: ["evil-telegram-shadow"],
allow: ["evil-external-chat-shadow"],
entries: {
"evil-telegram-shadow": { enabled: false },
"evil-external-chat-shadow": { enabled: false },
},
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "evil-telegram-shadow",
id: "evil-external-chat-shadow",
channels: [],
origin: "workspace",
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -865,13 +868,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["evil-telegram-shadow"],
onlyPluginIds: ["evil-external-chat-shadow"],
}),
);
expect(
@@ -885,18 +888,18 @@ describe("ensureChannelSetupPluginInstalled", () => {
const cfg: OpenClawConfig = {
plugins: {
entries: {
"custom-telegram-plugin": { enabled: false },
"custom-external-chat-plugin": { enabled: false },
},
},
};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-plugin",
id: "custom-external-chat-plugin",
channels: [],
origin: "bundled",
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -906,13 +909,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-plugin"],
onlyPluginIds: ["custom-external-chat-plugin"],
}),
);
expect(
@@ -927,11 +930,11 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "custom-telegram-global",
id: "custom-external-chat-global",
channels: [],
origin: "global",
activation: {
onChannels: ["telegram"],
onChannels: ["external-chat"],
},
},
],
@@ -941,13 +944,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
channel: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: ["custom-telegram-global"],
onlyPluginIds: ["custom-external-chat-global"],
}),
);
expect(
@@ -963,8 +966,8 @@ describe("ensureChannelSetupPluginInstalled", () => {
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "msteams",
pluginId: "@openclaw/msteams-plugin",
channel: "external-chat",
pluginId: "@vendor/external-chat-plugin",
workspaceDir: "/tmp/openclaw-workspace",
});
@@ -975,7 +978,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
autoEnabledReasons: {},
workspaceDir: "/tmp/openclaw-workspace",
cache: false,
onlyPluginIds: ["@openclaw/msteams-plugin"],
onlyPluginIds: ["@vendor/external-chat-plugin"],
includeSetupOnlyChannelPlugins: true,
activate: false,
}),

View File

@@ -8,10 +8,10 @@ import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
} from "./channel-setup/plugin-install.js";
import { configMocks, offsetMocks } from "./channels.mock-harness.js";
import { configMocks, lifecycleMocks } from "./channels.mock-harness.js";
import {
createMSTeamsCatalogEntry,
createMSTeamsSetupPlugin,
createExternalChatCatalogEntry,
createExternalChatSetupPlugin,
} from "./channels.plugin-install.test-helpers.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
@@ -53,66 +53,66 @@ vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks);
const runtime = createTestRuntime();
function listConfiguredAccountIds(
channelConfig: { accounts?: Record<string, unknown>; botToken?: string } | undefined,
channelConfig: { accounts?: Record<string, unknown>; token?: string } | undefined,
): string[] {
const accountIds = Object.keys(channelConfig?.accounts ?? {});
if (accountIds.length > 0) {
return accountIds;
}
if (channelConfig?.botToken) {
if (channelConfig?.token) {
return [DEFAULT_ACCOUNT_ID];
}
return [];
}
function createTelegramAddTestPlugin(): ChannelPlugin {
const resolveTelegramAccount = (
function createLifecycleChatAddTestPlugin(): ChannelPlugin {
const resolveLifecycleChatAccount = (
cfg: Parameters<NonNullable<ChannelPlugin["config"]["resolveAccount"]>>[0],
accountId: string,
) => {
const telegram = cfg.channels?.telegram as
const lifecycleChat = cfg.channels?.["lifecycle-chat"] as
| {
botToken?: string;
token?: string;
enabled?: boolean;
accounts?: Record<string, { botToken?: string; enabled?: boolean }>;
accounts?: Record<string, { token?: string; enabled?: boolean }>;
}
| undefined;
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
const scoped = telegram?.accounts?.[resolvedAccountId];
const scoped = lifecycleChat?.accounts?.[resolvedAccountId];
return {
token: scoped?.botToken ?? telegram?.botToken ?? "",
token: scoped?.token ?? lifecycleChat?.token ?? "",
enabled:
typeof scoped?.enabled === "boolean"
? scoped.enabled
: typeof telegram?.enabled === "boolean"
? telegram.enabled
: typeof lifecycleChat?.enabled === "boolean"
? lifecycleChat.enabled
: true,
};
};
return {
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
id: "lifecycle-chat",
label: "Lifecycle Chat",
docsPath: "/channels/lifecycle-chat",
}),
config: {
listAccountIds: (cfg) =>
listConfiguredAccountIds(
cfg.channels?.telegram as
| { accounts?: Record<string, unknown>; botToken?: string }
cfg.channels?.["lifecycle-chat"] as
| { accounts?: Record<string, unknown>; token?: string }
| undefined,
),
resolveAccount: resolveTelegramAccount,
resolveAccount: resolveLifecycleChatAccount,
},
setup: {
resolveAccountId: ({ accountId }) => accountId || DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId, input }) => {
const telegram = (cfg.channels?.telegram as
const lifecycleChat = (cfg.channels?.["lifecycle-chat"] as
| {
enabled?: boolean;
botToken?: string;
accounts?: Record<string, { botToken?: string }>;
token?: string;
accounts?: Record<string, { token?: string }>;
}
| undefined) ?? { enabled: true };
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
@@ -121,10 +121,10 @@ function createTelegramAddTestPlugin(): ChannelPlugin {
...cfg,
channels: {
...cfg.channels,
telegram: {
...telegram,
"lifecycle-chat": {
...lifecycleChat,
enabled: true,
...(input.token ? { botToken: input.token } : {}),
...(input.token ? { token: input.token } : {}),
},
},
};
@@ -133,14 +133,14 @@ function createTelegramAddTestPlugin(): ChannelPlugin {
...cfg,
channels: {
...cfg.channels,
telegram: {
...telegram,
"lifecycle-chat": {
...lifecycleChat,
enabled: true,
accounts: {
...telegram.accounts,
...lifecycleChat.accounts,
[resolvedAccountId]: {
...telegram.accounts?.[resolvedAccountId],
...(input.token ? { botToken: input.token } : {}),
...lifecycleChat.accounts?.[resolvedAccountId],
...(input.token ? { token: input.token } : {}),
},
},
},
@@ -150,10 +150,10 @@ function createTelegramAddTestPlugin(): ChannelPlugin {
},
lifecycle: {
onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => {
const prevTelegram = resolveTelegramAccount(prevCfg, accountId) as { token?: string };
const nextTelegram = resolveTelegramAccount(nextCfg, accountId) as { token?: string };
if ((prevTelegram.token ?? "").trim() !== (nextTelegram.token ?? "").trim()) {
await offsetMocks.deleteTelegramUpdateOffset({ accountId });
const prev = resolveLifecycleChatAccount(prevCfg, accountId) as { token?: string };
const next = resolveLifecycleChatAccount(nextCfg, accountId) as { token?: string };
if ((prev.token ?? "").trim() !== (next.token ?? "").trim()) {
await lifecycleMocks.onAccountConfigChanged({ accountId });
}
},
},
@@ -164,17 +164,17 @@ function setMinimalChannelsAddRegistryForTests(): void {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: createTelegramAddTestPlugin(),
pluginId: "lifecycle-chat",
plugin: createLifecycleChatAddTestPlugin(),
source: "test",
},
]),
);
}
function registerMSTeamsSetupPlugin(pluginId = "@openclaw/msteams-plugin"): void {
function registerExternalChatSetupPlugin(pluginId = "@vendor/external-chat-plugin"): void {
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry([{ pluginId, plugin: createMSTeamsSetupPlugin(), source: "test" }]),
createTestRegistry([{ pluginId, plugin: createExternalChatSetupPlugin(), source: "test" }]),
);
}
@@ -235,7 +235,7 @@ describe("channelsAddCommand", () => {
.mockImplementation(async (params: { nextConfig: unknown }) => {
await configMocks.writeConfigFile(params.nextConfig);
});
offsetMocks.deleteTelegramUpdateOffset.mockClear();
lifecycleMocks.onAccountConfigChanged.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
@@ -255,54 +255,54 @@ describe("channelsAddCommand", () => {
setMinimalChannelsAddRegistryForTests();
});
it("clears telegram update offsets only when the token changes", async () => {
it("runs channel lifecycle hooks only when account config changes", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
telegram: { botToken: "old-token", enabled: true },
"lifecycle-chat": { token: "old-token", enabled: true },
},
},
});
await channelsAddCommand(
{ channel: "telegram", account: "default", token: "new-token" },
{ channel: "lifecycle-chat", account: "default", token: "new-token" },
runtime,
{ hasFlags: true },
);
expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledTimes(1);
expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledWith({ accountId: "default" });
expect(lifecycleMocks.onAccountConfigChanged).toHaveBeenCalledTimes(1);
expect(lifecycleMocks.onAccountConfigChanged).toHaveBeenCalledWith({ accountId: "default" });
offsetMocks.deleteTelegramUpdateOffset.mockClear();
lifecycleMocks.onAccountConfigChanged.mockClear();
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
telegram: { botToken: "same-token", enabled: true },
"lifecycle-chat": { token: "same-token", enabled: true },
},
},
});
await channelsAddCommand(
{ channel: "telegram", account: "default", token: "same-token" },
{ channel: "lifecycle-chat", account: "default", token: "same-token" },
runtime,
{ hasFlags: true },
);
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
expect(lifecycleMocks.onAccountConfigChanged).not.toHaveBeenCalled();
});
it("loads external channel setup snapshots for newly installed and existing plugins", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
setActivePluginRegistry(createTestRegistry());
const catalogEntry = createMSTeamsCatalogEntry();
const catalogEntry = createExternalChatCatalogEntry();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
registerMSTeamsSetupPlugin("msteams");
registerExternalChatSetupPlugin("external-chat");
await channelsAddCommand(
{
channel: "msteams",
channel: "external-chat",
account: "default",
token: "tenant-scoped",
},
@@ -317,7 +317,7 @@ describe("channelsAddCommand", () => {
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
msteams: expect.objectContaining({
"external-chat": expect.objectContaining({
enabled: true,
}),
},
@@ -333,7 +333,7 @@ describe("channelsAddCommand", () => {
await channelsAddCommand(
{
channel: "msteams",
channel: "external-chat",
account: "default",
token: "tenant-installed",
},
@@ -346,7 +346,7 @@ describe("channelsAddCommand", () => {
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
msteams: expect.objectContaining({
"external-chat": expect.objectContaining({
enabled: true,
}),
},
@@ -358,43 +358,43 @@ describe("channelsAddCommand", () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
setActivePluginRegistry(createTestRegistry());
const catalogEntry: ChannelPluginCatalogEntry = {
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
id: "external-chat",
pluginId: "@vendor/external-chat-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
id: "external-chat",
label: "External Chat",
selectionLabel: "External Chat",
docsPath: "/channels/external-chat",
blurb: "external chat channel",
},
install: {
npmSpec: "@openclaw/msteams",
npmSpec: "@vendor/external-chat",
},
};
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({
cfg,
installed: true,
pluginId: "@vendor/teams-runtime",
pluginId: "@vendor/external-chat-runtime",
}));
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry([
{
pluginId: "@vendor/teams-runtime",
pluginId: "@vendor/external-chat-runtime",
plugin: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
id: "external-chat",
label: "External Chat",
docsPath: "/channels/external-chat",
}),
setup: {
applyAccountConfig: vi.fn(({ cfg, input }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
"external-chat": {
enabled: true,
tenantId: input.token,
token: input.token,
},
},
})),
@@ -407,7 +407,7 @@ describe("channelsAddCommand", () => {
await channelsAddCommand(
{
channel: "msteams",
channel: "external-chat",
account: "default",
token: "tenant-scoped",
},
@@ -419,7 +419,7 @@ describe("channelsAddCommand", () => {
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
msteams: expect.objectContaining({
"external-chat": expect.objectContaining({
enabled: true,
}),
},

View File

@@ -27,6 +27,12 @@ export const offsetMocks: {
deleteTelegramUpdateOffset: vi.fn().mockResolvedValue(undefined) as unknown as MockFn,
};
export const lifecycleMocks: {
onAccountConfigChanged: MockFn;
} = {
onAccountConfigChanged: vi.fn().mockResolvedValue(undefined) as unknown as MockFn,
};
export const secretMocks = {
resolveCommandConfigWithSecrets: vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,

View File

@@ -14,38 +14,38 @@ export function createMockChannelSetupPluginInstallModule(
};
}
export function createMSTeamsCatalogEntry(): ChannelPluginCatalogEntry {
export function createExternalChatCatalogEntry(): ChannelPluginCatalogEntry {
return {
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
id: "external-chat",
pluginId: "@vendor/external-chat-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
id: "external-chat",
label: "External Chat",
selectionLabel: "External Chat",
docsPath: "/channels/external-chat",
blurb: "external chat channel",
},
install: {
npmSpec: "@openclaw/msteams",
npmSpec: "@vendor/external-chat",
},
};
}
export function createMSTeamsSetupPlugin(): ChannelPlugin {
export function createExternalChatSetupPlugin(): ChannelPlugin {
return {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
id: "external-chat",
label: "External Chat",
docsPath: "/channels/external-chat",
}),
setup: {
applyAccountConfig: vi.fn(({ cfg, input }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
"external-chat": {
enabled: true,
tenantId: input.token,
token: input.token,
},
},
})),
@@ -53,23 +53,23 @@ export function createMSTeamsSetupPlugin(): ChannelPlugin {
} as ChannelPlugin;
}
export function createMSTeamsDeletePlugin(): ChannelPlugin {
export function createExternalChatDeletePlugin(): ChannelPlugin {
return {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
id: "external-chat",
label: "External Chat",
docsPath: "/channels/external-chat",
}),
config: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
id: "external-chat",
label: "External Chat",
docsPath: "/channels/external-chat",
}).config,
deleteAccount: vi.fn(({ cfg }: { cfg: Record<string, unknown> }) => {
const channels = (cfg.channels as Record<string, unknown> | undefined) ?? {};
const nextChannels = { ...channels };
delete nextChannels.msteams;
delete nextChannels["external-chat"];
return {
...cfg,
channels: nextChannels as ChannelsConfig,

View File

@@ -8,8 +8,8 @@ import {
} from "./channel-setup/plugin-install.js";
import { configMocks } from "./channels.mock-harness.js";
import {
createMSTeamsCatalogEntry,
createMSTeamsDeletePlugin,
createExternalChatCatalogEntry,
createExternalChatDeletePlugin,
} from "./channels.plugin-install.test-helpers.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
@@ -86,22 +86,22 @@ describe("channelsRemoveCommand", () => {
...baseConfigSnapshot,
config: {
channels: {
msteams: {
"external-chat": {
enabled: true,
tenantId: "tenant-1",
token: "token-1",
},
},
},
});
const catalogEntry: ChannelPluginCatalogEntry = createMSTeamsCatalogEntry();
const catalogEntry: ChannelPluginCatalogEntry = createExternalChatCatalogEntry();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
const scopedPlugin = createMSTeamsDeletePlugin();
const scopedPlugin = createExternalChatDeletePlugin();
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
.mockReturnValueOnce(createTestRegistry())
.mockReturnValueOnce(
createTestRegistry([
{
pluginId: "@openclaw/msteams-plugin",
pluginId: "@vendor/external-chat-plugin",
plugin: scopedPlugin,
source: "test",
},
@@ -110,7 +110,7 @@ describe("channelsRemoveCommand", () => {
await channelsRemoveCommand(
{
channel: "msteams",
channel: "external-chat",
account: "default",
delete: true,
},
@@ -125,7 +125,7 @@ describe("channelsRemoveCommand", () => {
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.not.objectContaining({
channels: expect.objectContaining({
msteams: expect.anything(),
"external-chat": expect.anything(),
}),
}),
);

View File

@@ -190,8 +190,8 @@ describe("setupChannels workspace shadow exclusion", () => {
resolveDefaultAgentId.mockReturnValue("default");
listTrustedChannelPluginCatalogEntries.mockReturnValue([
{
id: "telegram",
pluginId: "@openclaw/telegram-plugin",
id: "external-chat",
pluginId: "@vendor/external-chat-plugin",
origin: "bundled",
},
]);
@@ -234,8 +234,8 @@ describe("setupChannels workspace shadow exclusion", () => {
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
pluginId: "@openclaw/telegram-plugin",
channel: "external-chat",
pluginId: "@vendor/external-chat-plugin",
workspaceDir: "/tmp/openclaw-workspace",
}),
);
@@ -243,14 +243,14 @@ describe("setupChannels workspace shadow exclusion", () => {
it("keeps trusted workspace overrides eligible during preload", async () => {
listTrustedChannelPluginCatalogEntries.mockReturnValue([
{ id: "telegram", pluginId: "trusted-telegram-shadow", origin: "workspace" },
{ id: "external-chat", pluginId: "trusted-external-chat-shadow", origin: "workspace" },
]);
await setupChannels(
{
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
allow: ["trusted-external-chat-shadow"],
},
} as never,
{} as never,
@@ -262,8 +262,8 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
pluginId: "trusted-telegram-shadow",
channel: "external-chat",
pluginId: "trusted-external-chat-shadow",
workspaceDir: "/tmp/openclaw-workspace",
}),
);
@@ -273,8 +273,8 @@ describe("setupChannels workspace shadow exclusion", () => {
resolveChannelSetupEntries.mockReturnValue({
entries: [
{
id: "telegram",
meta: makeMeta("telegram", "Telegram"),
id: "external-chat",
meta: makeMeta("external-chat", "External Chat"),
},
],
installedCatalogEntries: [],
@@ -413,52 +413,52 @@ describe("setupChannels workspace shadow exclusion", () => {
cfg: {
...cfg,
channels: {
telegram: { token: "secret" },
"external-chat": { token: "secret" },
},
} as never,
}));
const setupWizard = {
channel: "telegram",
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "telegram",
channel: "external-chat",
configured: false,
statusLines: [],
})),
configure,
} as ChannelSetupPlugin["setupWizard"];
const telegramPlugin = makeSetupPlugin({
id: "telegram",
label: "Telegram",
const externalChatPlugin = makeSetupPlugin({
id: "external-chat",
label: "External Chat",
setupWizard,
});
const installedCatalogEntry = makeCatalogEntry("telegram", "Telegram", {
pluginId: "telegram",
const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
pluginId: "external-chat",
origin: "bundled",
});
resolveChannelSetupEntries.mockReturnValue({
entries: [
{
id: "telegram",
meta: makeMeta("telegram", "Telegram"),
id: "external-chat",
meta: makeMeta("external-chat", "External Chat"),
},
],
installedCatalogEntries: [installedCatalogEntry],
installableCatalogEntries: [],
installedCatalogById: new Map([["telegram", installedCatalogEntry]]),
installedCatalogById: new Map([["external-chat", installedCatalogEntry]]),
installableCatalogById: new Map(),
});
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(
makePluginRegistry({
channels: [
{
pluginId: "telegram",
pluginId: "external-chat",
source: "bundled",
plugin: telegramPlugin,
plugin: externalChatPlugin,
},
],
}),
);
const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__");
const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const next = await setupChannels(
{} as never,
@@ -478,8 +478,8 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
pluginId: "telegram",
channel: "external-chat",
pluginId: "external-chat",
workspaceDir: "/tmp/openclaw-workspace",
}),
);
@@ -492,16 +492,16 @@ describe("setupChannels workspace shadow exclusion", () => {
);
expect(next).toEqual({
channels: {
telegram: { token: "secret" },
"external-chat": { token: "secret" },
},
});
});
it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => {
const setupWizard = {
channel: "telegram",
channel: "external-chat",
getStatus: vi.fn(async () => ({
channel: "telegram",
channel: "external-chat",
configured: true,
statusLines: [],
})),
@@ -510,8 +510,8 @@ describe("setupChannels workspace shadow exclusion", () => {
resolveChannelSetupEntries.mockReturnValue({
entries: [
{
id: "telegram",
meta: makeMeta("telegram", "Telegram"),
id: "external-chat",
meta: makeMeta("external-chat", "External Chat"),
},
],
installedCatalogEntries: [],
@@ -519,11 +519,11 @@ describe("setupChannels workspace shadow exclusion", () => {
installedCatalogById: new Map(),
installableCatalogById: new Map(),
});
const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__");
const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const note = vi.fn(async () => undefined);
const cfg = {
channels: {
telegram: { enabled: false, token: "secret" },
"external-chat": { enabled: false, token: "secret" },
},
};
@@ -544,13 +544,13 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
"telegram cannot be configured while disabled. Enable it before setup.",
"external-chat cannot be configured while disabled. Enable it before setup.",
"Channel setup",
);
expect(setupWizard.configure).not.toHaveBeenCalled();
expect(next).toEqual({
channels: {
telegram: { enabled: false, token: "secret" },
"external-chat": { enabled: false, token: "secret" },
},
});
});
@@ -559,8 +559,8 @@ describe("setupChannels workspace shadow exclusion", () => {
resolveChannelSetupEntries.mockReturnValue({
entries: [
{
id: "telegram",
meta: makeMeta("telegram", "Telegram"),
id: "external-chat",
meta: makeMeta("external-chat", "External Chat"),
},
],
installedCatalogEntries: [],
@@ -568,12 +568,12 @@ describe("setupChannels workspace shadow exclusion", () => {
installedCatalogById: new Map(),
installableCatalogById: new Map(),
});
const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__");
const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__");
const note = vi.fn(async () => undefined);
const cfg = {
plugins: { enabled: false },
channels: {
telegram: { enabled: true, token: "secret" },
"external-chat": { enabled: true, token: "secret" },
},
};
@@ -594,7 +594,7 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
"telegram cannot be configured while plugins disabled. Enable it before setup.",
"external-chat cannot be configured while plugins disabled. Enable it before setup.",
"Channel setup",
);
});

View File

@@ -77,14 +77,20 @@ describe("deriveSessionChatTypeFromKey", () => {
{ key: "agent:main:discord:channel:c1", expected: "channel" },
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
{ key: "telegram:dm:123456", expected: "direct" },
{ key: "discord:acc-1:guild-123:channel-456", expected: "channel" },
{ key: "12345-678@g.us", expected: "group" },
{ key: "agent:main:main", expected: "unknown" },
{ key: "agent:main", expected: "unknown" },
{ key: "", expected: "unknown" },
] as const)("derives chat type for %j => $expected", ({ key, expected }) => {
expect(deriveSessionChatTypeFromKey(key)).toBe(expected);
});
it("uses plugin-owned legacy chat-type hooks after generic token parsing", () => {
expect(
deriveSessionChatTypeFromKey("legacy-room:abc", [
(sessionKey) => (sessionKey.startsWith("legacy-room:") ? "channel" : undefined),
]),
).toBe("channel");
});
});
describe("thread session suffix parsing", () => {

View File

@@ -5,6 +5,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { deriveSessionChatType } from "./session-chat-type.js";
export type SessionSendPolicyDecision = "allow" | "deny";
@@ -63,17 +64,9 @@ function deriveChatTypeFromKey(key?: string): SessionChatType | undefined {
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
}
if (/^group:[^:]+$/u.test(normalizedKey)) {
return "group";
}
if (/^[0-9]+(?:-[0-9]+)*@g\.us$/u.test(normalizedKey)) {
return "group";
}
if (/^whatsapp:(?!.*:group:).+@g\.us$/u.test(normalizedKey)) {
return "group";
}
if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/u.test(normalizedKey)) {
return "channel";
const derived = deriveSessionChatType(normalizedKey);
if (derived !== "unknown") {
return derived;
}
return undefined;
}

View File

@@ -9,15 +9,6 @@ function deriveBuiltInLegacySessionChatType(
if (/^group:[^:]+$/.test(scopedSessionKey)) {
return "group";
}
if (/^[0-9]+(?:-[0-9]+)*@g\.us$/.test(scopedSessionKey)) {
return "group";
}
if (/^whatsapp:(?!.*:group:).+@g\.us$/.test(scopedSessionKey)) {
return "group";
}
if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scopedSessionKey)) {
return "channel";
}
return undefined;
}

View File

@@ -39,6 +39,31 @@ describe("delivery context helpers", () => {
},
},
},
{
pluginId: "thread-child-chat",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "thread-child-chat",
label: "Thread child chat",
}),
messaging: {
resolveDeliveryTarget: ({
conversationId,
parentConversationId,
}: {
conversationId: string;
parentConversationId?: string;
}) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
return parent && parent !== child
? { to: `channel:${parent}`, threadId: child }
: { to: `channel:${child}` };
},
},
},
},
]),
);
});
@@ -149,37 +174,15 @@ describe("delivery context helpers", () => {
});
});
it.each([
{
channel: "slack",
conversationId: "1710000000.000100",
parentConversationId: "C123",
expected: { to: "channel:C123", threadId: "1710000000.000100" },
},
{
channel: "telegram",
conversationId: "42",
parentConversationId: "-10099",
expected: { to: "channel:-10099", threadId: "42" },
},
{
channel: "mattermost",
conversationId: "msg-child-id",
parentConversationId: "channel-parent-id",
expected: { to: "channel:channel-parent-id", threadId: "msg-child-id" },
},
])(
"resolves parent-scoped thread delivery targets for $channel",
({ channel, conversationId, parentConversationId, expected }) => {
expect(
resolveConversationDeliveryTarget({
channel,
conversationId,
parentConversationId,
}),
).toEqual(expected);
},
);
it("resolves parent-scoped thread delivery targets through channel messaging hooks", () => {
expect(
resolveConversationDeliveryTarget({
channel: "thread-child-chat",
conversationId: "msg-child-id",
parentConversationId: "channel-parent-id",
}),
).toEqual({ to: "channel:channel-parent-id", threadId: "msg-child-id" });
});
it("derives delivery context from a session entry", () => {
expect(

View File

@@ -67,22 +67,6 @@ export function resolveConversationDeliveryTarget(params: {
: typeof params.parentConversationId === "string"
? normalizeOptionalString(params.parentConversationId)
: undefined;
const isThreadChild =
conversationId && parentConversationId && parentConversationId !== conversationId;
if (channel && isThreadChild) {
if (channel === "matrix") {
return {
to: `room:${parentConversationId}`,
threadId: conversationId,
};
}
if (channel === "slack" || channel === "mattermost" || channel === "telegram") {
return {
to: `channel:${parentConversationId}`,
threadId: conversationId,
};
}
}
const pluginTarget =
channel && conversationId
? getChannelPlugin(

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.test.json",
"include": [
"src/**/*.d.ts",
"src/agents/**/*.test.ts",
"src/agents/**/*.test.tsx",
"ui/**/*.d.ts",
"packages/**/*.d.ts"
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.core.test.json",
"exclude": [
"node_modules",
"dist",
"**/dist/**",
"src/agents/**/*.test.ts",
"src/agents/**/*.test.tsx"
]
}