diff --git a/src/channels/plugins/contracts/directory.contract.test.ts b/src/channels/plugins/contracts/directory.contract.test.ts new file mode 100644 index 00000000000..97969adc35b --- /dev/null +++ b/src/channels/plugins/contracts/directory.contract.test.ts @@ -0,0 +1,12 @@ +import { describe } from "vitest"; +import { directoryContractRegistry } from "./registry.js"; +import { installChannelDirectoryContractSuite } from "./suites.js"; + +for (const entry of directoryContractRegistry) { + describe(`${entry.id} directory contract`, () => { + installChannelDirectoryContractSuite({ + plugin: entry.plugin, + invokeLookups: entry.invokeLookups, + }); + }); +} diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts index 69ff11d8e68..a379792253a 100644 --- a/src/channels/plugins/contracts/registry.contract.test.ts +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { actionContractRegistry, + directoryContractRegistry, pluginContractRegistry, setupContractRegistry, statusContractRegistry, surfaceContractRegistry, + threadingContractRegistry, type ChannelPluginSurface, } from "./registry.js"; @@ -70,4 +72,26 @@ describe("channel contract registry", () => { expect(statusSurfaceIds.has(entry.id)).toBe(true); } }); + + it("only installs deep threading coverage for plugins that declare threading", () => { + const threadingSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("threading")) + .map((entry) => entry.id), + ); + for (const entry of threadingContractRegistry) { + expect(threadingSurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("only installs deep directory coverage for plugins that declare directory", () => { + const directorySurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("directory")) + .map((entry) => entry.id), + ); + for (const entry of directoryContractRegistry) { + expect(directorySurfaceIds.has(entry.id)).toBe(true); + } + }); }); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 2d4569383f8..617aa9c2221 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -84,6 +84,17 @@ type SurfaceContractEntry = { surfaces: readonly ChannelPluginSurface[]; }; +type ThreadingContractEntry = { + id: string; + plugin: Pick; +}; + +type DirectoryContractEntry = { + id: string; + plugin: Pick; + invokeLookups: boolean; +}; + const telegramListActionsMock = vi.fn(); const telegramGetCapabilitiesMock = vi.fn(); const discordListActionsMock = vi.fn(); @@ -672,3 +683,20 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ ], }, ]; + +export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("threading")) + .map((entry) => ({ + id: entry.id, + plugin: entry.plugin, + })); + +const directoryShapeOnlyIds = new Set(["matrix", "whatsapp", "zalouser"]); + +export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("directory")) + .map((entry) => ({ + id: entry.id, + plugin: entry.plugin, + invokeLookups: !directoryShapeOnlyIds.has(entry.id), + })); diff --git a/src/channels/plugins/contracts/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts new file mode 100644 index 00000000000..a21632c4515 --- /dev/null +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect } from "vitest"; +import { + __testing as feishuThreadBindingTesting, + createFeishuThreadBindingManager, +} from "../../../../extensions/feishu/src/thread-bindings.js"; +import { + __testing as telegramThreadBindingTesting, + createTelegramThreadBindingManager, +} from "../../../../extensions/telegram/src/thread-bindings.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { + __testing as sessionBindingTesting, + getSessionBindingService, +} from "../../../infra/outbound/session-binding-service.js"; +import { installSessionBindingContractSuite } from "./suites.js"; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); + telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); +}); + +describe("feishu session binding contract", () => { + installSessionBindingContractSuite({ + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }, + getCapabilities: () => { + createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + return getSessionBindingService().getCapabilities({ + channel: "feishu", + accountId: "default", + }); + }, + bindAndResolve: async () => { + createFeishuThreadBindingManager({ cfg: baseCfg, 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", + }, + }); + expect( + service.resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + )?.toMatchObject({ + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + }); + return binding; + }, + cleanup: async () => { + const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + ).toBeNull(); + }, + }); +}); + +describe("telegram session binding contract", () => { + installSessionBindingContractSuite({ + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }, + getCapabilities: () => { + createTelegramThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + return getSessionBindingService().getCapabilities({ + channel: "telegram", + accountId: "default", + }); + }, + bindAndResolve: async () => { + createTelegramThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + 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", + }, + }); + expect( + service.resolveByConversation({ + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + }), + )?.toMatchObject({ + targetSessionKey: "agent:main:subagent:child-1", + }); + return binding; + }, + cleanup: async () => { + const manager = createTelegramThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + }), + ).toBeNull(); + }, + }); +}); diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index f2c8a8e3b16..90d852e7923 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -5,13 +5,21 @@ import type { ResolveProviderRuntimeGroupPolicyParams, RuntimeGroupPolicyResolution, } from "../../../config/runtime-group-policy.js"; +import type { + SessionBindingCapabilities, + SessionBindingRecord, +} from "../../../infra/outbound/session-binding-service.js"; import { normalizeChatType } from "../../chat-type.js"; import { resolveConversationLabel } from "../../conversation-label.js"; import { validateSenderIdentity } from "../../sender-identity.js"; import type { ChannelAccountSnapshot, ChannelAccountState, + ChannelDirectoryEntry, + ChannelFocusedBindingContext, + ChannelReplyTransport, ChannelSetupInput, + ChannelThreadingToolContext, } from "../types.core.js"; import type { ChannelMessageActionName, @@ -23,6 +31,68 @@ function sortStrings(values: readonly string[]) { return [...values].toSorted((left, right) => left.localeCompare(right)); } +function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) { + expect(["user", "group", "channel"]).toContain(entry.kind); + expect(typeof entry.id).toBe("string"); + expect(entry.id.trim()).not.toBe(""); + if (entry.name !== undefined) { + expect(typeof entry.name).toBe("string"); + } + if (entry.handle !== undefined) { + expect(typeof entry.handle).toBe("string"); + } + if (entry.avatarUrl !== undefined) { + expect(typeof entry.avatarUrl).toBe("string"); + } + if (entry.rank !== undefined) { + expect(typeof entry.rank).toBe("number"); + } +} + +function expectThreadingToolContextShape(context: ChannelThreadingToolContext) { + if (context.currentChannelId !== undefined) { + expect(typeof context.currentChannelId).toBe("string"); + } + if (context.currentChannelProvider !== undefined) { + expect(typeof context.currentChannelProvider).toBe("string"); + } + if (context.currentThreadTs !== undefined) { + expect(typeof context.currentThreadTs).toBe("string"); + } + if (context.currentMessageId !== undefined) { + expect(["string", "number"]).toContain(typeof context.currentMessageId); + } + if (context.replyToMode !== undefined) { + expect(["off", "first", "all"]).toContain(context.replyToMode); + } + if (context.hasRepliedRef !== undefined) { + expect(typeof context.hasRepliedRef).toBe("object"); + } + if (context.skipCrossContextDecoration !== undefined) { + expect(typeof context.skipCrossContextDecoration).toBe("boolean"); + } +} + +function expectReplyTransportShape(transport: ChannelReplyTransport) { + if (transport.replyToId !== undefined && transport.replyToId !== null) { + expect(typeof transport.replyToId).toBe("string"); + } + if (transport.threadId !== undefined && transport.threadId !== null) { + expect(["string", "number"]).toContain(typeof transport.threadId); + } +} + +function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) { + expect(typeof binding.conversationId).toBe("string"); + expect(binding.conversationId.trim()).not.toBe(""); + if (binding.parentConversationId !== undefined) { + expect(typeof binding.parentConversationId).toBe("string"); + } + expect(["current", "child"]).toContain(binding.placement); + expect(typeof binding.labelNoun).toBe("string"); + expect(binding.labelNoun.trim()).not.toBe(""); +} + export function installChannelPluginContractSuite(params: { plugin: Pick; }) { @@ -228,6 +298,186 @@ export function installChannelSurfaceContractSuite(params: { }); } +export function installChannelThreadingContractSuite(params: { + plugin: Pick; +}) { + it("exposes the base threading contract", () => { + expect(params.plugin.threading).toBeDefined(); + }); + + it("keeps threading return values normalized", () => { + const threading = params.plugin.threading; + expect(threading).toBeDefined(); + + if (threading?.resolveReplyToMode) { + expect( + ["off", "first", "all"].includes( + threading.resolveReplyToMode({ + cfg: {} as OpenClawConfig, + accountId: "default", + chatType: "group", + }), + ), + ).toBe(true); + } + + const repliedRef = { value: false }; + const toolContext = threading?.buildToolContext?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + context: { + Channel: "group:test", + From: "user:test", + To: "group:test", + ChatType: "group", + CurrentMessageId: "msg-1", + ReplyToId: "msg-0", + ReplyToIdFull: "thread-0", + MessageThreadId: "thread-0", + NativeChannelId: "native:test", + }, + hasRepliedRef: repliedRef, + }); + + if (toolContext) { + expectThreadingToolContextShape(toolContext); + if (toolContext.hasRepliedRef) { + expect(toolContext.hasRepliedRef).toBe(repliedRef); + } + } + + const autoThreadId = threading?.resolveAutoThreadId?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + to: "group:test", + toolContext, + replyToId: null, + }); + if (autoThreadId !== undefined) { + expect(typeof autoThreadId).toBe("string"); + expect(autoThreadId.trim()).not.toBe(""); + } + + const replyTransport = threading?.resolveReplyTransport?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + threadId: "thread-0", + replyToId: "msg-0", + }); + if (replyTransport) { + expectReplyTransportShape(replyTransport); + } + + const focusedBinding = threading?.resolveFocusedBinding?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + context: { + Channel: "group:test", + From: "user:test", + To: "group:test", + ChatType: "group", + CurrentMessageId: "msg-1", + ReplyToId: "msg-0", + ReplyToIdFull: "thread-0", + MessageThreadId: "thread-0", + NativeChannelId: "native:test", + }, + }); + if (focusedBinding) { + expectFocusedBindingShape(focusedBinding); + } + }); +} + +export function installChannelDirectoryContractSuite(params: { + plugin: Pick; + invokeLookups?: boolean; +}) { + it("exposes the base directory contract", async () => { + const directory = params.plugin.directory; + expect(directory).toBeDefined(); + + if (params.invokeLookups === false) { + return; + } + + const self = await directory?.self?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + }); + if (self) { + expectDirectoryEntryShape(self); + } + + const peers = + (await directory?.listPeers?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + query: "", + limit: 5, + })) ?? []; + expect(Array.isArray(peers)).toBe(true); + for (const peer of peers) { + expectDirectoryEntryShape(peer); + } + + const groups = + (await directory?.listGroups?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + query: "", + limit: 5, + })) ?? []; + expect(Array.isArray(groups)).toBe(true); + for (const group of groups) { + expectDirectoryEntryShape(group); + } + + if (directory?.listGroupMembers && groups[0]?.id) { + const members = await directory.listGroupMembers({ + cfg: {} as OpenClawConfig, + accountId: "default", + groupId: groups[0].id, + query: "", + limit: 5, + }); + expect(Array.isArray(members)).toBe(true); + for (const member of members) { + expectDirectoryEntryShape(member); + } + } + }); +} + +export function installSessionBindingContractSuite(params: { + getCapabilities: () => SessionBindingCapabilities; + bindAndResolve: () => Promise; + cleanup: () => Promise | void; + expectedCapabilities: SessionBindingCapabilities; +}) { + it("registers the expected session binding capabilities", () => { + expect(params.getCapabilities()).toEqual(params.expectedCapabilities); + }); + + it("binds and resolves a session binding through the shared service", async () => { + const binding = await params.bindAndResolve(); + expect(typeof binding.bindingId).toBe("string"); + expect(binding.bindingId.trim()).not.toBe(""); + expect(typeof binding.targetSessionKey).toBe("string"); + expect(binding.targetSessionKey.trim()).not.toBe(""); + expect(["session", "subagent"]).toContain(binding.targetKind); + expect(typeof binding.conversation.channel).toBe("string"); + expect(typeof binding.conversation.accountId).toBe("string"); + expect(typeof binding.conversation.conversationId).toBe("string"); + expect(["active", "ending", "ended"]).toContain(binding.status); + expect(typeof binding.boundAt).toBe("number"); + }); + + it("cleans up registered bindings", async () => { + await params.cleanup(); + }); +} + type ChannelSetupContractCase = { name: string; cfg: OpenClawConfig; diff --git a/src/channels/plugins/contracts/threading.contract.test.ts b/src/channels/plugins/contracts/threading.contract.test.ts new file mode 100644 index 00000000000..54799b54c44 --- /dev/null +++ b/src/channels/plugins/contracts/threading.contract.test.ts @@ -0,0 +1,11 @@ +import { describe } from "vitest"; +import { threadingContractRegistry } from "./registry.js"; +import { installChannelThreadingContractSuite } from "./suites.js"; + +for (const entry of threadingContractRegistry) { + describe(`${entry.id} threading contract`, () => { + installChannelThreadingContractSuite({ + plugin: entry.plugin, + }); + }); +}