test(contracts): split session binding registry seams

This commit is contained in:
Vincent Koc
2026-04-04 00:13:12 +09:00
parent 93f136cbed
commit 4e3b2781fb
6 changed files with 616 additions and 633 deletions

View File

@@ -1,12 +1,8 @@
import {
describeChannelRegistryBackedContracts,
describeSessionBindingRegistryBackedContract,
} from "../../../../test/helpers/channels/registry-backed-contract.js";
import { describeChannelRegistryBackedContracts } from "../../../../test/helpers/channels/registry-backed-contract.js";
import {
actionContractRegistry,
directoryContractRegistry,
pluginContractRegistry,
sessionBindingContractRegistry,
setupContractRegistry,
statusContractRegistry,
surfaceContractRegistry,
@@ -26,7 +22,3 @@ const registryIds = new Set<string>([
for (const id of [...registryIds].toSorted()) {
describeChannelRegistryBackedContracts(id);
}
for (const entry of sessionBindingContractRegistry) {
describeSessionBindingRegistryBackedContract(entry.id);
}

View File

@@ -0,0 +1,503 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
getSessionBindingService,
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import { createChannelConversationBindingManager } from "../conversation-bindings.js";
import {
sessionBindingContractChannelIds,
type SessionBindingContractChannelId,
} from "./manifest.js";
import "./registry.js";
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
};
let discordRuntimeApiPromise:
| Promise<typeof import("../../../../extensions/discord/runtime-api.js")>
| undefined;
let feishuApiPromise: Promise<typeof import("../../../../extensions/feishu/api.js")> | undefined;
let matrixApiPromise: Promise<typeof import("../../../../extensions/matrix/api.js")> | undefined;
let matrixRuntimeApiPromise:
| Promise<typeof import("../../../../extensions/matrix/runtime-api.js")>
| undefined;
const matrixSessionBindingStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
);
const matrixSessionBindingAuth = {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
async function getDiscordRuntimeApi() {
discordRuntimeApiPromise ??= import("../../../../extensions/discord/runtime-api.js");
return await discordRuntimeApiPromise;
}
async function getFeishuApi() {
feishuApiPromise ??= import("../../../../extensions/feishu/api.js");
return await feishuApiPromise;
}
async function getMatrixApi() {
matrixApiPromise ??= import("../../../../extensions/matrix/api.js");
return await matrixApiPromise;
}
async function getMatrixRuntimeApi() {
matrixRuntimeApiPromise ??= import("../../../../extensions/matrix/runtime-api.js");
return await matrixRuntimeApiPromise;
}
function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
targetSessionKey: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
}),
)?.toMatchObject({
targetSessionKey: params.targetSessionKey,
});
}
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
const service = getSessionBindingService();
const removed = await service.unbind({
bindingId: binding.bindingId,
reason: "contract-test",
});
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
expect(service.resolveByConversation(binding.conversation)).toBeNull();
}
function expectClearedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
}),
).toBeNull();
}
function resetMatrixSessionBindingStateDir() {
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
}
async function createContractMatrixThreadBindingManager() {
resetMatrixSessionBindingStateDir();
const { setMatrixRuntime } = await getMatrixRuntimeApi();
const { createMatrixThreadBindingManager } = await getMatrixApi();
setMatrixRuntime({
state: {
resolveStateDir: () => matrixSessionBindingStateDir,
},
} as never);
return await createMatrixThreadBindingManager({
accountId: matrixSessionBindingAuth.accountId,
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
bluebubbles: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "bluebubbles",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
targetKind: "session",
conversation: {
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
});
},
},
discord: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
const { createThreadBindingManager } = await getDiscordRuntimeApi();
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createThreadBindingManager } = await getDiscordRuntimeApi();
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:discord:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
},
placement: "current",
metadata: {
label: "codex-discord",
},
});
expectResolvedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
targetSessionKey: "agent:discord:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const { createThreadBindingManager } = await getDiscordRuntimeApi();
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
manager.stop();
expectClearedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
});
},
},
feishu: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: async () => {
const { createFeishuThreadBindingManager } = await getFeishuApi();
createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" });
return getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createFeishuThreadBindingManager } = await getFeishuApi();
createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" });
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const { createFeishuThreadBindingManager } = await getFeishuApi();
const manager = createFeishuThreadBindingManager({
cfg: baseSessionBindingCfg,
accountId: "default",
});
manager.stop();
expectClearedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
});
},
},
imessage: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "imessage",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:imessage:default:abc123",
targetKind: "session",
conversation: {
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
targetSessionKey: "agent:codex:acp:binding:imessage:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
});
},
},
matrix: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example",
},
placement: "current",
metadata: {
label: "codex-matrix",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
targetSessionKey: "agent:matrix:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const { resetMatrixThreadBindingsForTests } = await getMatrixApi();
resetMatrixThreadBindingsForTests();
resetMatrixSessionBindingStateDir();
expectClearedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
});
},
},
telegram: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
boundBy: "user-1",
},
});
expectResolvedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
targetSessionKey: "agent:main:subagent:child-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
});
},
},
};
export const sessionBindingContractRegistry: SessionBindingContractEntry[] =
sessionBindingContractChannelIds.map((id) => ({
id,
...sessionBindingContractEntries[id],
}));

