From 6c866b8543beb3ef9ed058b017d008734dd0e9ea Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 22:26:55 -0700 Subject: [PATCH] Tests: centralize contract coverage follow-ups (#48751) * Plugins: harden global contract coverage * Channels: tighten global contract coverage * Channels: centralize inbound contract coverage * Channels: move inbound contract helpers into core * Tests: rename local inbound context checks * Tests: stabilize contract runner profile * Tests: split scoped contract lanes * Channels: move inbound dispatch testkit into contracts * Plugins: share provider contract registry helpers * Plugins: reuse provider contract registry helpers --- CONTRIBUTING.md | 1 + ...> message-handler.inbound-context.test.ts} | 4 +- ... => event-handler.inbound-context.test.ts} | 2 +- .../event-handler.mention-gating.test.ts | 2 +- ...> process-message.inbound-context.test.ts} | 2 +- package.json | 4 +- .../contracts/directory.contract.test.ts | 4 +- .../contracts}/dispatch-inbound-capture.ts | 0 .../contracts}/inbound-contract-capture.ts | 4 +- .../inbound-contract-dispatch-mock.ts | 2 +- .../contracts/inbound.contract.test.ts | 299 ++++++++++ .../inbound.discord.contract.test.ts | 24 - .../contracts/inbound.signal.contract.test.ts | 73 --- .../contracts/inbound.slack.contract.test.ts | 54 -- .../inbound.telegram.contract.test.ts | 60 -- .../inbound.whatsapp.contract.test.ts | 111 ---- .../contracts/registry.contract.test.ts | 71 ++- src/channels/plugins/contracts/registry.ts | 516 +++++++++++------- .../session-binding.contract.test.ts | 159 +----- src/channels/plugins/contracts/suites.ts | 28 +- .../contracts/auth-choice.contract.test.ts | 44 +- .../contracts/catalog.contract.test.ts | 25 +- src/plugins/contracts/loader.contract.test.ts | 9 +- .../contracts/registry.contract.test.ts | 41 ++ src/plugins/contracts/registry.ts | 31 ++ .../contracts/runtime.contract.test.ts | 51 +- src/plugins/contracts/wizard.contract.test.ts | 27 +- 27 files changed, 855 insertions(+), 793 deletions(-) rename extensions/discord/src/monitor/{message-handler.inbound-contract.test.ts => message-handler.inbound-context.test.ts} (91%) rename extensions/signal/src/monitor/{event-handler.inbound-contract.test.ts => event-handler.inbound-context.test.ts} (99%) rename extensions/whatsapp/src/auto-reply/monitor/{process-message.inbound-contract.test.ts => process-message.inbound-context.test.ts} (99%) rename {test/helpers => src/channels/plugins/contracts}/dispatch-inbound-capture.ts (100%) rename {test/helpers => src/channels/plugins/contracts}/inbound-contract-capture.ts (76%) rename {test/helpers => src/channels/plugins/contracts}/inbound-contract-dispatch-mock.ts (82%) create mode 100644 src/channels/plugins/contracts/inbound.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.discord.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.signal.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.slack.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.telegram.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0327a8ad62..14a9b3c8bcd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,7 @@ Welcome to the lobster tank! 🦞 - `pnpm test:extension ` - `pnpm test:extension --list` to see valid extension ids - If you changed shared plugin or channel surfaces, run `pnpm test:contracts` + - For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins` - If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. - Ensure CI checks pass diff --git a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts b/extensions/discord/src/monitor/message-handler.inbound-context.test.ts similarity index 91% rename from extensions/discord/src/monitor/message-handler.inbound-contract.test.ts rename to extensions/discord/src/monitor/message-handler.inbound-context.test.ts index 6421d24a61a..6eb378e7bbb 100644 --- a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts +++ b/extensions/discord/src/monitor/message-handler.inbound-context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { inboundCtxCapture as capture } from "../../../../src/channels/plugins/contracts/inbound-contract-dispatch-mock.js"; import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; -import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import { processDiscordMessage } from "./message-handler.process.js"; import { @@ -8,7 +8,7 @@ import { createDiscordDirectMessageContextOverrides, } from "./message-handler.test-harness.js"; -describe("discord processDiscordMessage inbound contract", () => { +describe("discord processDiscordMessage inbound context", () => { it("passes a finalized MsgContext to dispatchInboundMessage", async () => { capture.ctx = undefined; const messageCtx = await createBaseDiscordMessageContext({ diff --git a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts similarity index 99% rename from extensions/signal/src/monitor/event-handler.inbound-contract.test.ts rename to extensions/signal/src/monitor/event-handler.inbound-context.test.ts index 9a6cfc0e90e..3aafda7fe3d 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -49,7 +49,7 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({ upsertChannelPairingRequest: vi.fn(), })); -describe("signal createSignalEventHandler inbound contract", () => { +describe("signal createSignalEventHandler inbound context", () => { beforeEach(() => { capture.ctx = undefined; sendTypingMock.mockReset().mockResolvedValue(true); diff --git a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts index 05836c43975..60222d4a7ab 100644 --- a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts +++ b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../../../../src/auto-reply/templating.js"; +import { buildDispatchInboundCaptureMock } from "../../../../src/channels/plugins/contracts/dispatch-inbound-capture.js"; import type { OpenClawConfig } from "../../../../src/config/types.js"; -import { buildDispatchInboundCaptureMock } from "../../../../test/helpers/dispatch-inbound-capture.js"; import { createBaseSignalEventHandlerDeps, createSignalReceiveEvent, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-context.test.ts similarity index 99% rename from extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts rename to extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-context.test.ts index 566c8a76e1e..c6db2affda3 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-context.test.ts @@ -109,7 +109,7 @@ vi.mock("../deliver-reply.js", () => ({ import { updateLastRouteInBackground } from "./last-route.js"; import { processMessage } from "./process-message.js"; -describe("web processMessage inbound contract", () => { +describe("web processMessage inbound context", () => { beforeEach(async () => { capturedCtx = undefined; capturedDispatchParams = undefined; diff --git a/package.json b/package.json index afbcb632ed0..c22a05548cd 100644 --- a/package.json +++ b/package.json @@ -488,7 +488,9 @@ "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", "test:channels": "vitest run --config vitest.channels.config.ts", - "test:contracts": "pnpm test -- src/channels/plugins/contracts src/plugins/contracts", + "test:contracts": "pnpm test:contracts:channels && pnpm test:contracts:plugins", + "test:contracts:channels": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/channels/plugins/contracts", + "test:contracts:plugins": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/plugins/contracts", "test:coverage": "vitest run --config vitest.unit.config.ts --coverage", "test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", diff --git a/src/channels/plugins/contracts/directory.contract.test.ts b/src/channels/plugins/contracts/directory.contract.test.ts index 97969adc35b..d664f003531 100644 --- a/src/channels/plugins/contracts/directory.contract.test.ts +++ b/src/channels/plugins/contracts/directory.contract.test.ts @@ -6,7 +6,9 @@ for (const entry of directoryContractRegistry) { describe(`${entry.id} directory contract`, () => { installChannelDirectoryContractSuite({ plugin: entry.plugin, - invokeLookups: entry.invokeLookups, + coverage: entry.coverage, + cfg: entry.cfg, + accountId: entry.accountId, }); }); } diff --git a/test/helpers/dispatch-inbound-capture.ts b/src/channels/plugins/contracts/dispatch-inbound-capture.ts similarity index 100% rename from test/helpers/dispatch-inbound-capture.ts rename to src/channels/plugins/contracts/dispatch-inbound-capture.ts diff --git a/test/helpers/inbound-contract-capture.ts b/src/channels/plugins/contracts/inbound-contract-capture.ts similarity index 76% rename from test/helpers/inbound-contract-capture.ts rename to src/channels/plugins/contracts/inbound-contract-capture.ts index ccc61d010f5..b74164c7a79 100644 --- a/test/helpers/inbound-contract-capture.ts +++ b/src/channels/plugins/contracts/inbound-contract-capture.ts @@ -1,4 +1,4 @@ -import type { MsgContext } from "../../src/auto-reply/templating.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; import { buildDispatchInboundCaptureMock } from "./dispatch-inbound-capture.js"; export type InboundContextCapture = { @@ -13,7 +13,7 @@ export async function buildDispatchInboundContextCapture( importOriginal: >() => Promise, capture: InboundContextCapture, ) { - const actual = await importOriginal(); + const actual = await importOriginal(); return buildDispatchInboundCaptureMock(actual, (ctx) => { capture.ctx = ctx as MsgContext; }); diff --git a/test/helpers/inbound-contract-dispatch-mock.ts b/src/channels/plugins/contracts/inbound-contract-dispatch-mock.ts similarity index 82% rename from test/helpers/inbound-contract-dispatch-mock.ts rename to src/channels/plugins/contracts/inbound-contract-dispatch-mock.ts index 6193ae245c1..05698d628c5 100644 --- a/test/helpers/inbound-contract-dispatch-mock.ts +++ b/src/channels/plugins/contracts/inbound-contract-dispatch-mock.ts @@ -4,6 +4,6 @@ import { buildDispatchInboundContextCapture } from "./inbound-contract-capture.j export const inboundCtxCapture = createInboundContextCapture(); -vi.mock("../../src/auto-reply/dispatch.js", async (importOriginal) => { +vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { return await buildDispatchInboundContextCapture(importOriginal, inboundCtxCapture); }); diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts new file mode 100644 index 00000000000..e90e5090e6b --- /dev/null +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -0,0 +1,299 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; +import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js"; +import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"; +import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { inboundCtxCapture } from "./inbound-contract-dispatch-mock.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const signalCapture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined })); +const bufferedReplyCapture = vi.hoisted(() => ({ + ctx: undefined as MsgContext | undefined, +})); +const dispatchInboundMessageMock = vi.hoisted(() => + vi.fn( + async (params: { + ctx: MsgContext; + replyOptions?: { onReplyStart?: () => void | Promise }; + }) => { + signalCapture.ctx = params.ctx; + await Promise.resolve(params.replyOptions?.onReplyStart?.()); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }, + ), +); + +vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: dispatchInboundMessageMock, + dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + }; +}); + +vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { + bufferedReplyCapture.ctx = params.ctx; + return { queuedFinal: false }; + }), +})); + +vi.mock("../../../../extensions/signal/src/send.js", () => ({ + sendMessageSignal: vi.fn(), + sendTypingSignal: vi.fn(async () => true), + sendReadReceiptSignal: vi.fn(async () => true), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn().mockResolvedValue([]), + upsertChannelPairingRequest: vi.fn(), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({ + trackBackgroundTask: (tasks: Set>, task: Promise) => { + tasks.add(task); + void task.finally(() => { + tasks.delete(task); + }); + }, + updateLastRouteInBackground: vi.fn(), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({ + deliverWebReply: vi.fn(async () => {}), +})); + +const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); +const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); +const { createSignalEventHandler } = + await import("../../../../extensions/signal/src/monitor/event-handler.js"); +const { createBaseSignalEventHandlerDeps, createSignalReceiveEvent } = + await import("../../../../extensions/signal/src/monitor/event-handler.test-harness.js"); +const { processMessage } = + await import("../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js"); + +function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config, + replyToMode: config.replyToMode, + replyToModeByChatType: config.replyToModeByChatType, + dm: config.dm, + }; +} + +function createSlackMessage(overrides: Partial): SlackMessageEvent { + return { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + ...overrides, + } as SlackMessageEvent; +} + +function makeWhatsAppProcessArgs(sessionStorePath: string) { + return { + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: {}, session: { store: sessionStorePath } } as any, + // oxlint-disable-next-line typescript/no-explicit-any + msg: { + id: "msg1", + from: "123@g.us", + to: "+15550001111", + chatType: "group", + body: "hi", + senderName: "Alice", + senderJid: "alice@s.whatsapp.net", + senderE164: "+15550002222", + groupSubject: "Test Group", + groupParticipants: [], + } as unknown as Record, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:group:123", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "123@g.us", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set>(), + rememberSentText: () => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + groupHistory: [], + // oxlint-disable-next-line typescript/no-explicit-any + } as any; +} + +async function removeDirEventually(dir: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } +} + +describe("channel inbound contract", () => { + let whatsappSessionDir = ""; + + beforeEach(() => { + inboundCtxCapture.ctx = undefined; + signalCapture.ctx = undefined; + bufferedReplyCapture.ctx = undefined; + dispatchInboundMessageMock.mockClear(); + }); + + afterEach(async () => { + if (whatsappSessionDir) { + await removeDirEventually(whatsappSessionDir); + whatsappSessionDir = ""; + } + }); + + it("keeps Discord inbound context finalized", async () => { + const messageCtx = await createBaseDiscordMessageContext({ + cfg: { messages: {} }, + ackReactionScope: "direct", + ...createDiscordDirectMessageContextOverrides(), + }); + + await processDiscordMessage(messageCtx); + + expect(inboundCtxCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(inboundCtxCapture.ctx!); + }); + + it("keeps Signal inbound context finalized", async () => { + const handler = createSignalEventHandler( + createBaseSignalEventHandlerDeps({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: { inbound: { debounceMs: 0 } } } as any, + historyLimit: 0, + }), + ); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + message: "hi", + attachments: [], + groupInfo: { groupId: "g1", groupName: "Test Group" }, + }, + }), + ); + + expect(signalCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(signalCapture.ctx!); + }); + + it("keeps Slack inbound context finalized", async () => { + const ctx = createInboundSlackTestContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as OpenClawConfig, + }); + // oxlint-disable-next-line typescript/no-explicit-any + ctx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const prepared = await prepareSlackMessage({ + ctx, + account: createSlackAccount(), + message: createSlackMessage({}), + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expectChannelInboundContextContract(prepared!.ctxPayload); + }); + + it("keeps Telegram inbound context finalized", async () => { + const { getLoadConfigMock, getOnHandler, onSpy, sendMessageSpy } = + await import("../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js"); + const { resetInboundDedupe } = await import("../../../auto-reply/reply/inbound-dedupe.js"); + + resetInboundDedupe(); + onSpy.mockReset(); + sendMessageSpy.mockReset(); + sendMessageSpy.mockResolvedValue({ message_id: 77 }); + getLoadConfigMock().mockReset(); + getLoadConfigMock().mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + } satisfies OpenClawConfig); + + const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js"); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + const payload = bufferedReplyCapture.ctx; + expect(payload).toBeTruthy(); + expectChannelInboundContextContract(payload!); + }); + + it("keeps WhatsApp inbound context finalized", async () => { + whatsappSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-")); + const sessionStorePath = path.join(whatsappSessionDir, "sessions.json"); + + await processMessage(makeWhatsAppProcessArgs(sessionStorePath)); + + expect(bufferedReplyCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(bufferedReplyCapture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.discord.contract.test.ts b/src/channels/plugins/contracts/inbound.discord.contract.test.ts deleted file mode 100644 index 6b168f7d244..00000000000 --- a/src/channels/plugins/contracts/inbound.discord.contract.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { inboundCtxCapture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; -import { expectChannelInboundContextContract } from "./suites.js"; - -const { processDiscordMessage } = - await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); -const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = - await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); - -describe("discord inbound contract", () => { - it("keeps inbound context finalized", async () => { - inboundCtxCapture.ctx = undefined; - const messageCtx = await createBaseDiscordMessageContext({ - cfg: { messages: {} }, - ackReactionScope: "direct", - ...createDiscordDirectMessageContextOverrides(), - }); - - await processDiscordMessage(messageCtx); - - expect(inboundCtxCapture.ctx).toBeTruthy(); - expectChannelInboundContextContract(inboundCtxCapture.ctx!); - }); -}); diff --git a/src/channels/plugins/contracts/inbound.signal.contract.test.ts b/src/channels/plugins/contracts/inbound.signal.contract.test.ts deleted file mode 100644 index abec31c0174..00000000000 --- a/src/channels/plugins/contracts/inbound.signal.contract.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createSignalEventHandler } from "../../../../extensions/signal/src/monitor/event-handler.js"; -import { - createBaseSignalEventHandlerDeps, - createSignalReceiveEvent, -} from "../../../../extensions/signal/src/monitor/event-handler.test-harness.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; -import { expectChannelInboundContextContract } from "./suites.js"; - -const capture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined })); -const dispatchInboundMessageMock = vi.hoisted(() => - vi.fn( - async (params: { - ctx: MsgContext; - replyOptions?: { onReplyStart?: () => void | Promise }; - }) => { - capture.ctx = params.ctx; - await Promise.resolve(params.replyOptions?.onReplyStart?.()); - return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; - }, - ), -); - -vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchInboundMessage: dispatchInboundMessageMock, - dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, - dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, - }; -}); - -vi.mock("../../../../extensions/signal/src/send.js", () => ({ - sendMessageSignal: vi.fn(), - sendTypingSignal: vi.fn(async () => true), - sendReadReceiptSignal: vi.fn(async () => true), -})); - -vi.mock("../../../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: vi.fn().mockResolvedValue([]), - upsertChannelPairingRequest: vi.fn(), -})); - -describe("signal inbound contract", () => { - beforeEach(() => { - capture.ctx = undefined; - dispatchInboundMessageMock.mockClear(); - }); - - it("keeps inbound context finalized", async () => { - const handler = createSignalEventHandler( - createBaseSignalEventHandlerDeps({ - // oxlint-disable-next-line typescript/no-explicit-any - cfg: { messages: { inbound: { debounceMs: 0 } } } as any, - historyLimit: 0, - }), - ); - - await handler( - createSignalReceiveEvent({ - dataMessage: { - message: "hi", - attachments: [], - groupInfo: { groupId: "g1", groupName: "Test Group" }, - }, - }), - ); - - expect(capture.ctx).toBeTruthy(); - expectChannelInboundContextContract(capture.ctx!); - }); -}); diff --git a/src/channels/plugins/contracts/inbound.slack.contract.test.ts b/src/channels/plugins/contracts/inbound.slack.contract.test.ts deleted file mode 100644 index e013bed3b4f..00000000000 --- a/src/channels/plugins/contracts/inbound.slack.contract.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; -import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js"; -import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"; -import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { expectChannelInboundContextContract } from "./suites.js"; - -function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { - return { - accountId: "default", - enabled: true, - botTokenSource: "config", - appTokenSource: "config", - userTokenSource: "none", - config, - replyToMode: config.replyToMode, - replyToModeByChatType: config.replyToModeByChatType, - dm: config.dm, - }; -} - -function createSlackMessage(overrides: Partial): SlackMessageEvent { - return { - channel: "D123", - channel_type: "im", - user: "U1", - text: "hi", - ts: "1.000", - ...overrides, - } as SlackMessageEvent; -} - -describe("slack inbound contract", () => { - it("keeps inbound context finalized", async () => { - const ctx = createInboundSlackTestContext({ - cfg: { - channels: { slack: { enabled: true } }, - } as OpenClawConfig, - }); - // oxlint-disable-next-line typescript/no-explicit-any - ctx.resolveUserName = async () => ({ name: "Alice" }) as any; - - const prepared = await prepareSlackMessage({ - ctx, - account: createSlackAccount(), - message: createSlackMessage({}), - opts: { source: "message" }, - }); - - expect(prepared).toBeTruthy(); - expectChannelInboundContextContract(prepared!.ctxPayload); - }); -}); diff --git a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts deleted file mode 100644 index a872964bd53..00000000000 --- a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { - getLoadConfigMock, - getOnHandler, - onSpy, - replySpy, -} from "../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { expectChannelInboundContextContract } from "./suites.js"; - -const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js"); - -describe("telegram inbound contract", () => { - const loadConfig = getLoadConfigMock(); - - beforeEach(() => { - onSpy.mockClear(); - replySpy.mockClear(); - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", - }, - }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - } satisfies OpenClawConfig); - }); - - it("keeps inbound context finalized", async () => { - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 42, type: "group", title: "Ops" }, - text: "hello", - date: 1736380800, - message_id: 2, - from: { - id: 99, - first_name: "Ada", - last_name: "Lovelace", - username: "ada", - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - const payload = replySpy.mock.calls[0]?.[0] as MsgContext | undefined; - expect(payload).toBeTruthy(); - expectChannelInboundContextContract(payload!); - }); -}); diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts deleted file mode 100644 index 108131226aa..00000000000 --- a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { processMessage } from "../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; -import { expectChannelInboundContextContract } from "./suites.js"; - -const capture = vi.hoisted(() => ({ - ctx: undefined as MsgContext | undefined, -})); - -vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { - capture.ctx = params.ctx; - return { queuedFinal: false }; - }), -})); - -vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({ - trackBackgroundTask: (tasks: Set>, task: Promise) => { - tasks.add(task); - void task.finally(() => { - tasks.delete(task); - }); - }, - updateLastRouteInBackground: vi.fn(), -})); - -vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({ - deliverWebReply: vi.fn(async () => {}), -})); - -function makeProcessArgs(sessionStorePath: string) { - return { - // oxlint-disable-next-line typescript/no-explicit-any - cfg: { messages: {}, session: { store: sessionStorePath } } as any, - // oxlint-disable-next-line typescript/no-explicit-any - msg: { - id: "msg1", - from: "123@g.us", - to: "+15550001111", - chatType: "group", - body: "hi", - senderName: "Alice", - senderJid: "alice@s.whatsapp.net", - senderE164: "+15550002222", - groupSubject: "Test Group", - groupParticipants: [], - } as unknown as Record, - route: { - agentId: "main", - accountId: "default", - sessionKey: "agent:main:whatsapp:group:123", - // oxlint-disable-next-line typescript/no-explicit-any - } as any, - groupHistoryKey: "123@g.us", - groupHistories: new Map(), - groupMemberNames: new Map(), - connectionId: "conn", - verbose: false, - maxMediaBytes: 1, - // oxlint-disable-next-line typescript/no-explicit-any - replyResolver: (async () => undefined) as any, - // oxlint-disable-next-line typescript/no-explicit-any - replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, - backgroundTasks: new Set>(), - rememberSentText: () => {}, - echoHas: () => false, - echoForget: () => {}, - buildCombinedEchoKey: () => "echo", - groupHistory: [], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; -} - -async function removeDirEventually(dir: string) { - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } -} - -describe("whatsapp inbound contract", () => { - let sessionDir = ""; - - afterEach(async () => { - capture.ctx = undefined; - if (sessionDir) { - await removeDirEventually(sessionDir); - sessionDir = ""; - } - }); - - it("keeps inbound context finalized", async () => { - sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-")); - const sessionStorePath = path.join(sessionDir, "sessions.json"); - - await processMessage(makeProcessArgs(sessionStorePath)); - - expect(capture.ctx).toBeTruthy(); - expectChannelInboundContextContract(capture.ctx!); - }); -}); diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts index a379792253a..64b5bc6c369 100644 --- a/src/channels/plugins/contracts/registry.contract.test.ts +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -1,25 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { actionContractRegistry, + channelPluginSurfaceKeys, directoryContractRegistry, pluginContractRegistry, + sessionBindingContractRegistry, setupContractRegistry, statusContractRegistry, surfaceContractRegistry, threadingContractRegistry, - type ChannelPluginSurface, } from "./registry.js"; -const orderedSurfaceKeys = [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", -] as const satisfies readonly ChannelPluginSurface[]; +function listFilesRecursively(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursively(fullPath)); + continue; + } + files.push(fullPath); + } + return files; +} + +function discoverSessionBindingChannels() { + const extensionsDir = path.resolve(import.meta.dirname, "../../../../extensions"); + const channels = new Set(); + for (const filePath of listFilesRecursively(extensionsDir)) { + if (!filePath.endsWith(".ts") || filePath.endsWith(".test.ts")) { + continue; + } + const source = fs.readFileSync(filePath, "utf8"); + for (const match of source.matchAll( + /registerSessionBindingAdapter\(\{[\s\S]*?channel:\s*"([^"]+)"/g, + )) { + channels.add(match[1]); + } + } + return [...channels].toSorted(); +} describe("channel contract registry", () => { it("does not duplicate channel plugin ids", () => { @@ -35,7 +58,7 @@ describe("channel contract registry", () => { it("declares the actual owned channel plugin surfaces explicitly", () => { for (const entry of surfaceContractRegistry) { - const actual = orderedSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface])); + const actual = channelPluginSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface])); expect([...entry.surfaces].toSorted()).toEqual(actual.toSorted()); } }); @@ -84,7 +107,7 @@ describe("channel contract registry", () => { } }); - it("only installs deep directory coverage for plugins that declare directory", () => { + it("covers every declared directory surface with an explicit contract level", () => { const directorySurfaceIds = new Set( surfaceContractRegistry .filter((entry) => entry.surfaces.includes("directory")) @@ -93,5 +116,27 @@ describe("channel contract registry", () => { for (const entry of directoryContractRegistry) { expect(directorySurfaceIds.has(entry.id)).toBe(true); } + expect(directoryContractRegistry.map((entry) => entry.id).toSorted()).toEqual( + [...directorySurfaceIds].toSorted(), + ); + }); + + it("only installs lookup 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.filter( + (candidate) => candidate.coverage === "lookups", + )) { + expect(directorySurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("keeps session binding coverage aligned with registered session binding adapters", () => { + expect(sessionBindingContractRegistry.map((entry) => entry.id).toSorted()).toEqual( + discoverSessionBindingChannels(), + ); }); }); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 617aa9c2221..4e87f1cfedd 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,11 +1,27 @@ import { expect, vi } from "vitest"; +import { + __testing as discordThreadBindingTesting, + createThreadBindingManager as createDiscordThreadBindingManager, +} from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js"; +import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/src/thread-bindings.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; +import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/src/thread-bindings.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import { + getSessionBindingService, + type SessionBindingCapabilities, + type SessionBindingRecord, +} from "../../../infra/outbound/session-binding-service.js"; import { resolveDefaultLineAccountId, resolveLineAccount, listLineAccountIds, } from "../../../line/accounts.js"; -import { bundledChannelRuntimeSetters, requireBundledChannelPlugin } from "../bundled.js"; +import { + bundledChannelPlugins, + bundledChannelRuntimeSetters, + requireBundledChannelPlugin, +} from "../bundled.js"; import type { ChannelPlugin } from "../types.js"; type PluginContractEntry = { @@ -57,6 +73,17 @@ type StatusContractEntry = { }>; }; +export const channelPluginSurfaceKeys = [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", +] as const; + export type ChannelPluginSurface = | "actions" | "setup" @@ -92,7 +119,18 @@ type ThreadingContractEntry = { type DirectoryContractEntry = { id: string; plugin: Pick; - invokeLookups: boolean; + coverage: "lookups" | "presence"; + cfg?: OpenClawConfig; + accountId?: string; +}; + +type SessionBindingContractEntry = { + id: string; + expectedCapabilities: SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities; + bindAndResolve: () => Promise; + unbindAndVerify: (binding: SessionBindingRecord) => Promise; + cleanup: () => Promise | void; }; const telegramListActionsMock = vi.fn(); @@ -133,28 +171,18 @@ bundledChannelRuntimeSetters.setLineRuntime({ }, } as never); -export const pluginContractRegistry: PluginContractEntry[] = [ - { id: "bluebubbles", plugin: requireBundledChannelPlugin("bluebubbles") }, - { id: "discord", plugin: requireBundledChannelPlugin("discord") }, - { id: "feishu", plugin: requireBundledChannelPlugin("feishu") }, - { id: "googlechat", plugin: requireBundledChannelPlugin("googlechat") }, - { id: "imessage", plugin: requireBundledChannelPlugin("imessage") }, - { id: "irc", plugin: requireBundledChannelPlugin("irc") }, - { id: "line", plugin: requireBundledChannelPlugin("line") }, - { id: "matrix", plugin: requireBundledChannelPlugin("matrix") }, - { id: "mattermost", plugin: requireBundledChannelPlugin("mattermost") }, - { id: "msteams", plugin: requireBundledChannelPlugin("msteams") }, - { id: "nextcloud-talk", plugin: requireBundledChannelPlugin("nextcloud-talk") }, - { id: "nostr", plugin: requireBundledChannelPlugin("nostr") }, - { id: "signal", plugin: requireBundledChannelPlugin("signal") }, - { id: "slack", plugin: requireBundledChannelPlugin("slack") }, - { id: "synology-chat", plugin: requireBundledChannelPlugin("synology-chat") }, - { id: "telegram", plugin: requireBundledChannelPlugin("telegram") }, - { id: "tlon", plugin: requireBundledChannelPlugin("tlon") }, - { id: "whatsapp", plugin: requireBundledChannelPlugin("whatsapp") }, - { id: "zalo", plugin: requireBundledChannelPlugin("zalo") }, - { id: "zalouser", plugin: requireBundledChannelPlugin("zalouser") }, -]; +setMatrixRuntime({ + state: { + resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), + }, +} as never); + +export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map( + (plugin) => ({ + id: plugin.id, + plugin, + }), +); export const actionContractRegistry: ActionsContractEntry[] = [ { @@ -500,189 +528,13 @@ export const statusContractRegistry: StatusContractEntry[] = [ }, ]; -export const surfaceContractRegistry: SurfaceContractEntry[] = [ - { - id: "bluebubbles", - plugin: requireBundledChannelPlugin("bluebubbles"), - surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"], - }, - { - id: "discord", - plugin: requireBundledChannelPlugin("discord"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "feishu", - plugin: requireBundledChannelPlugin("feishu"), - surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], - }, - { - id: "googlechat", - plugin: requireBundledChannelPlugin("googlechat"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "imessage", - plugin: requireBundledChannelPlugin("imessage"), - surfaces: ["setup", "status", "outbound", "messaging", "gateway"], - }, - { - id: "irc", - plugin: requireBundledChannelPlugin("irc"), - surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], - }, - { - id: "line", - plugin: requireBundledChannelPlugin("line"), - surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], - }, - { - id: "matrix", - plugin: requireBundledChannelPlugin("matrix"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "mattermost", - plugin: requireBundledChannelPlugin("mattermost"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "msteams", - plugin: requireBundledChannelPlugin("msteams"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "nextcloud-talk", - plugin: requireBundledChannelPlugin("nextcloud-talk"), - surfaces: ["setup", "status", "outbound", "messaging", "gateway"], - }, - { - id: "nostr", - plugin: requireBundledChannelPlugin("nostr"), - surfaces: ["setup", "status", "outbound", "messaging", "gateway"], - }, - { - id: "signal", - plugin: requireBundledChannelPlugin("signal"), - surfaces: ["actions", "setup", "status", "outbound", "messaging", "gateway"], - }, - { - id: "slack", - plugin: requireBundledChannelPlugin("slack"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "synology-chat", - plugin: requireBundledChannelPlugin("synology-chat"), - surfaces: ["setup", "outbound", "messaging", "directory", "gateway"], - }, - { - id: "telegram", - plugin: requireBundledChannelPlugin("telegram"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "tlon", - plugin: requireBundledChannelPlugin("tlon"), - surfaces: ["setup", "status", "outbound", "messaging", "gateway"], - }, - { - id: "whatsapp", - plugin: requireBundledChannelPlugin("whatsapp"), - surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], - }, - { - id: "zalo", - plugin: requireBundledChannelPlugin("zalo"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, - { - id: "zalouser", - plugin: requireBundledChannelPlugin("zalouser"), - surfaces: [ - "actions", - "setup", - "status", - "outbound", - "messaging", - "threading", - "directory", - "gateway", - ], - }, -]; +export const surfaceContractRegistry: SurfaceContractEntry[] = bundledChannelPlugins.map( + (plugin) => ({ + id: plugin.id, + plugin, + surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])), + }), +); export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry .filter((entry) => entry.surfaces.includes("threading")) @@ -691,12 +543,258 @@ export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContra plugin: entry.plugin, })); -const directoryShapeOnlyIds = new Set(["matrix", "whatsapp", "zalouser"]); +const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]); +const matrixDirectoryCfg = { + channels: { + matrix: { + enabled: true, + homeserver: "https://matrix.example.com", + userId: "@lobster:example.com", + accessToken: "matrix-access-token", + dm: { + allowFrom: ["matrix:@alice:example.com"], + }, + groupAllowFrom: ["matrix:@team:example.com"], + groups: { + "!room:example.com": { + users: ["matrix:@alice:example.com"], + }, + }, + }, + }, +} as OpenClawConfig; export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry .filter((entry) => entry.surfaces.includes("directory")) .map((entry) => ({ id: entry.id, plugin: entry.plugin, - invokeLookups: !directoryShapeOnlyIds.has(entry.id), + coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups", + ...(entry.id === "matrix" ? { cfg: matrixDirectoryCfg } : {}), })); + +const baseSessionBindingCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ + { + id: "discord", + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }, + getCapabilities: () => { + createDiscordThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + return getSessionBindingService().getCapabilities({ + channel: "discord", + accountId: "default", + }); + }, + bindAndResolve: async () => { + createDiscordThreadBindingManager({ + 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", + }, + }); + expect( + service.resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "channel:123456789012345678", + }), + )?.toMatchObject({ + targetSessionKey: "agent:discord:child:thread-1", + }); + return binding; + }, + unbindAndVerify: async (binding) => { + 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(); + }, + cleanup: async () => { + const manager = createDiscordThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + manager.stop(); + discordThreadBindingTesting.resetThreadBindingsForTests(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "channel:123456789012345678", + }), + ).toBeNull(); + }, + }, + { + id: "feishu", + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }, + getCapabilities: () => { + createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" }); + return getSessionBindingService().getCapabilities({ + channel: "feishu", + accountId: "default", + }); + }, + bindAndResolve: async () => { + 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", + }, + }); + 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; + }, + unbindAndVerify: async (binding) => { + 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(); + }, + cleanup: async () => { + const manager = createFeishuThreadBindingManager({ + cfg: baseSessionBindingCfg, + accountId: "default", + }); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + ).toBeNull(); + }, + }, + { + id: "telegram", + 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; + }, + unbindAndVerify: async (binding) => { + 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(); + }, + 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/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts index a21632c4515..b8201569cde 100644 --- a/src/channels/plugins/contracts/session-binding.contract.test.ts +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -1,151 +1,26 @@ -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 { beforeEach, describe } 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 { __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 baseCfg = { - session: { mainKey: "main", scope: "per-sender" }, -} satisfies OpenClawConfig; - beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); + discordThreadBindingTesting.resetThreadBindingsForTests(); 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(); - }, +for (const entry of sessionBindingContractRegistry) { + describe(`${entry.id} session binding contract`, () => { + installSessionBindingContractSuite({ + expectedCapabilities: entry.expectedCapabilities, + getCapabilities: entry.getCapabilities, + bindAndResolve: entry.bindAndResolve, + unbindAndVerify: entry.unbindAndVerify, + cleanup: entry.cleanup, + }); }); -}); - -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 461be379261..cc442b5ef20 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -393,18 +393,20 @@ export function installChannelThreadingContractSuite(params: { export function installChannelDirectoryContractSuite(params: { plugin: Pick; - invokeLookups?: boolean; + coverage?: "lookups" | "presence"; + cfg?: OpenClawConfig; + accountId?: string; }) { it("exposes the base directory contract", async () => { const directory = params.plugin.directory; expect(directory).toBeDefined(); - if (params.invokeLookups === false) { + if (params.coverage === "presence") { return; } const self = await directory?.self?.({ - cfg: {} as OpenClawConfig, - accountId: "default", + cfg: params.cfg ?? ({} as OpenClawConfig), + accountId: params.accountId ?? "default", runtime: contractRuntime, }); if (self) { @@ -413,8 +415,8 @@ export function installChannelDirectoryContractSuite(params: { const peers = (await directory?.listPeers?.({ - cfg: {} as OpenClawConfig, - accountId: "default", + cfg: params.cfg ?? ({} as OpenClawConfig), + accountId: params.accountId ?? "default", query: "", limit: 5, runtime: contractRuntime, @@ -426,8 +428,8 @@ export function installChannelDirectoryContractSuite(params: { const groups = (await directory?.listGroups?.({ - cfg: {} as OpenClawConfig, - accountId: "default", + cfg: params.cfg ?? ({} as OpenClawConfig), + accountId: params.accountId ?? "default", query: "", limit: 5, runtime: contractRuntime, @@ -439,8 +441,8 @@ export function installChannelDirectoryContractSuite(params: { if (directory?.listGroupMembers && groups[0]?.id) { const members = await directory.listGroupMembers({ - cfg: {} as OpenClawConfig, - accountId: "default", + cfg: params.cfg ?? ({} as OpenClawConfig), + accountId: params.accountId ?? "default", groupId: groups[0].id, limit: 5, runtime: contractRuntime, @@ -456,6 +458,7 @@ export function installChannelDirectoryContractSuite(params: { export function installSessionBindingContractSuite(params: { getCapabilities: () => SessionBindingCapabilities; bindAndResolve: () => Promise; + unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; expectedCapabilities: SessionBindingCapabilities; }) { @@ -477,6 +480,11 @@ export function installSessionBindingContractSuite(params: { expect(typeof binding.boundAt).toBe("number"); }); + it("unbinds a registered binding through the shared service", async () => { + const binding = await params.bindAndResolve(); + await params.unbindAndVerify(binding); + }); + it("cleans up registered bindings", async () => { await params.cleanup(); }); diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index b33ef2740e8..631df701933 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -10,8 +10,9 @@ import { setupAuthTestEnv, } from "../../commands/test-wizard-helpers.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; +import { buildProviderPluginMethodChoice } from "../provider-wizard.js"; import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; -import { providerContractRegistry } from "./registry.js"; +import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js"; type ResolvePluginProviders = typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders; @@ -101,11 +102,7 @@ describe("provider auth-choice contract", () => { beforeEach(() => { resolvePreferredProviderPluginProvidersMock.mockReset(); - resolvePreferredProviderPluginProvidersMock.mockReturnValue([ - ...new Map( - providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]), - ).values(), - ]); + resolvePreferredProviderPluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); }); afterEach(async () => { @@ -121,21 +118,34 @@ describe("provider auth-choice contract", () => { activeStateDir = null; }); - it("maps plugin-backed auth choices through the shared preferred-provider resolver", async () => { - const scenarios = [ - { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, - { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, - { authChoice: "minimax-global-oauth" as const, expectedProvider: "minimax-portal" }, - { authChoice: "modelstudio-api-key" as const, expectedProvider: "modelstudio" }, - { authChoice: "ollama" as const, expectedProvider: "ollama" }, - { authChoice: "unknown", expectedProvider: undefined }, - ] as const; + it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => { + const pluginFallbackScenarios = [ + "github-copilot", + "qwen-portal", + "minimax-portal", + "modelstudio", + "ollama", + ].map((providerId) => { + const provider = requireProviderContractProvider(providerId); + return { + authChoice: buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"), + expectedProvider: provider.id, + }; + }); - for (const scenario of scenarios) { + for (const scenario of pluginFallbackScenarios) { + resolvePreferredProviderPluginProvidersMock.mockClear(); await expect( - resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice }), + resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice as AuthChoice }), ).resolves.toBe(scenario.expectedProvider); + expect(resolvePreferredProviderPluginProvidersMock).toHaveBeenCalled(); } + + resolvePreferredProviderPluginProvidersMock.mockClear(); + await expect( + resolvePreferredProviderForAuthChoice({ choice: "unknown" as AuthChoice }), + ).resolves.toBe(undefined); + expect(resolvePreferredProviderPluginProvidersMock).toHaveBeenCalled(); }); it("applies qwen portal auth choices through the shared plugin-provider path", async () => { diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 16a93d30dbe..dcfe0c86f6a 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { providerContractRegistry } from "./registry.js"; - -function uniqueProviders() { - return [ - ...new Map( - providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]), - ).values(), - ]; -} +import { + providerContractPluginIds, + resolveProviderContractProvidersForPluginIds, + uniqueProviderContractProviders, +} from "./registry.js"; const resolvePluginProvidersMock = vi.fn(); const resolveOwningPluginIdsForProviderMock = vi.fn(); @@ -30,12 +26,10 @@ const { describe("provider catalog contract", () => { beforeEach(() => { - const providers = uniqueProviders(); - const providerIds = [...new Set(providerContractRegistry.map((entry) => entry.pluginId))]; resetProviderRuntimeHookCacheForTest(); resolveOwningPluginIdsForProviderMock.mockReset(); - resolveOwningPluginIdsForProviderMock.mockReturnValue(providerIds); + resolveOwningPluginIdsForProviderMock.mockReturnValue(providerContractPluginIds); resolveNonBundledProviderPluginIdsMock.mockReset(); resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); @@ -44,12 +38,9 @@ describe("provider catalog contract", () => { resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { const onlyPluginIds = params?.onlyPluginIds; if (!onlyPluginIds || onlyPluginIds.length === 0) { - return providers; + return uniqueProviderContractProviders; } - const allowed = new Set(onlyPluginIds); - return providerContractRegistry - .filter((entry) => allowed.has(entry.pluginId)) - .map((entry) => entry.provider); + return resolveProviderContractProvidersForPluginIds(onlyPluginIds); }); }); diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 740366394a6..aa7cf2ed1bc 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; import { __testing as providerTesting } from "../providers.js"; import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; -import { providerContractRegistry, webSearchProviderContractRegistry } from "./registry.js"; +import { + providerContractPluginIds, + webSearchProviderContractRegistry, +} from "./registry.js"; function uniqueSortedPluginIds(values: string[]) { return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); @@ -19,7 +22,7 @@ describe("plugin loader contract", () => { it("keeps bundled provider compatibility wired to the provider registry", () => { const providerPluginIds = uniqueSortedPluginIds( - providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)), + providerContractPluginIds.map(normalizeProviderContractPluginId), ); const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ config: { @@ -46,7 +49,7 @@ describe("plugin loader contract", () => { it("keeps vitest bundled provider enablement wired to the provider registry", () => { const providerPluginIds = uniqueSortedPluginIds( - providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)), + providerContractPluginIds.map(normalizeProviderContractPluginId), ); const compatConfig = providerTesting.withBundledProviderVitestCompat({ config: undefined, diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 0f6d588ea1a..f7b89c2296e 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; +import { loadPluginManifestRegistry } from "../manifest-registry.js"; +import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, + providerContractPluginIds, providerContractRegistry, speechProviderContractRegistry, webSearchProviderContractRegistry, @@ -84,6 +87,27 @@ describe("plugin contract registry", () => { expect(ids).toEqual([...new Set(ids)]); }); + it("covers every bundled provider plugin discovered from manifests", () => { + const bundledProviderPluginIds = loadPluginManifestRegistry({}) + .plugins.filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); + + expect(providerContractPluginIds).toEqual(bundledProviderPluginIds); + }); + + it("covers every bundled web search plugin from the shared resolver", () => { + const bundledWebSearchPluginIds = resolvePluginWebSearchProviders({}) + .map((provider) => provider.pluginId) + .toSorted((left, right) => left.localeCompare(right)); + + expect( + [...new Set(webSearchProviderContractRegistry.map((entry) => entry.pluginId))].toSorted( + (left, right) => left.localeCompare(right), + ), + ).toEqual(bundledWebSearchPluginIds); + }); + it("keeps multi-provider plugin ownership explicit", () => { expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]); expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]); @@ -146,6 +170,23 @@ describe("plugin contract registry", () => { }); }); + it("tracks every provider, speech, media, or web search plugin in the registration registry", () => { + const expectedPluginIds = [ + ...new Set([ + ...providerContractRegistry.map((entry) => entry.pluginId), + ...speechProviderContractRegistry.map((entry) => entry.pluginId), + ...mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId), + ...webSearchProviderContractRegistry.map((entry) => entry.pluginId), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + + expect( + pluginRegistrationContractRegistry + .map((entry) => entry.pluginId) + .toSorted((left, right) => left.localeCompare(right)), + ).toEqual(expectedPluginIds); + }); + it("keeps bundled speech voice-list support explicit", () => { expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function)); expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function)); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index cd58bf41de2..8247b8b273d 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,3 +1,4 @@ +import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js"; import anthropicPlugin from "../../../extensions/anthropic/index.js"; import bravePlugin from "../../../extensions/brave/index.js"; import byteplusPlugin from "../../../extensions/byteplus/index.js"; @@ -72,6 +73,7 @@ type PluginRegistrationContractEntry = { }; const bundledProviderPlugins: RegistrablePlugin[] = [ + amazonBedrockPlugin, anthropicPlugin, byteplusPlugin, cloudflareAiGatewayPlugin, @@ -150,6 +152,35 @@ export const providerContractRegistry: ProviderContractEntry[] = buildCapability select: (captured) => captured.providers, }); +export const uniqueProviderContractProviders: ProviderPlugin[] = [ + ...new Map(providerContractRegistry.map((entry) => [entry.provider.id, entry.provider])).values(), +]; + +export const providerContractPluginIds = [ + ...new Set(providerContractRegistry.map((entry) => entry.pluginId)), +].toSorted((left, right) => left.localeCompare(right)); + +export function requireProviderContractProvider(providerId: string): ProviderPlugin { + const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId); + if (!provider) { + throw new Error(`provider contract entry missing for ${providerId}`); + } + return provider; +} + +export function resolveProviderContractProvidersForPluginIds( + pluginIds: readonly string[], +): ProviderPlugin[] { + const allowed = new Set(pluginIds); + return [ + ...new Map( + providerContractRegistry + .filter((entry) => allowed.has(entry.pluginId)) + .map((entry) => [entry.provider.id, entry.provider]), + ).values(), + ]; +} + export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] = bundledWebSearchPlugins.flatMap((plugin) => { const captured = captureRegistrations(plugin); diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index ee8503d88bf..073ad01c960 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; import type { ProviderRuntimeModel } from "../types.js"; +import { requireProviderContractProvider } from "./registry.js"; const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn()); @@ -17,16 +18,6 @@ vi.mock("../../providers/qwen-portal-oauth.js", () => ({ refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, })); -const { providerContractRegistry } = await import("./registry.js"); - -function requireProvider(providerId: string) { - const entry = providerContractRegistry.find((candidate) => candidate.provider.id === providerId); - if (!entry) { - throw new Error(`provider contract entry missing for ${providerId}`); - } - return entry.provider; -} - function createModel(overrides: Partial & Pick) { return { id: overrides.id, @@ -45,7 +36,7 @@ function createModel(overrides: Partial & Pick { describe("anthropic", () => { it("owns anthropic 4.6 forward-compat resolution", () => { - const provider = requireProvider("anthropic"); + const provider = requireProviderContractProvider("anthropic"); const model = provider.resolveDynamicModel?.({ provider: "anthropic", modelId: "claude-sonnet-4.6-20260219", @@ -71,7 +62,7 @@ describe("provider runtime contract", () => { }); it("owns usage auth resolution", async () => { - const provider = requireProvider("anthropic"); + const provider = requireProviderContractProvider("anthropic"); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -88,7 +79,7 @@ describe("provider runtime contract", () => { }); it("owns auth doctor hint generation", () => { - const provider = requireProvider("anthropic"); + const provider = requireProviderContractProvider("anthropic"); const hint = provider.buildAuthDoctorHint?.({ provider: "anthropic", profileId: "anthropic:default", @@ -121,7 +112,7 @@ describe("provider runtime contract", () => { }); it("owns usage snapshot fetching", async () => { - const provider = requireProvider("anthropic"); + const provider = requireProviderContractProvider("anthropic"); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("api.anthropic.com/api/oauth/usage")) { return makeResponse(200, { @@ -154,7 +145,7 @@ describe("provider runtime contract", () => { describe("github-copilot", () => { it("owns Copilot-specific forward-compat fallbacks", () => { - const provider = requireProvider("github-copilot"); + const provider = requireProviderContractProvider("github-copilot"); const model = provider.resolveDynamicModel?.({ provider: "github-copilot", modelId: "gpt-5.3-codex", @@ -181,7 +172,7 @@ describe("provider runtime contract", () => { describe("google", () => { it("owns google direct gemini 3.1 forward-compat resolution", () => { - const provider = requireProvider("google"); + const provider = requireProviderContractProvider("google"); const model = provider.resolveDynamicModel?.({ provider: "google", modelId: "gemini-3.1-pro-preview", @@ -213,7 +204,7 @@ describe("provider runtime contract", () => { describe("google-gemini-cli", () => { it("owns gemini cli 3.1 forward-compat resolution", () => { - const provider = requireProvider("google-gemini-cli"); + const provider = requireProviderContractProvider("google-gemini-cli"); const model = provider.resolveDynamicModel?.({ provider: "google-gemini-cli", modelId: "gemini-3.1-pro-preview", @@ -241,7 +232,7 @@ describe("provider runtime contract", () => { }); it("owns usage-token parsing", async () => { - const provider = requireProvider("google-gemini-cli"); + const provider = requireProviderContractProvider("google-gemini-cli"); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -260,7 +251,7 @@ describe("provider runtime contract", () => { }); it("owns OAuth auth-profile formatting", () => { - const provider = requireProvider("google-gemini-cli"); + const provider = requireProviderContractProvider("google-gemini-cli"); expect( provider.formatApiKey?.({ @@ -275,7 +266,7 @@ describe("provider runtime contract", () => { }); it("owns usage snapshot fetching", async () => { - const provider = requireProvider("google-gemini-cli"); + const provider = requireProviderContractProvider("google-gemini-cli"); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { return makeResponse(200, { @@ -309,7 +300,7 @@ describe("provider runtime contract", () => { describe("openai", () => { it("owns openai gpt-5.4 forward-compat resolution", () => { - const provider = requireProvider("openai"); + const provider = requireProviderContractProvider("openai"); const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4-pro", @@ -337,7 +328,7 @@ describe("provider runtime contract", () => { }); it("owns direct openai transport normalization", () => { - const provider = requireProvider("openai"); + const provider = requireProviderContractProvider("openai"); expect( provider.normalizeResolvedModel?.({ provider: "openai", @@ -360,7 +351,7 @@ describe("provider runtime contract", () => { describe("openai-codex", () => { it("owns refresh fallback for accountId extraction failures", async () => { - const provider = requireProvider("openai-codex"); + const provider = requireProviderContractProvider("openai-codex"); const credential = { type: "oauth" as const, provider: "openai-codex", @@ -376,7 +367,7 @@ describe("provider runtime contract", () => { }); it("owns forward-compat codex models", () => { - const provider = requireProvider("openai-codex"); + const provider = requireProviderContractProvider("openai-codex"); const model = provider.resolveDynamicModel?.({ provider: "openai-codex", modelId: "gpt-5.4", @@ -403,7 +394,7 @@ describe("provider runtime contract", () => { }); it("owns codex transport defaults", () => { - const provider = requireProvider("openai-codex"); + const provider = requireProviderContractProvider("openai-codex"); expect( provider.prepareExtraParams?.({ provider: "openai-codex", @@ -417,7 +408,7 @@ describe("provider runtime contract", () => { }); it("owns usage snapshot fetching", async () => { - const provider = requireProvider("openai-codex"); + const provider = requireProviderContractProvider("openai-codex"); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("chatgpt.com/backend-api/wham/usage")) { return makeResponse(200, { @@ -455,7 +446,7 @@ describe("provider runtime contract", () => { describe("qwen-portal", () => { it("owns OAuth refresh", async () => { - const provider = requireProvider("qwen-portal"); + const provider = requireProviderContractProvider("qwen-portal"); const credential = { type: "oauth" as const, provider: "qwen-portal", @@ -478,7 +469,7 @@ describe("provider runtime contract", () => { describe("zai", () => { it("owns glm-5 forward-compat resolution", () => { - const provider = requireProvider("zai"); + const provider = requireProviderContractProvider("zai"); const model = provider.resolveDynamicModel?.({ provider: "zai", modelId: "glm-5", @@ -507,7 +498,7 @@ describe("provider runtime contract", () => { }); it("owns usage auth resolution", async () => { - const provider = requireProvider("zai"); + const provider = requireProviderContractProvider("zai"); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -524,7 +515,7 @@ describe("provider runtime contract", () => { }); it("owns usage snapshot fetching", async () => { - const provider = requireProvider("zai"); + const provider = requireProviderContractProvider("zai"); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) { return makeResponse(200, { diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 4ebcedb17d9..9af9d21d411 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -1,14 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../types.js"; -import { providerContractRegistry } from "./registry.js"; - -function uniqueProviders(): ProviderPlugin[] { - return [ - ...new Map( - providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]), - ).values(), - ]; -} +import { providerContractPluginIds, uniqueProviderContractProviders } from "./registry.js"; const resolvePluginProvidersMock = vi.fn(); @@ -81,18 +73,16 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { describe("provider wizard contract", () => { beforeEach(() => { - const providers = uniqueProviders(); resolvePluginProvidersMock.mockReset(); - resolvePluginProvidersMock.mockReturnValue(providers); + resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); }); it("exposes every registered provider setup choice through the shared wizard layer", () => { - const providers = uniqueProviders(); const options = resolveProviderWizardOptions({ config: { plugins: { enabled: true, - allow: [...new Set(providerContractRegistry.map((entry) => entry.pluginId))], + allow: providerContractPluginIds, slots: { memory: "none", }, @@ -103,18 +93,16 @@ describe("provider wizard contract", () => { expect( options.map((option) => option.value).toSorted((left, right) => left.localeCompare(right)), - ).toEqual(resolveExpectedWizardChoiceValues(providers)); + ).toEqual(resolveExpectedWizardChoiceValues(uniqueProviderContractProviders)); expect(options.map((option) => option.value)).toEqual([ ...new Set(options.map((option) => option.value)), ]); }); it("round-trips every shared wizard choice back to its provider and auth method", () => { - const providers = uniqueProviders(); - for (const option of resolveProviderWizardOptions({ config: {}, env: process.env })) { const resolved = resolveProviderPluginChoice({ - providers, + providers: uniqueProviderContractProviders, choice: option.value, }); expect(resolved).not.toBeNull(); @@ -124,15 +112,14 @@ describe("provider wizard contract", () => { }); it("exposes every registered model-picker entry through the shared wizard layer", () => { - const providers = uniqueProviders(); const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env }); expect( entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)), - ).toEqual(resolveExpectedModelPickerValues(providers)); + ).toEqual(resolveExpectedModelPickerValues(uniqueProviderContractProviders)); for (const entry of entries) { const resolved = resolveProviderPluginChoice({ - providers, + providers: uniqueProviderContractProviders, choice: entry.value, }); expect(resolved).not.toBeNull();