diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dab0842940..a26a8e80b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. - Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. +- Contracts/Matrix: validate Matrix session binding coverage through the real manager, expose the manager on the Matrix runtime API, and let tests pass an explicit state directory for isolated contract setup. (#50369) thanks @ChroniCat. ### Fixes diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts index 620864b9a90..4a3e03f0a31 100644 --- a/extensions/matrix/api.ts +++ b/extensions/matrix/api.ts @@ -1,3 +1,8 @@ export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; +export { + createMatrixThreadBindingManager, + getMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, +} from "./src/matrix/thread-bindings.js"; export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js"; diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index eb9a7e4c1d9..fe3116f3691 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -173,6 +173,7 @@ function resolveBindingsPath(params: { auth: MatrixAuth; accountId: string; env?: NodeJS.ProcessEnv; + stateDir?: string; }): string { const storagePaths = resolveMatrixStoragePaths({ homeserver: params.auth.homeserver, @@ -181,6 +182,7 @@ function resolveBindingsPath(params: { accountId: params.accountId, deviceId: params.auth.deviceId, env: params.env, + stateDir: params.stateDir, }); return path.join(storagePaths.rootDir, "thread-bindings.json"); } @@ -341,6 +343,7 @@ export async function createMatrixThreadBindingManager(params: { auth: MatrixAuth; client: MatrixClient; env?: NodeJS.ProcessEnv; + stateDir?: string; idleTimeoutMs: number; maxAgeMs: number; enableSweeper?: boolean; @@ -360,6 +363,7 @@ export async function createMatrixThreadBindingManager(params: { auth: params.auth, accountId: params.accountId, env: params.env, + stateDir: params.stateDir, }); const loaded = await loadBindingsFromDisk(filePath, params.accountId); for (const record of loaded) { diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 94892151c7b..3068f790053 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,9 +1,13 @@ +import fs from "node:fs/promises"; +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 } from "../../../../extensions/matrix/api.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { @@ -126,7 +130,7 @@ type DirectoryContractEntry = { type SessionBindingContractEntry = { id: string; expectedCapabilities: SessionBindingCapabilities; - getCapabilities: () => SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; @@ -136,6 +140,7 @@ function expectResolvedSessionBinding(params: { channel: string; accountId: string; conversationId: string; + parentConversationId?: string; targetSessionKey: string; }) { expect( @@ -143,6 +148,7 @@ function expectResolvedSessionBinding(params: { channel: params.channel, accountId: params.accountId, conversationId: params.conversationId, + parentConversationId: params.parentConversationId, }), )?.toMatchObject({ targetSessionKey: params.targetSessionKey, @@ -589,6 +595,24 @@ const baseSessionBindingCfg = { session: { mainKey: "main", scope: "per-sender" }, } satisfies OpenClawConfig; +async function createContractMatrixThreadBindingManager() { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-contract-thread-bindings-")); + return await createMatrixThreadBindingManager({ + accountId: "ops", + auth: { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + }, + client: {} as never, + stateDir, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); +} + export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ { id: "discord", @@ -708,6 +732,61 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ }); }, }, + { + id: "matrix", + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }, + getCapabilities: async () => { + await createContractMatrixThreadBindingManager(); + return getSessionBindingService().getCapabilities({ + channel: "matrix", + accountId: "ops", + }); + }, + bindAndResolve: async () => { + await createContractMatrixThreadBindingManager(); + const service = getSessionBindingService(); + const binding = await service.bind({ + targetSessionKey: "agent:matrix:subagent:child-1", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "!room:example", + }, + placement: "child", + metadata: { + label: "codex-matrix", + introText: "intro root", + }, + }); + expectResolvedSessionBinding({ + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + targetSessionKey: "agent:matrix:subagent:child-1", + }); + return binding; + }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, + cleanup: async () => { + const manager = await createContractMatrixThreadBindingManager(); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + }), + ).toBeNull(); + }, + }, { 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..efc85cb74b4 100644 --- a/src/channels/plugins/contracts/session-binding.contract.test.ts +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -1,15 +1,32 @@ -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/api.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"; +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: vi.fn( + async (_to: string, _message: string, opts?: { threadId?: string }) => ({ + messageId: opts?.threadId ? "$reply" : "$root", + roomId: "!room:example", + }), + ), + }; +}); + beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); discordThreadBindingTesting.resetThreadBindingsForTests(); feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); + resetMatrixThreadBindingsForTests(); telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); }); 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 () => {