View File

@@ -1,76 +1,17 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
getSessionBindingService,
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../../plugin-sdk/line.js";
import { loadBundledPluginTestApiSync } from "../../../test-utils/bundled-plugin-public-surface.js";
import {
listBundledChannelPlugins,
requireBundledChannelPlugin,
setBundledChannelRuntime,
} from "../bundled.js";
import { createChannelConversationBindingManager } from "../conversation-bindings.js";
import type { ChannelPlugin } from "../types.js";
import {
channelPluginSurfaceKeys,
type ChannelPluginSurface,
sessionBindingContractChannelIds,
type SessionBindingContractChannelId,
} from "./manifest.js";
type DiscordThreadBindingTesting = {
resetThreadBindingsForTests: () => void;
};
let discordThreadBindingTestingCache: DiscordThreadBindingTesting | undefined;
let discordRuntimeApiPromise:
| Promise<typeof import("../../../../extensions/discord/runtime-api.js")>
| undefined;
let feishuApiPromise: Promise<typeof import("../../../../extensions/feishu/api.js")> | undefined;
let matrixApiPromise: Promise<typeof import("../../../../extensions/matrix/api.js")> | undefined;
let matrixRuntimeApiPromise:
| Promise<typeof import("../../../../extensions/matrix/runtime-api.js")>
| undefined;
function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting {
if (!discordThreadBindingTestingCache) {
({ discordThreadBindingTesting: discordThreadBindingTestingCache } =
loadBundledPluginTestApiSync<{
discordThreadBindingTesting: DiscordThreadBindingTesting;
}>("discord"));
}
return discordThreadBindingTestingCache;
}
async function getDiscordRuntimeApi() {
discordRuntimeApiPromise ??= import("../../../../extensions/discord/runtime-api.js");
return await discordRuntimeApiPromise;
}
async function getFeishuApi() {
feishuApiPromise ??= import("../../../../extensions/feishu/api.js");
return await feishuApiPromise;
}
async function getMatrixApi() {
matrixApiPromise ??= import("../../../../extensions/matrix/api.js");
return await matrixApiPromise;
}
async function getMatrixRuntimeApi() {
matrixRuntimeApiPromise ??= import("../../../../extensions/matrix/runtime-api.js");
return await matrixRuntimeApiPromise;
}
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string {
return ["..", "..", "..", "..", "extensions", pluginId, artifactBasename].join("/");
@@ -155,58 +96,6 @@ type DirectoryContractEntry = {
accountId?: string;
};
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
};
function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
targetSessionKey: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
}),
)?.toMatchObject({
targetSessionKey: params.targetSessionKey,
});
}
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
const service = getSessionBindingService();
const removed = await service.unbind({
bindingId: binding.bindingId,
reason: "contract-test",
});
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
expect(service.resolveByConversation(binding.conversation)).toBeNull();
}
function expectClearedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
}),
).toBeNull();
}
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$matrix-thread" : "$matrix-root",
@@ -234,40 +123,6 @@ vi.mock(buildBundledPluginModuleId("matrix", "runtime-api.js"), async () => {
};
});
const matrixSessionBindingStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
);
const matrixSessionBindingAuth = {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
function resetMatrixSessionBindingStateDir() {
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
}
async function createContractMatrixThreadBindingManager() {
resetMatrixSessionBindingStateDir();
const { setMatrixRuntime } = await getMatrixRuntimeApi();
const { createMatrixThreadBindingManager } = await getMatrixApi();
setMatrixRuntime({
state: {
resolveStateDir: () => matrixSessionBindingStateDir,
},
} as never);
return await createMatrixThreadBindingManager({
accountId: matrixSessionBindingAuth.accountId,
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
export const pluginContractRegistry: PluginContractEntry[] = listBundledChannelPlugins().map(
(plugin) => ({
id: plugin.id,
@@ -666,377 +521,3 @@ export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContra
plugin: entry.plugin,
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
}));
const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
bluebubbles: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "bluebubbles",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
targetKind: "session",
conversation: {
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
});
},
},
discord: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
const { createThreadBindingManager } = await getDiscordRuntimeApi();
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createThreadBindingManager } = await getDiscordRuntimeApi();
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:discord:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
},
placement: "current",
metadata: {
label: "codex-discord",
},
});
expectResolvedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
targetSessionKey: "agent:discord:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const { createThreadBindingManager } = await getDiscordRuntimeApi();
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
manager.stop();
getDiscordThreadBindingTesting().resetThreadBindingsForTests();
expectClearedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
});
},
},
feishu: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: async () => {
const { createFeishuThreadBindingManager } = await getFeishuApi();
createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" });
return getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createFeishuThreadBindingManager } = await getFeishuApi();
createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" });
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const { createFeishuThreadBindingManager } = await getFeishuApi();
const manager = createFeishuThreadBindingManager({
cfg: baseSessionBindingCfg,
accountId: "default",
});
manager.stop();
expectClearedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
});
},
},
imessage: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "imessage",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:imessage:default:abc123",
targetKind: "session",
conversation: {
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
targetSessionKey: "agent:codex:acp:binding:imessage:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
});
},
},
matrix: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example",
},
placement: "current",
metadata: {
label: "codex-matrix",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
targetSessionKey: "agent:matrix:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const { resetMatrixThreadBindingsForTests } = await getMatrixApi();
resetMatrixThreadBindingsForTests();
resetMatrixSessionBindingStateDir();
expectClearedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
});
},
},
telegram: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
boundBy: "user-1",
},
});
expectResolvedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
targetSessionKey: "agent:main:subagent:child-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
});
},
},
};
export const sessionBindingContractRegistry: SessionBindingContractEntry[] =
sessionBindingContractChannelIds.map((id) => ({
id,
...sessionBindingContractEntries[id],
}));

