From 46aa10c04a7776e4faa001d0195365c7489aac33 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:59:01 -0500 Subject: [PATCH] Add matrix session binding contracts --- src/channels/plugins/contracts/registry.ts | 89 ++++++++++++++++++- .../session-binding.contract.test.ts | 22 ++++- src/channels/plugins/contracts/suites.ts | 6 +- 3 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 94892151c7b..572cfb302aa 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,9 +1,17 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { expect, vi } from "vitest"; import { __testing as discordThreadBindingTesting, createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; +import { + createMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, +} from "../../../../extensions/matrix/src/matrix/thread-bindings.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { @@ -126,12 +134,39 @@ type DirectoryContractEntry = { type SessionBindingContractEntry = { id: string; expectedCapabilities: SessionBindingCapabilities; - getCapabilities: () => SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; }; +const matrixSessionBindingAuth = { + accountId: "default", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", +} as const; + +function createMatrixSessionBindingStateDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-session-binding-contract-")); +} + +async function setupMatrixSessionBindingManager() { + setMatrixRuntime({ + state: { + resolveStateDir: () => createMatrixSessionBindingStateDir(), + }, + } as never); + return await createMatrixThreadBindingManager({ + accountId: "default", + auth: matrixSessionBindingAuth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); +} + function expectResolvedSessionBinding(params: { channel: string; accountId: string; @@ -708,6 +743,58 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ }); }, }, + { + id: "matrix", + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }, + getCapabilities: async () => { + await setupMatrixSessionBindingManager(); + return getSessionBindingService().getCapabilities({ + channel: "matrix", + accountId: "default", + }); + }, + bindAndResolve: async () => { + await setupMatrixSessionBindingManager(); + const service = getSessionBindingService(); + const binding = await service.bind({ + targetSessionKey: "agent:matrix:child:thread-1", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "!room:example", + }, + placement: "child", + metadata: { + label: "codex-matrix", + introText: "matrix contract intro", + }, + }); + expectResolvedSessionBinding({ + channel: "matrix", + accountId: "default", + conversationId: "$root", + targetSessionKey: "agent:matrix:child:thread-1", + }); + return binding; + }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, + cleanup: async () => { + const manager = await setupMatrixSessionBindingManager(); + manager.stop(); + resetMatrixThreadBindingsForTests(); + expectClearedSessionBinding({ + channel: "matrix", + accountId: "default", + conversationId: "$root", + }); + }, + }, { id: "telegram", expectedCapabilities: { diff --git a/src/channels/plugins/contracts/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts index b8201569cde..c876b716604 100644 --- a/src/channels/plugins/contracts/session-binding.contract.test.ts +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -1,16 +1,36 @@ -import { beforeEach, describe } from "vitest"; +import { beforeEach, describe, vi } from "vitest"; import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js"; import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js"; +import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/src/matrix/thread-bindings.js"; import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js"; import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js"; import { sessionBindingContractRegistry } from "./registry.js"; import { installSessionBindingContractSuite } from "./suites.js"; +const sendMessageMatrixMock = vi.hoisted(() => + vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({ + messageId: opts?.threadId ? "$reply" : "$root", + roomId: "!room:example", + })), +); + +vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../extensions/matrix/src/matrix/send.js") + >("../../../../extensions/matrix/src/matrix/send.js"); + return { + ...actual, + sendMessageMatrix: sendMessageMatrixMock, + }; +}); + beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); discordThreadBindingTesting.resetThreadBindingsForTests(); feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); + resetMatrixThreadBindingsForTests(); telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); + sendMessageMatrixMock.mockClear(); }); for (const entry of sessionBindingContractRegistry) { diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 892d4b293f9..7c9803ee47f 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: { } export function installSessionBindingContractSuite(params: { - getCapabilities: () => SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; expectedCapabilities: SessionBindingCapabilities; }) { - it("registers the expected session binding capabilities", () => { - expect(params.getCapabilities()).toEqual(params.expectedCapabilities); + it("registers the expected session binding capabilities", async () => { + expect(await params.getCapabilities()).toEqual(params.expectedCapabilities); }); it("binds and resolves a session binding through the shared service", async () => {