Tests: fast-path Matrix ACP thread binding

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 01:57:49 -04:00
parent 807c6648f9
commit 878f2122e5
8 changed files with 282 additions and 25 deletions

View File

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

View File

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

View File

@@ -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<string, ThreadBindingApi | undefined>();
function loadBundledChannelThreadBindingApi(channelId: string): ThreadBindingApi | undefined {
const cacheKey = channelId.trim();
if (threadBindingApiCache.has(cacheKey)) {
return threadBindingApiCache.get(cacheKey);
}
try {
const loaded = loadBundledPluginPublicArtifactModuleSync<ThreadBindingApi>({
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(),
};

View File

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