View File

@@ -0,0 +1,6 @@
import { describeSessionBindingRegistryBackedContract } from "../../../../test/helpers/channels/session-binding-registry-backed-contract.js";
import { sessionBindingContractRegistry } from "./registry-session-binding.js";
for (const entry of sessionBindingContractRegistry) {
describeSessionBindingRegistryBackedContract(entry.id);
}

View File

@@ -1,9 +1,8 @@
import { afterEach, beforeEach, describe } from "vitest";
import { describe } from "vitest";
import {
actionContractRegistry,
directoryContractRegistry,
pluginContractRegistry,
sessionBindingContractRegistry,
setupContractRegistry,
statusContractRegistry,
surfaceContractRegistry,
@@ -17,57 +16,7 @@ import {
installChannelStatusContractSuite,
installChannelSurfaceContractSuite,
installChannelThreadingContractSuite,
installSessionBindingContractSuite,
} from "../../../src/channels/plugins/contracts/suites.js";
import { setDefaultChannelPluginRegistryForTests } from "../../../src/commands/channel-test-registry.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../../../src/config/config.js";
import { __testing as sessionBindingTesting } from "../../../src/infra/outbound/session-binding-service.js";
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type DiscordThreadBindingTesting = {
resetThreadBindingsForTests: () => void;
};
let discordThreadBindingTestingCache: DiscordThreadBindingTesting | undefined;
let feishuApiPromise: Promise<typeof import("../../../extensions/feishu/api.js")> | undefined;
let matrixApiPromise: Promise<typeof import("../../../extensions/matrix/api.js")> | undefined;
function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting {
if (!discordThreadBindingTestingCache) {
({ discordThreadBindingTesting: discordThreadBindingTestingCache } =
loadBundledPluginTestApiSync<{
discordThreadBindingTesting: DiscordThreadBindingTesting;
}>("discord"));
}
return discordThreadBindingTestingCache;
}
type ResetTelegramThreadBindingsForTests = () => Promise<void>;
let resetTelegramThreadBindingsForTestsCache: ResetTelegramThreadBindingsForTests | undefined;
function getResetTelegramThreadBindingsForTests(): ResetTelegramThreadBindingsForTests {
if (!resetTelegramThreadBindingsForTestsCache) {
({ resetTelegramThreadBindingsForTests: resetTelegramThreadBindingsForTestsCache } =
loadBundledPluginTestApiSync<{
resetTelegramThreadBindingsForTests: ResetTelegramThreadBindingsForTests;
}>("telegram"));
}
return resetTelegramThreadBindingsForTestsCache;
}
async function getFeishuThreadBindingTesting() {
feishuApiPromise ??= import("../../../extensions/feishu/api.js");
return (await feishuApiPromise).feishuThreadBindingTesting;
}
async function getResetMatrixThreadBindingsForTests() {
matrixApiPromise ??= import("../../../extensions/matrix/api.js");
return (await matrixApiPromise).resetMatrixThreadBindingsForTests;
}
function hasEntries<T extends { id: string }>(
entries: readonly T[],
@@ -76,21 +25,6 @@ function hasEntries<T extends { id: string }>(
return entries.some((entry) => entry.id === id);
}
function resolveSessionBindingContractRuntimeConfig(id: string) {
if (id !== "discord" && id !== "matrix") {
return null;
}
return {
plugins: {
entries: {
[id]: {
enabled: true,
},
},
},
};
}
export function describeChannelRegistryBackedContracts(id: string) {
if (hasEntries(pluginContractRegistry, id)) {
const entry = pluginContractRegistry.find((item) => item.id === id)!;
@@ -164,40 +98,3 @@ export function describeChannelRegistryBackedContracts(id: string) {
});
}
}
export function describeSessionBindingRegistryBackedContract(id: string) {
const entry = sessionBindingContractRegistry.find((item) => item.id === id);
if (!entry) {
throw new Error(`missing session binding contract entry for ${id}`);
}
describe(`${entry.id} session binding contract`, () => {
beforeEach(async () => {
resetPluginRuntimeStateForTest();
clearRuntimeConfigSnapshot();
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
if (runtimeConfig) {
// These registry-backed contract suites intentionally exercise bundled runtime facades.
// Opt those specific plugins in so the activation boundary behaves like real runtime usage.
setRuntimeConfigSnapshot(runtimeConfig);
}
setDefaultChannelPluginRegistryForTests();
sessionBindingTesting.resetSessionBindingAdaptersForTests();
getDiscordThreadBindingTesting().resetThreadBindingsForTests();
(await getFeishuThreadBindingTesting()).resetFeishuThreadBindingsForTests();
(await getResetMatrixThreadBindingsForTests())();
await getResetTelegramThreadBindingsForTests()();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
});
installSessionBindingContractSuite({
expectedCapabilities: entry.expectedCapabilities,
getCapabilities: entry.getCapabilities,
bindAndResolve: entry.bindAndResolve,
unbindAndVerify: entry.unbindAndVerify,
cleanup: entry.cleanup,
});
});
}

View File

@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe } from "vitest";
import { sessionBindingContractRegistry } from "../../../src/channels/plugins/contracts/registry-session-binding.js";
import { installSessionBindingContractSuite } from "../../../src/channels/plugins/contracts/suites.js";
import { setDefaultChannelPluginRegistryForTests } from "../../../src/commands/channel-test-registry.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../../../src/config/config.js";
import { __testing as sessionBindingTesting } from "../../../src/infra/outbound/session-binding-service.js";
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type DiscordThreadBindingTesting = {
resetThreadBindingsForTests: () => void;
};
type ResetTelegramThreadBindingsForTests = () => Promise<void>;
let discordThreadBindingTestingCache: DiscordThreadBindingTesting | undefined;
let resetTelegramThreadBindingsForTestsCache: ResetTelegramThreadBindingsForTests | undefined;
let feishuApiPromise: Promise<typeof import("../../../extensions/feishu/api.js")> | undefined;
let matrixApiPromise: Promise<typeof import("../../../extensions/matrix/api.js")> | undefined;
function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting {
if (!discordThreadBindingTestingCache) {
({ discordThreadBindingTesting: discordThreadBindingTestingCache } =
loadBundledPluginTestApiSync<{
discordThreadBindingTesting: DiscordThreadBindingTesting;
}>("discord"));
}
return discordThreadBindingTestingCache;
}
function getResetTelegramThreadBindingsForTests(): ResetTelegramThreadBindingsForTests {
if (!resetTelegramThreadBindingsForTestsCache) {
({ resetTelegramThreadBindingsForTests: resetTelegramThreadBindingsForTestsCache } =
loadBundledPluginTestApiSync<{
resetTelegramThreadBindingsForTests: ResetTelegramThreadBindingsForTests;
}>("telegram"));
}
return resetTelegramThreadBindingsForTestsCache;
}
async function getFeishuThreadBindingTesting() {
feishuApiPromise ??= import("../../../extensions/feishu/api.js");
return (await feishuApiPromise).feishuThreadBindingTesting;
}
async function getResetMatrixThreadBindingsForTests() {
matrixApiPromise ??= import("../../../extensions/matrix/api.js");
return (await matrixApiPromise).resetMatrixThreadBindingsForTests;
}
function resolveSessionBindingContractRuntimeConfig(id: string) {
if (id !== "discord" && id !== "matrix") {
return null;
}
return {
plugins: {
entries: {
[id]: {
enabled: true,
},
},
},
};
}
export function describeSessionBindingRegistryBackedContract(id: string) {
const entry = sessionBindingContractRegistry.find((item) => item.id === id);
if (!entry) {
throw new Error(`missing session binding contract entry for ${id}`);
}
describe(`${entry.id} session binding contract`, () => {
beforeEach(async () => {
resetPluginRuntimeStateForTest();
clearRuntimeConfigSnapshot();
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
if (runtimeConfig) {
// These registry-backed contract suites intentionally exercise bundled runtime facades.
// Opt those specific plugins in so the activation boundary behaves like real runtime usage.
setRuntimeConfigSnapshot(runtimeConfig);
}
setDefaultChannelPluginRegistryForTests();
sessionBindingTesting.resetSessionBindingAdaptersForTests();
getDiscordThreadBindingTesting().resetThreadBindingsForTests();
(await getFeishuThreadBindingTesting()).resetFeishuThreadBindingsForTests();
(await getResetMatrixThreadBindingsForTests())();
await getResetTelegramThreadBindingsForTests()();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
});
installSessionBindingContractSuite({
expectedCapabilities: entry.expectedCapabilities,
getCapabilities: entry.getCapabilities,
bindAndResolve: entry.bindAndResolve,
unbindAndVerify: entry.unbindAndVerify,
cleanup: entry.cleanup,
});
});
}