mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:31:00 +00:00
Tests: fast-path Matrix ACP thread binding
This commit is contained in:
@@ -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({
|
||||
|
||||
101
src/channels/plugins/thread-binding-api.test.ts
Normal file
101
src/channels/plugins/thread-binding-api.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
82
src/channels/plugins/thread-binding-api.ts
Normal file
82
src/channels/plugins/thread-binding-api.ts
Normal 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(),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user