From 5cd206f780ec7089d792d21ee79706f4a3f6f969 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:31:48 -0700 Subject: [PATCH] Channels: expand contract suites --- src/channels/plugins/contracts/suites.ts | 334 ++++++++++++++++++++++- 1 file changed, 333 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index fc79d26fa07..f2c8a8e3b16 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -1,5 +1,13 @@ -import { expect, it } from "vitest"; +import { expect, it, type Mock } from "vitest"; +import type { MsgContext } from "../../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { + ResolveProviderRuntimeGroupPolicyParams, + RuntimeGroupPolicyResolution, +} from "../../../config/runtime-group-policy.js"; +import { normalizeChatType } from "../../chat-type.js"; +import { resolveConversationLabel } from "../../conversation-label.js"; +import { validateSenderIdentity } from "../../sender-identity.js"; import type { ChannelAccountSnapshot, ChannelAccountState, @@ -84,6 +92,142 @@ export function installChannelActionsContractSuite(params: { } } +export function installChannelSurfaceContractSuite(params: { + plugin: Pick< + ChannelPlugin, + | "id" + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway" + >; + surface: + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway"; +}) { + const { plugin, surface } = params; + + it(`exposes the ${surface} surface contract`, () => { + if (surface === "actions") { + expect(plugin.actions).toBeDefined(); + expect(typeof plugin.actions?.listActions).toBe("function"); + return; + } + + if (surface === "setup") { + expect(plugin.setup).toBeDefined(); + expect(typeof plugin.setup?.applyAccountConfig).toBe("function"); + return; + } + + if (surface === "status") { + expect(plugin.status).toBeDefined(); + expect(typeof plugin.status?.buildAccountSnapshot).toBe("function"); + return; + } + + if (surface === "outbound") { + const outbound = plugin.outbound; + expect(outbound).toBeDefined(); + expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode); + expect( + [ + outbound?.sendPayload, + outbound?.sendFormattedText, + outbound?.sendFormattedMedia, + outbound?.sendText, + outbound?.sendMedia, + outbound?.sendPoll, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + if (surface === "messaging") { + const messaging = plugin.messaging; + expect(messaging).toBeDefined(); + expect( + [ + messaging?.normalizeTarget, + messaging?.parseExplicitTarget, + messaging?.inferTargetChatType, + messaging?.buildCrossContextComponents, + messaging?.enableInteractiveReplies, + messaging?.hasStructuredReplyPayload, + messaging?.formatTargetDisplay, + messaging?.resolveOutboundSessionRoute, + ].some((value) => typeof value === "function"), + ).toBe(true); + if (messaging?.targetResolver) { + if (messaging.targetResolver.looksLikeId) { + expect(typeof messaging.targetResolver.looksLikeId).toBe("function"); + } + if (messaging.targetResolver.hint !== undefined) { + expect(typeof messaging.targetResolver.hint).toBe("string"); + expect(messaging.targetResolver.hint.trim()).not.toBe(""); + } + if (messaging.targetResolver.resolveTarget) { + expect(typeof messaging.targetResolver.resolveTarget).toBe("function"); + } + } + return; + } + + if (surface === "threading") { + const threading = plugin.threading; + expect(threading).toBeDefined(); + expect( + [ + threading?.resolveReplyToMode, + threading?.buildToolContext, + threading?.resolveAutoThreadId, + threading?.resolveReplyTransport, + threading?.resolveFocusedBinding, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + if (surface === "directory") { + const directory = plugin.directory; + expect(directory).toBeDefined(); + expect( + [ + directory?.self, + directory?.listPeers, + directory?.listPeersLive, + directory?.listGroups, + directory?.listGroupsLive, + directory?.listGroupMembers, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + const gateway = plugin.gateway; + expect(gateway).toBeDefined(); + expect( + [ + gateway?.startAccount, + gateway?.stopAccount, + gateway?.loginWithQrStart, + gateway?.loginWithQrWait, + gateway?.logoutAccount, + ].some((value) => typeof value === "function"), + ).toBe(true); + }); +} + type ChannelSetupContractCase = { name: string; cfg: OpenClawConfig; @@ -214,3 +358,191 @@ export function installChannelStatusContractSuite { + run: () => Promise>; + sendMock: Mock; + to: string; + }; +}) { + it("text-only delegates to sendText", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "hello" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("single media delegates to sendMedia", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "cap", + expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("multi-media iterates URLs with caption on first", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { + text: "caption", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + }, + sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock).toHaveBeenNthCalledWith( + 1, + to, + "caption", + expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), + ); + expect(sendMock).toHaveBeenNthCalledWith( + 2, + to, + "", + expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); + }); + + it("empty payload returns no-op", async () => { + const { run, sendMock } = params.createHarness({ payload: {} }); + const result = await run(); + + expect(sendMock).not.toHaveBeenCalled(); + expect(result).toEqual({ channel: params.channel, messageId: "" }); + }); + + if (params.chunking.mode === "passthrough") { + it("text exceeding chunk limit is sent as-is when chunker is null", async () => { + const text = "a".repeat(params.chunking.longTextLength); + const { run, sendMock, to } = params.createHarness({ payload: { text } }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + return; + } + + const chunking = params.chunking; + + it("chunking splits long text", async () => { + const text = "a".repeat(chunking.longTextLength); + const { run, sendMock } = params.createHarness({ + payload: { text }, + sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], + }); + const result = await run(); + + expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of sendMock.mock.calls) { + expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); + } + expect(result).toMatchObject({ channel: params.channel }); + }); +} + +export function primeChannelOutboundSendMock( + sendMock: Mock, + fallbackResult: Record, + sendResults: SendResultLike[] = [], +) { + sendMock.mockReset(); + if (sendResults.length === 0) { + sendMock.mockResolvedValue(fallbackResult); + return; + } + for (const result of sendResults) { + sendMock.mockResolvedValueOnce(result); + } +} + +type RuntimeGroupPolicyResolver = ( + params: ResolveProviderRuntimeGroupPolicyParams, +) => RuntimeGroupPolicyResolution; + +export function installChannelRuntimeGroupPolicyFallbackSuite(params: { + configuredLabel: string; + defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open"; + missingConfigLabel: string; + missingDefaultLabel: string; + resolve: RuntimeGroupPolicyResolver; +}) { + it(params.missingConfigLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it(params.configuredLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it(params.missingDefaultLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + defaultGroupPolicy: params.defaultGroupPolicyUnderTest, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +} + +export function expectChannelInboundContextContract(ctx: MsgContext) { + expect(validateSenderIdentity(ctx)).toEqual([]); + + expect(ctx.Body).toBeTypeOf("string"); + expect(ctx.BodyForAgent).toBeTypeOf("string"); + expect(ctx.BodyForCommands).toBeTypeOf("string"); + + const chatType = normalizeChatType(ctx.ChatType); + if (chatType && chatType !== "direct") { + const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); + expect(label).toBeTruthy(); + } +}