From 4e3b2781fb7f81eaa92b11ea34f5de81f3eada58 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 4 Apr 2026 00:13:12 +0900 Subject: [PATCH] test(contracts): split session binding registry seams --- .../registry-backed.contract.test.ts | 10 +- .../contracts/registry-session-binding.ts | 503 +++++++++++++++++ src/channels/plugins/contracts/registry.ts | 521 +----------------- ...n-binding.registry-backed.contract.test.ts | 6 + .../channels/registry-backed-contract.ts | 105 +--- ...ession-binding-registry-backed-contract.ts | 104 ++++ 6 files changed, 616 insertions(+), 633 deletions(-) create mode 100644 src/channels/plugins/contracts/registry-session-binding.ts create mode 100644 src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts create mode 100644 test/helpers/channels/session-binding-registry-backed-contract.ts diff --git a/src/channels/plugins/contracts/registry-backed.contract.test.ts b/src/channels/plugins/contracts/registry-backed.contract.test.ts index f9c309ec792..b5939c5fa78 100644 --- a/src/channels/plugins/contracts/registry-backed.contract.test.ts +++ b/src/channels/plugins/contracts/registry-backed.contract.test.ts @@ -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([ for (const id of [...registryIds].toSorted()) { describeChannelRegistryBackedContracts(id); } - -for (const entry of sessionBindingContractRegistry) { - describeSessionBindingRegistryBackedContract(entry.id); -} diff --git a/src/channels/plugins/contracts/registry-session-binding.ts b/src/channels/plugins/contracts/registry-session-binding.ts new file mode 100644 index 00000000000..a671cb14242 --- /dev/null +++ b/src/channels/plugins/contracts/registry-session-binding.ts @@ -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; + bindAndResolve: () => Promise; + unbindAndVerify: (binding: SessionBindingRecord) => Promise; + cleanup: () => Promise | void; +}; +let discordRuntimeApiPromise: + | Promise + | undefined; +let feishuApiPromise: Promise | undefined; +let matrixApiPromise: Promise | undefined; +let matrixRuntimeApiPromise: + | Promise + | 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 +> = { + 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], + })); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index d0e546f8255..8234f05e3e6 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -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 - | undefined; -let feishuApiPromise: Promise | undefined; -let matrixApiPromise: Promise | undefined; -let matrixRuntimeApiPromise: - | Promise - | 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; - bindAndResolve: () => Promise; - unbindAndVerify: (binding: SessionBindingRecord) => Promise; - cleanup: () => Promise | 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 -> = { - 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], - })); diff --git a/src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts b/src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts new file mode 100644 index 00000000000..d0605800619 --- /dev/null +++ b/src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts @@ -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); +} diff --git a/test/helpers/channels/registry-backed-contract.ts b/test/helpers/channels/registry-backed-contract.ts index 37fc9a8787a..1fb6942bce0 100644 --- a/test/helpers/channels/registry-backed-contract.ts +++ b/test/helpers/channels/registry-backed-contract.ts @@ -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 | undefined; -let matrixApiPromise: Promise | undefined; - -function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting { - if (!discordThreadBindingTestingCache) { - ({ discordThreadBindingTesting: discordThreadBindingTestingCache } = - loadBundledPluginTestApiSync<{ - discordThreadBindingTesting: DiscordThreadBindingTesting; - }>("discord")); - } - return discordThreadBindingTestingCache; -} -type ResetTelegramThreadBindingsForTests = () => Promise; - -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( entries: readonly T[], @@ -76,21 +25,6 @@ function hasEntries( 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, - }); - }); -} diff --git a/test/helpers/channels/session-binding-registry-backed-contract.ts b/test/helpers/channels/session-binding-registry-backed-contract.ts new file mode 100644 index 00000000000..e32878e2547 --- /dev/null +++ b/test/helpers/channels/session-binding-registry-backed-contract.ts @@ -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; + +let discordThreadBindingTestingCache: DiscordThreadBindingTesting | undefined; +let resetTelegramThreadBindingsForTestsCache: ResetTelegramThreadBindingsForTests | undefined; +let feishuApiPromise: Promise | undefined; +let matrixApiPromise: Promise | 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, + }); + }); +}