From 878f2122e5216d6031f57123d9ddae766e987c90 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 01:57:49 -0400 Subject: [PATCH] Tests: fast-path Matrix ACP thread binding --- extensions/matrix/src/channel.ts | 20 +--- .../matrix/src/thread-binding-api.test.ts | 33 ++++++ extensions/matrix/src/thread-binding-api.ts | 23 ++++ extensions/matrix/thread-binding-api.ts | 4 + src/agents/acp-spawn.ts | 40 +++++-- .../plugins/thread-binding-api.test.ts | 101 ++++++++++++++++++ src/channels/plugins/thread-binding-api.ts | 82 ++++++++++++++ src/channels/plugins/types.core.ts | 4 + 8 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 extensions/matrix/src/thread-binding-api.test.ts create mode 100644 extensions/matrix/src/thread-binding-api.ts create mode 100644 extensions/matrix/thread-binding-api.ts create mode 100644 src/channels/plugins/thread-binding-api.test.ts create mode 100644 src/channels/plugins/thread-binding-api.ts diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 2e28612e0c7..060c5ea0e16 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -67,6 +67,7 @@ import { import { matrixSetupAdapter } from "./setup-core.js"; import { matrixSetupWizard } from "./setup-surface.js"; import { runMatrixStartupMaintenance } from "./startup-maintenance.js"; +import { resolveMatrixInboundConversation } from "./thread-binding-api.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); @@ -267,25 +268,6 @@ function resolveMatrixCommandConversation(params: { return parentConversationId ? { conversationId: parentConversationId } : null; } -function resolveMatrixInboundConversation(params: { - to?: string; - conversationId?: string; - threadId?: string | number; -}) { - const rawTarget = params.to?.trim() || params.conversationId?.trim() || ""; - const target = rawTarget ? resolveMatrixTargetIdentity(rawTarget) : null; - const parentConversationId = target?.kind === "room" ? target.id : undefined; - const threadId = - params.threadId != null ? normalizeOptionalString(String(params.threadId)) : undefined; - if (threadId) { - return { - conversationId: threadId, - ...(parentConversationId ? { parentConversationId } : {}), - }; - } - return parentConversationId ? { conversationId: parentConversationId } : null; -} - function resolveMatrixDeliveryTarget(params: { conversationId: string; parentConversationId?: string; diff --git a/extensions/matrix/src/thread-binding-api.test.ts b/extensions/matrix/src/thread-binding-api.test.ts new file mode 100644 index 00000000000..32353bdd6d9 --- /dev/null +++ b/extensions/matrix/src/thread-binding-api.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + defaultTopLevelPlacement, + resolveMatrixInboundConversation, +} from "./thread-binding-api.js"; + +describe("Matrix thread binding public API", () => { + it("advertises child placement for top-level Matrix rooms", () => { + expect(defaultTopLevelPlacement).toBe("child"); + }); + + it("resolves top-level room targets as parent conversations", () => { + expect(resolveMatrixInboundConversation({ to: "channel:!room:example" })).toEqual({ + conversationId: "!room:example", + }); + }); + + it("preserves canonical room casing when resolving thread conversations", () => { + expect( + resolveMatrixInboundConversation({ + to: "room:!Room:Example.org", + threadId: "$thread-root", + }), + ).toEqual({ + conversationId: "$thread-root", + parentConversationId: "!Room:Example.org", + }); + }); + + it("does not resolve user targets as thread binding rooms", () => { + expect(resolveMatrixInboundConversation({ to: "user:@user:example.org" })).toBeNull(); + }); +}); diff --git a/extensions/matrix/src/thread-binding-api.ts b/extensions/matrix/src/thread-binding-api.ts new file mode 100644 index 00000000000..3fa21d5ba1e --- /dev/null +++ b/extensions/matrix/src/thread-binding-api.ts @@ -0,0 +1,23 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js"; + +export const defaultTopLevelPlacement = "child" as const; + +export function resolveMatrixInboundConversation(params: { + to?: string; + conversationId?: string; + threadId?: string | number; +}) { + const rawTarget = params.to?.trim() || params.conversationId?.trim() || ""; + const target = rawTarget ? resolveMatrixTargetIdentity(rawTarget) : null; + const parentConversationId = target?.kind === "room" ? target.id : undefined; + const threadId = + params.threadId != null ? normalizeOptionalString(String(params.threadId)) : undefined; + if (threadId) { + return { + conversationId: threadId, + ...(parentConversationId ? { parentConversationId } : {}), + }; + } + return parentConversationId ? { conversationId: parentConversationId } : null; +} diff --git a/extensions/matrix/thread-binding-api.ts b/extensions/matrix/thread-binding-api.ts new file mode 100644 index 00000000000..baff7f8f8db --- /dev/null +++ b/extensions/matrix/thread-binding-api.ts @@ -0,0 +1,4 @@ +export { + defaultTopLevelPlacement, + resolveMatrixInboundConversation as resolveInboundConversation, +} from "./src/thread-binding-api.js"; diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index a5765626757..4368139d0c1 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -12,7 +12,15 @@ import { } from "../acp/runtime/session-identifiers.js"; import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js"; import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js"; -import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { + getChannelPlugin, + getLoadedChannelPlugin, + normalizeChannelId, +} from "../channels/plugins/index.js"; +import { + resolveBundledChannelThreadBindingDefaultPlacement, + resolveBundledChannelThreadBindingInboundConversation, +} from "../channels/plugins/thread-binding-api.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -277,15 +285,30 @@ function resolvePluginConversationRefForThreadBinding(params: { threadId?: string | number; groupId?: string; }): { conversationId: string; parentConversationId?: string } | null { - const resolvedConversation = getChannelPlugin( - params.channelId, - )?.messaging?.resolveInboundConversation?.({ + const resolverParams = { // Keep the live delivery target authoritative; conversationId is only a fallback hint. to: params.to, conversationId: params.groupId ?? params.to, threadId: params.threadId, isGroup: true, - }); + }; + const loadedPluginConversation = getLoadedChannelPlugin( + params.channelId, + )?.messaging?.resolveInboundConversation?.(resolverParams); + const bundledApiConversation = + loadedPluginConversation === undefined + ? resolveBundledChannelThreadBindingInboundConversation({ + channelId: params.channelId, + ...resolverParams, + }) + : undefined; + const resolvedConversation = + loadedPluginConversation ?? + (bundledApiConversation !== undefined + ? bundledApiConversation + : getChannelPlugin(params.channelId)?.messaging?.resolveInboundConversation?.( + resolverParams, + )); const conversationId = normalizeOptionalString(resolvedConversation?.conversationId); if (!conversationId) { return null; @@ -644,8 +667,13 @@ function prepareAcpThreadBinding(params: { error: `Thread bindings are unavailable for ${policy.channel}.`, }; } - const pluginPlacement = getChannelPlugin(policy.channel)?.conversationBindings + const loadedPluginPlacement = getLoadedChannelPlugin(policy.channel)?.conversationBindings ?.defaultTopLevelPlacement; + const bundledApiPlacement = + loadedPluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(policy.channel); + const pluginPlacement = + bundledApiPlacement ?? + getChannelPlugin(policy.channel)?.conversationBindings?.defaultTopLevelPlacement; const placementToUse = pluginPlacement ?? resolvePlacementWithoutChannelPlugin({ diff --git a/src/channels/plugins/thread-binding-api.test.ts b/src/channels/plugins/thread-binding-api.test.ts new file mode 100644 index 00000000000..f2d39b1de71 --- /dev/null +++ b/src/channels/plugins/thread-binding-api.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "matrix" && artifactBasename === "thread-binding-api.js") { + return { + defaultTopLevelPlacement: "child", + resolveInboundConversation: () => ({ + conversationId: " $thread ", + parentConversationId: " !room:example ", + }), + }; + } + if (dirName === "invalid" && artifactBasename === "thread-binding-api.js") { + return { + defaultTopLevelPlacement: "floating", + }; + } + if (dirName === "empty" && artifactBasename === "thread-binding-api.js") { + return {}; + } + if (dirName === "broken" && artifactBasename === "thread-binding-api.js") { + throw new Error("broken thread binding artifact"); + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); + +vi.mock("../../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); + +import { + __testing, + resolveBundledChannelThreadBindingDefaultPlacement, + resolveBundledChannelThreadBindingInboundConversation, +} from "./thread-binding-api.js"; + +describe("bundled channel thread binding fast path", () => { + beforeEach(() => { + __testing.clearThreadBindingApiCache(); + loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + }); + + it("loads default placement from the narrow thread binding artifact", () => { + expect(resolveBundledChannelThreadBindingDefaultPlacement("matrix")).toBe("child"); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "matrix", + artifactBasename: "thread-binding-api.js", + }); + }); + + it("loads inbound conversation resolution from the narrow artifact", () => { + expect( + resolveBundledChannelThreadBindingInboundConversation({ + channelId: "matrix", + to: "room:!room:example", + threadId: "$thread", + isGroup: true, + }), + ).toEqual({ + conversationId: " $thread ", + parentConversationId: " !room:example ", + }); + }); + + it("treats missing artifacts as absent hints", () => { + expect(resolveBundledChannelThreadBindingDefaultPlacement("discord")).toBeUndefined(); + expect( + resolveBundledChannelThreadBindingInboundConversation({ + channelId: "discord", + to: "channel:general", + isGroup: true, + }), + ).toBeUndefined(); + }); + + it("ignores invalid placement values", () => { + expect(resolveBundledChannelThreadBindingDefaultPlacement("invalid")).toBeUndefined(); + }); + + it("distinguishes a present artifact without an inbound resolver from a missing artifact", () => { + expect( + resolveBundledChannelThreadBindingInboundConversation({ + channelId: "empty", + to: "channel:general", + isGroup: true, + }), + ).toBeUndefined(); + }); + + it("surfaces errors from present thread binding artifacts", () => { + expect(() => resolveBundledChannelThreadBindingDefaultPlacement("broken")).toThrow( + "broken thread binding artifact", + ); + }); +}); diff --git a/src/channels/plugins/thread-binding-api.ts b/src/channels/plugins/thread-binding-api.ts new file mode 100644 index 00000000000..11d1dbba45f --- /dev/null +++ b/src/channels/plugins/thread-binding-api.ts @@ -0,0 +1,82 @@ +import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; + +type ThreadBindingPlacement = "current" | "child"; + +type ThreadBindingInboundConversationParams = { + from?: string; + to?: string; + conversationId?: string; + threadId?: string | number; + isGroup: boolean; +}; + +type ThreadBindingConversationRef = { + conversationId?: string; + parentConversationId?: string; +}; + +type ThreadBindingApi = { + defaultTopLevelPlacement?: unknown; + resolveInboundConversation?: ( + params: ThreadBindingInboundConversationParams, + ) => ThreadBindingConversationRef | null; +}; + +const THREAD_BINDING_API_ARTIFACT_BASENAME = "thread-binding-api.js"; +const MISSING_PUBLIC_SURFACE_PREFIX = "Unable to resolve bundled plugin public surface "; +const threadBindingApiCache = new Map(); + +function loadBundledChannelThreadBindingApi(channelId: string): ThreadBindingApi | undefined { + const cacheKey = channelId.trim(); + if (threadBindingApiCache.has(cacheKey)) { + return threadBindingApiCache.get(cacheKey); + } + try { + const loaded = loadBundledPluginPublicArtifactModuleSync({ + dirName: cacheKey, + artifactBasename: THREAD_BINDING_API_ARTIFACT_BASENAME, + }); + threadBindingApiCache.set(cacheKey, loaded); + return loaded; + } catch (error) { + if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { + threadBindingApiCache.set(cacheKey, undefined); + return undefined; + } + throw error; + } +} + +function normalizeThreadBindingPlacement(value: unknown): ThreadBindingPlacement | undefined { + const normalized = normalizeOptionalString(typeof value === "string" ? value : undefined); + return normalized === "current" || normalized === "child" ? normalized : undefined; +} + +export function resolveBundledChannelThreadBindingDefaultPlacement( + channelId: string, +): ThreadBindingPlacement | undefined { + return normalizeThreadBindingPlacement( + loadBundledChannelThreadBindingApi(channelId)?.defaultTopLevelPlacement, + ); +} + +export function resolveBundledChannelThreadBindingInboundConversation( + params: ThreadBindingInboundConversationParams & { channelId: string }, +): ThreadBindingConversationRef | null | undefined { + const api = loadBundledChannelThreadBindingApi(params.channelId); + if (typeof api?.resolveInboundConversation !== "function") { + return undefined; + } + return api.resolveInboundConversation({ + from: params.from, + to: params.to, + conversationId: params.conversationId, + threadId: params.threadId, + isGroup: params.isGroup, + }); +} + +export const __testing = { + clearThreadBindingApiCache: () => threadBindingApiCache.clear(), +}; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 00c4c044094..d21d1840a7f 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -454,6 +454,10 @@ export type ChannelMessagingAdapter = { cfg: OpenClawConfig; accountId?: string | null; }) => string[]; + /** + * Bundled plugins that need inbound conversation resolution before runtime + * bootstrap can mirror it through a top-level `thread-binding-api.ts` surface. + */ resolveInboundConversation?: (params: { from?: string; to?: string;