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
This commit is contained in:
parent
0bf11c1d69
commit
6c866b8543
@ -93,6 +93,7 @@ Welcome to the lobster tank! 🦞
|
|||||||
- `pnpm test:extension <extension-name>`
|
- `pnpm test:extension <extension-name>`
|
||||||
- `pnpm test:extension --list` to see valid extension ids
|
- `pnpm test:extension --list` to see valid extension ids
|
||||||
- If you changed shared plugin or channel surfaces, run `pnpm test:contracts`
|
- 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 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.
|
- 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
|
- Ensure CI checks pass
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { 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 type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||||
import { processDiscordMessage } from "./message-handler.process.js";
|
import { processDiscordMessage } from "./message-handler.process.js";
|
||||||
import {
|
import {
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
createDiscordDirectMessageContextOverrides,
|
createDiscordDirectMessageContextOverrides,
|
||||||
} from "./message-handler.test-harness.js";
|
} from "./message-handler.test-harness.js";
|
||||||
|
|
||||||
describe("discord processDiscordMessage inbound contract", () => {
|
describe("discord processDiscordMessage inbound context", () => {
|
||||||
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
|
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
|
||||||
capture.ctx = undefined;
|
capture.ctx = undefined;
|
||||||
const messageCtx = await createBaseDiscordMessageContext({
|
const messageCtx = await createBaseDiscordMessageContext({
|
||||||
@ -49,7 +49,7 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
|||||||
upsertChannelPairingRequest: vi.fn(),
|
upsertChannelPairingRequest: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("signal createSignalEventHandler inbound contract", () => {
|
describe("signal createSignalEventHandler inbound context", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
capture.ctx = undefined;
|
capture.ctx = undefined;
|
||||||
sendTypingMock.mockReset().mockResolvedValue(true);
|
sendTypingMock.mockReset().mockResolvedValue(true);
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
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 type { OpenClawConfig } from "../../../../src/config/types.js";
|
||||||
import { buildDispatchInboundCaptureMock } from "../../../../test/helpers/dispatch-inbound-capture.js";
|
|
||||||
import {
|
import {
|
||||||
createBaseSignalEventHandlerDeps,
|
createBaseSignalEventHandlerDeps,
|
||||||
createSignalReceiveEvent,
|
createSignalReceiveEvent,
|
||||||
|
|||||||
@ -109,7 +109,7 @@ vi.mock("../deliver-reply.js", () => ({
|
|||||||
import { updateLastRouteInBackground } from "./last-route.js";
|
import { updateLastRouteInBackground } from "./last-route.js";
|
||||||
import { processMessage } from "./process-message.js";
|
import { processMessage } from "./process-message.js";
|
||||||
|
|
||||||
describe("web processMessage inbound contract", () => {
|
describe("web processMessage inbound context", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
capturedCtx = undefined;
|
capturedCtx = undefined;
|
||||||
capturedDispatchParams = undefined;
|
capturedDispatchParams = undefined;
|
||||||
@ -488,7 +488,9 @@
|
|||||||
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
"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: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: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: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: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",
|
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
|
||||||
|
|||||||
@ -6,7 +6,9 @@ for (const entry of directoryContractRegistry) {
|
|||||||
describe(`${entry.id} directory contract`, () => {
|
describe(`${entry.id} directory contract`, () => {
|
||||||
installChannelDirectoryContractSuite({
|
installChannelDirectoryContractSuite({
|
||||||
plugin: entry.plugin,
|
plugin: entry.plugin,
|
||||||
invokeLookups: entry.invokeLookups,
|
coverage: entry.coverage,
|
||||||
|
cfg: entry.cfg,
|
||||||
|
accountId: entry.accountId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
import { buildDispatchInboundCaptureMock } from "./dispatch-inbound-capture.js";
|
||||||
|
|
||||||
export type InboundContextCapture = {
|
export type InboundContextCapture = {
|
||||||
@ -13,7 +13,7 @@ export async function buildDispatchInboundContextCapture(
|
|||||||
importOriginal: <T extends Record<string, unknown>>() => Promise<T>,
|
importOriginal: <T extends Record<string, unknown>>() => Promise<T>,
|
||||||
capture: InboundContextCapture,
|
capture: InboundContextCapture,
|
||||||
) {
|
) {
|
||||||
const actual = await importOriginal<typeof import("../../src/auto-reply/dispatch.js")>();
|
const actual = await importOriginal<typeof import("../../../auto-reply/dispatch.js")>();
|
||||||
return buildDispatchInboundCaptureMock(actual, (ctx) => {
|
return buildDispatchInboundCaptureMock(actual, (ctx) => {
|
||||||
capture.ctx = ctx as MsgContext;
|
capture.ctx = ctx as MsgContext;
|
||||||
});
|
});
|
||||||
@ -4,6 +4,6 @@ import { buildDispatchInboundContextCapture } from "./inbound-contract-capture.j
|
|||||||
|
|
||||||
export const inboundCtxCapture = createInboundContextCapture();
|
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);
|
return await buildDispatchInboundContextCapture(importOriginal, inboundCtxCapture);
|
||||||
});
|
});
|
||||||
299
src/channels/plugins/contracts/inbound.contract.test.ts
Normal file
299
src/channels/plugins/contracts/inbound.contract.test.ts
Normal file
@ -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<void> };
|
||||||
|
}) => {
|
||||||
|
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<typeof import("../../../auto-reply/dispatch.js")>();
|
||||||
|
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<Promise<unknown>>, task: Promise<unknown>) => {
|
||||||
|
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>): 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<string, unknown>,
|
||||||
|
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<Promise<unknown>>(),
|
||||||
|
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<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
|
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!);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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!);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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<void> };
|
|
||||||
}) => {
|
|
||||||
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<typeof import("../../../auto-reply/dispatch.js")>();
|
|
||||||
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!);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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>): 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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<string, unknown>) => Promise<void>;
|
|
||||||
|
|
||||||
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!);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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<Promise<unknown>>, task: Promise<unknown>) => {
|
|
||||||
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<string, unknown>,
|
|
||||||
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<Promise<unknown>>(),
|
|
||||||
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!);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,25 +1,48 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
actionContractRegistry,
|
actionContractRegistry,
|
||||||
|
channelPluginSurfaceKeys,
|
||||||
directoryContractRegistry,
|
directoryContractRegistry,
|
||||||
pluginContractRegistry,
|
pluginContractRegistry,
|
||||||
|
sessionBindingContractRegistry,
|
||||||
setupContractRegistry,
|
setupContractRegistry,
|
||||||
statusContractRegistry,
|
statusContractRegistry,
|
||||||
surfaceContractRegistry,
|
surfaceContractRegistry,
|
||||||
threadingContractRegistry,
|
threadingContractRegistry,
|
||||||
type ChannelPluginSurface,
|
|
||||||
} from "./registry.js";
|
} from "./registry.js";
|
||||||
|
|
||||||
const orderedSurfaceKeys = [
|
function listFilesRecursively(dir: string): string[] {
|
||||||
"actions",
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
"setup",
|
const files: string[] = [];
|
||||||
"status",
|
for (const entry of entries) {
|
||||||
"outbound",
|
const fullPath = path.join(dir, entry.name);
|
||||||
"messaging",
|
if (entry.isDirectory()) {
|
||||||
"threading",
|
files.push(...listFilesRecursively(fullPath));
|
||||||
"directory",
|
continue;
|
||||||
"gateway",
|
}
|
||||||
] as const satisfies readonly ChannelPluginSurface[];
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverSessionBindingChannels() {
|
||||||
|
const extensionsDir = path.resolve(import.meta.dirname, "../../../../extensions");
|
||||||
|
const channels = new Set<string>();
|
||||||
|
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", () => {
|
describe("channel contract registry", () => {
|
||||||
it("does not duplicate channel plugin ids", () => {
|
it("does not duplicate channel plugin ids", () => {
|
||||||
@ -35,7 +58,7 @@ describe("channel contract registry", () => {
|
|||||||
|
|
||||||
it("declares the actual owned channel plugin surfaces explicitly", () => {
|
it("declares the actual owned channel plugin surfaces explicitly", () => {
|
||||||
for (const entry of surfaceContractRegistry) {
|
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());
|
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(
|
const directorySurfaceIds = new Set(
|
||||||
surfaceContractRegistry
|
surfaceContractRegistry
|
||||||
.filter((entry) => entry.surfaces.includes("directory"))
|
.filter((entry) => entry.surfaces.includes("directory"))
|
||||||
@ -93,5 +116,27 @@ describe("channel contract registry", () => {
|
|||||||
for (const entry of directoryContractRegistry) {
|
for (const entry of directoryContractRegistry) {
|
||||||
expect(directorySurfaceIds.has(entry.id)).toBe(true);
|
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(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,27 @@
|
|||||||
import { expect, vi } from "vitest";
|
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 type { OpenClawConfig } from "../../../config/config.js";
|
||||||
|
import {
|
||||||
|
getSessionBindingService,
|
||||||
|
type SessionBindingCapabilities,
|
||||||
|
type SessionBindingRecord,
|
||||||
|
} from "../../../infra/outbound/session-binding-service.js";
|
||||||
import {
|
import {
|
||||||
resolveDefaultLineAccountId,
|
resolveDefaultLineAccountId,
|
||||||
resolveLineAccount,
|
resolveLineAccount,
|
||||||
listLineAccountIds,
|
listLineAccountIds,
|
||||||
} from "../../../line/accounts.js";
|
} from "../../../line/accounts.js";
|
||||||
import { bundledChannelRuntimeSetters, requireBundledChannelPlugin } from "../bundled.js";
|
import {
|
||||||
|
bundledChannelPlugins,
|
||||||
|
bundledChannelRuntimeSetters,
|
||||||
|
requireBundledChannelPlugin,
|
||||||
|
} from "../bundled.js";
|
||||||
import type { ChannelPlugin } from "../types.js";
|
import type { ChannelPlugin } from "../types.js";
|
||||||
|
|
||||||
type PluginContractEntry = {
|
type PluginContractEntry = {
|
||||||
@ -57,6 +73,17 @@ type StatusContractEntry = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const channelPluginSurfaceKeys = [
|
||||||
|
"actions",
|
||||||
|
"setup",
|
||||||
|
"status",
|
||||||
|
"outbound",
|
||||||
|
"messaging",
|
||||||
|
"threading",
|
||||||
|
"directory",
|
||||||
|
"gateway",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type ChannelPluginSurface =
|
export type ChannelPluginSurface =
|
||||||
| "actions"
|
| "actions"
|
||||||
| "setup"
|
| "setup"
|
||||||
@ -92,7 +119,18 @@ type ThreadingContractEntry = {
|
|||||||
type DirectoryContractEntry = {
|
type DirectoryContractEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||||
invokeLookups: boolean;
|
coverage: "lookups" | "presence";
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionBindingContractEntry = {
|
||||||
|
id: string;
|
||||||
|
expectedCapabilities: SessionBindingCapabilities;
|
||||||
|
getCapabilities: () => SessionBindingCapabilities;
|
||||||
|
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||||
|
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||||
|
cleanup: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const telegramListActionsMock = vi.fn();
|
const telegramListActionsMock = vi.fn();
|
||||||
@ -133,28 +171,18 @@ bundledChannelRuntimeSetters.setLineRuntime({
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
export const pluginContractRegistry: PluginContractEntry[] = [
|
setMatrixRuntime({
|
||||||
{ id: "bluebubbles", plugin: requireBundledChannelPlugin("bluebubbles") },
|
state: {
|
||||||
{ id: "discord", plugin: requireBundledChannelPlugin("discord") },
|
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
|
||||||
{ id: "feishu", plugin: requireBundledChannelPlugin("feishu") },
|
},
|
||||||
{ id: "googlechat", plugin: requireBundledChannelPlugin("googlechat") },
|
} as never);
|
||||||
{ id: "imessage", plugin: requireBundledChannelPlugin("imessage") },
|
|
||||||
{ id: "irc", plugin: requireBundledChannelPlugin("irc") },
|
export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map(
|
||||||
{ id: "line", plugin: requireBundledChannelPlugin("line") },
|
(plugin) => ({
|
||||||
{ id: "matrix", plugin: requireBundledChannelPlugin("matrix") },
|
id: plugin.id,
|
||||||
{ id: "mattermost", plugin: requireBundledChannelPlugin("mattermost") },
|
plugin,
|
||||||
{ 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") },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const actionContractRegistry: ActionsContractEntry[] = [
|
export const actionContractRegistry: ActionsContractEntry[] = [
|
||||||
{
|
{
|
||||||
@ -500,189 +528,13 @@ export const statusContractRegistry: StatusContractEntry[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const surfaceContractRegistry: SurfaceContractEntry[] = [
|
export const surfaceContractRegistry: SurfaceContractEntry[] = bundledChannelPlugins.map(
|
||||||
{
|
(plugin) => ({
|
||||||
id: "bluebubbles",
|
id: plugin.id,
|
||||||
plugin: requireBundledChannelPlugin("bluebubbles"),
|
plugin,
|
||||||
surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"],
|
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
|
||||||
},
|
}),
|
||||||
{
|
);
|
||||||
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 threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry
|
export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry
|
||||||
.filter((entry) => entry.surfaces.includes("threading"))
|
.filter((entry) => entry.surfaces.includes("threading"))
|
||||||
@ -691,12 +543,258 @@ export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContra
|
|||||||
plugin: entry.plugin,
|
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
|
export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry
|
||||||
.filter((entry) => entry.surfaces.includes("directory"))
|
.filter((entry) => entry.surfaces.includes("directory"))
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
plugin: entry.plugin,
|
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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@ -1,151 +1,26 @@
|
|||||||
import { beforeEach, describe, expect } from "vitest";
|
import { beforeEach, describe } from "vitest";
|
||||||
import {
|
import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
|
||||||
__testing as feishuThreadBindingTesting,
|
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||||
createFeishuThreadBindingManager,
|
import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js";
|
||||||
} from "../../../../extensions/feishu/src/thread-bindings.js";
|
import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js";
|
||||||
import {
|
import { sessionBindingContractRegistry } from "./registry.js";
|
||||||
__testing as telegramThreadBindingTesting,
|
|
||||||
createTelegramThreadBindingManager,
|
|
||||||
} from "../../../../extensions/telegram/src/thread-bindings.js";
|
|
||||||
import type { OpenClawConfig } from "../../../config/config.js";
|
|
||||||
import {
|
|
||||||
__testing as sessionBindingTesting,
|
|
||||||
getSessionBindingService,
|
|
||||||
} from "../../../infra/outbound/session-binding-service.js";
|
|
||||||
import { installSessionBindingContractSuite } from "./suites.js";
|
import { installSessionBindingContractSuite } from "./suites.js";
|
||||||
|
|
||||||
const baseCfg = {
|
|
||||||
session: { mainKey: "main", scope: "per-sender" },
|
|
||||||
} satisfies OpenClawConfig;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||||
|
discordThreadBindingTesting.resetThreadBindingsForTests();
|
||||||
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||||
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
|
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("feishu session binding contract", () => {
|
for (const entry of sessionBindingContractRegistry) {
|
||||||
installSessionBindingContractSuite({
|
describe(`${entry.id} session binding contract`, () => {
|
||||||
expectedCapabilities: {
|
installSessionBindingContractSuite({
|
||||||
adapterAvailable: true,
|
expectedCapabilities: entry.expectedCapabilities,
|
||||||
bindSupported: true,
|
getCapabilities: entry.getCapabilities,
|
||||||
unbindSupported: true,
|
bindAndResolve: entry.bindAndResolve,
|
||||||
placements: ["current"],
|
unbindAndVerify: entry.unbindAndVerify,
|
||||||
},
|
cleanup: entry.cleanup,
|
||||||
getCapabilities: () => {
|
});
|
||||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
|
||||||
return getSessionBindingService().getCapabilities({
|
|
||||||
channel: "feishu",
|
|
||||||
accountId: "default",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
bindAndResolve: async () => {
|
|
||||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
|
||||||
const service = getSessionBindingService();
|
|
||||||
const binding = await service.bind({
|
|
||||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
||||||
targetKind: "session",
|
|
||||||
conversation: {
|
|
||||||
channel: "feishu",
|
|
||||||
accountId: "default",
|
|
||||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
||||||
parentConversationId: "oc_group_chat",
|
|
||||||
},
|
|
||||||
placement: "current",
|
|
||||||
metadata: {
|
|
||||||
agentId: "codex",
|
|
||||||
label: "codex-main",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
service.resolveByConversation({
|
|
||||||
channel: "feishu",
|
|
||||||
accountId: "default",
|
|
||||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
||||||
}),
|
|
||||||
)?.toMatchObject({
|
|
||||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
||||||
});
|
|
||||||
return binding;
|
|
||||||
},
|
|
||||||
cleanup: async () => {
|
|
||||||
const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
|
||||||
manager.stop();
|
|
||||||
expect(
|
|
||||||
getSessionBindingService().resolveByConversation({
|
|
||||||
channel: "feishu",
|
|
||||||
accountId: "default",
|
|
||||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
describe("telegram session binding contract", () => {
|
|
||||||
installSessionBindingContractSuite({
|
|
||||||
expectedCapabilities: {
|
|
||||||
adapterAvailable: true,
|
|
||||||
bindSupported: true,
|
|
||||||
unbindSupported: true,
|
|
||||||
placements: ["current"],
|
|
||||||
},
|
|
||||||
getCapabilities: () => {
|
|
||||||
createTelegramThreadBindingManager({
|
|
||||||
accountId: "default",
|
|
||||||
persist: false,
|
|
||||||
enableSweeper: false,
|
|
||||||
});
|
|
||||||
return getSessionBindingService().getCapabilities({
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: "default",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
bindAndResolve: async () => {
|
|
||||||
createTelegramThreadBindingManager({
|
|
||||||
accountId: "default",
|
|
||||||
persist: false,
|
|
||||||
enableSweeper: false,
|
|
||||||
});
|
|
||||||
const service = getSessionBindingService();
|
|
||||||
const binding = await service.bind({
|
|
||||||
targetSessionKey: "agent:main:subagent:child-1",
|
|
||||||
targetKind: "subagent",
|
|
||||||
conversation: {
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: "default",
|
|
||||||
conversationId: "-100200300:topic:77",
|
|
||||||
},
|
|
||||||
placement: "current",
|
|
||||||
metadata: {
|
|
||||||
boundBy: "user-1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
service.resolveByConversation({
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: "default",
|
|
||||||
conversationId: "-100200300:topic:77",
|
|
||||||
}),
|
|
||||||
)?.toMatchObject({
|
|
||||||
targetSessionKey: "agent:main:subagent:child-1",
|
|
||||||
});
|
|
||||||
return binding;
|
|
||||||
},
|
|
||||||
cleanup: async () => {
|
|
||||||
const manager = createTelegramThreadBindingManager({
|
|
||||||
accountId: "default",
|
|
||||||
persist: false,
|
|
||||||
enableSweeper: false,
|
|
||||||
});
|
|
||||||
manager.stop();
|
|
||||||
expect(
|
|
||||||
getSessionBindingService().resolveByConversation({
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: "default",
|
|
||||||
conversationId: "-100200300:topic:77",
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -393,18 +393,20 @@ export function installChannelThreadingContractSuite(params: {
|
|||||||
|
|
||||||
export function installChannelDirectoryContractSuite(params: {
|
export function installChannelDirectoryContractSuite(params: {
|
||||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||||
invokeLookups?: boolean;
|
coverage?: "lookups" | "presence";
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
}) {
|
}) {
|
||||||
it("exposes the base directory contract", async () => {
|
it("exposes the base directory contract", async () => {
|
||||||
const directory = params.plugin.directory;
|
const directory = params.plugin.directory;
|
||||||
expect(directory).toBeDefined();
|
expect(directory).toBeDefined();
|
||||||
|
|
||||||
if (params.invokeLookups === false) {
|
if (params.coverage === "presence") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const self = await directory?.self?.({
|
const self = await directory?.self?.({
|
||||||
cfg: {} as OpenClawConfig,
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||||
accountId: "default",
|
accountId: params.accountId ?? "default",
|
||||||
runtime: contractRuntime,
|
runtime: contractRuntime,
|
||||||
});
|
});
|
||||||
if (self) {
|
if (self) {
|
||||||
@ -413,8 +415,8 @@ export function installChannelDirectoryContractSuite(params: {
|
|||||||
|
|
||||||
const peers =
|
const peers =
|
||||||
(await directory?.listPeers?.({
|
(await directory?.listPeers?.({
|
||||||
cfg: {} as OpenClawConfig,
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||||
accountId: "default",
|
accountId: params.accountId ?? "default",
|
||||||
query: "",
|
query: "",
|
||||||
limit: 5,
|
limit: 5,
|
||||||
runtime: contractRuntime,
|
runtime: contractRuntime,
|
||||||
@ -426,8 +428,8 @@ export function installChannelDirectoryContractSuite(params: {
|
|||||||
|
|
||||||
const groups =
|
const groups =
|
||||||
(await directory?.listGroups?.({
|
(await directory?.listGroups?.({
|
||||||
cfg: {} as OpenClawConfig,
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||||
accountId: "default",
|
accountId: params.accountId ?? "default",
|
||||||
query: "",
|
query: "",
|
||||||
limit: 5,
|
limit: 5,
|
||||||
runtime: contractRuntime,
|
runtime: contractRuntime,
|
||||||
@ -439,8 +441,8 @@ export function installChannelDirectoryContractSuite(params: {
|
|||||||
|
|
||||||
if (directory?.listGroupMembers && groups[0]?.id) {
|
if (directory?.listGroupMembers && groups[0]?.id) {
|
||||||
const members = await directory.listGroupMembers({
|
const members = await directory.listGroupMembers({
|
||||||
cfg: {} as OpenClawConfig,
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||||
accountId: "default",
|
accountId: params.accountId ?? "default",
|
||||||
groupId: groups[0].id,
|
groupId: groups[0].id,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
runtime: contractRuntime,
|
runtime: contractRuntime,
|
||||||
@ -456,6 +458,7 @@ export function installChannelDirectoryContractSuite(params: {
|
|||||||
export function installSessionBindingContractSuite(params: {
|
export function installSessionBindingContractSuite(params: {
|
||||||
getCapabilities: () => SessionBindingCapabilities;
|
getCapabilities: () => SessionBindingCapabilities;
|
||||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||||
|
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||||
cleanup: () => Promise<void> | void;
|
cleanup: () => Promise<void> | void;
|
||||||
expectedCapabilities: SessionBindingCapabilities;
|
expectedCapabilities: SessionBindingCapabilities;
|
||||||
}) {
|
}) {
|
||||||
@ -477,6 +480,11 @@ export function installSessionBindingContractSuite(params: {
|
|||||||
expect(typeof binding.boundAt).toBe("number");
|
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 () => {
|
it("cleans up registered bindings", async () => {
|
||||||
await params.cleanup();
|
await params.cleanup();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,8 +10,9 @@ import {
|
|||||||
setupAuthTestEnv,
|
setupAuthTestEnv,
|
||||||
} from "../../commands/test-wizard-helpers.js";
|
} from "../../commands/test-wizard-helpers.js";
|
||||||
import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js";
|
import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js";
|
||||||
|
import { buildProviderPluginMethodChoice } from "../provider-wizard.js";
|
||||||
import type { OpenClawPluginApi, ProviderPlugin } from "../types.js";
|
import type { OpenClawPluginApi, ProviderPlugin } from "../types.js";
|
||||||
import { providerContractRegistry } from "./registry.js";
|
import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js";
|
||||||
|
|
||||||
type ResolvePluginProviders =
|
type ResolvePluginProviders =
|
||||||
typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders;
|
typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders;
|
||||||
@ -101,11 +102,7 @@ describe("provider auth-choice contract", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resolvePreferredProviderPluginProvidersMock.mockReset();
|
resolvePreferredProviderPluginProvidersMock.mockReset();
|
||||||
resolvePreferredProviderPluginProvidersMock.mockReturnValue([
|
resolvePreferredProviderPluginProvidersMock.mockReturnValue(uniqueProviderContractProviders);
|
||||||
...new Map(
|
|
||||||
providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]),
|
|
||||||
).values(),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -121,21 +118,34 @@ describe("provider auth-choice contract", () => {
|
|||||||
activeStateDir = null;
|
activeStateDir = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps plugin-backed auth choices through the shared preferred-provider resolver", async () => {
|
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
|
||||||
const scenarios = [
|
const pluginFallbackScenarios = [
|
||||||
{ authChoice: "github-copilot" as const, expectedProvider: "github-copilot" },
|
"github-copilot",
|
||||||
{ authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" },
|
"qwen-portal",
|
||||||
{ authChoice: "minimax-global-oauth" as const, expectedProvider: "minimax-portal" },
|
"minimax-portal",
|
||||||
{ authChoice: "modelstudio-api-key" as const, expectedProvider: "modelstudio" },
|
"modelstudio",
|
||||||
{ authChoice: "ollama" as const, expectedProvider: "ollama" },
|
"ollama",
|
||||||
{ authChoice: "unknown", expectedProvider: undefined },
|
].map((providerId) => {
|
||||||
] as const;
|
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(
|
await expect(
|
||||||
resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice }),
|
resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice as AuthChoice }),
|
||||||
).resolves.toBe(scenario.expectedProvider);
|
).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 () => {
|
it("applies qwen portal auth choices through the shared plugin-provider path", async () => {
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { providerContractRegistry } from "./registry.js";
|
import {
|
||||||
|
providerContractPluginIds,
|
||||||
function uniqueProviders() {
|
resolveProviderContractProvidersForPluginIds,
|
||||||
return [
|
uniqueProviderContractProviders,
|
||||||
...new Map(
|
} from "./registry.js";
|
||||||
providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]),
|
|
||||||
).values(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePluginProvidersMock = vi.fn();
|
const resolvePluginProvidersMock = vi.fn();
|
||||||
const resolveOwningPluginIdsForProviderMock = vi.fn();
|
const resolveOwningPluginIdsForProviderMock = vi.fn();
|
||||||
@ -30,12 +26,10 @@ const {
|
|||||||
|
|
||||||
describe("provider catalog contract", () => {
|
describe("provider catalog contract", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const providers = uniqueProviders();
|
|
||||||
const providerIds = [...new Set(providerContractRegistry.map((entry) => entry.pluginId))];
|
|
||||||
resetProviderRuntimeHookCacheForTest();
|
resetProviderRuntimeHookCacheForTest();
|
||||||
|
|
||||||
resolveOwningPluginIdsForProviderMock.mockReset();
|
resolveOwningPluginIdsForProviderMock.mockReset();
|
||||||
resolveOwningPluginIdsForProviderMock.mockReturnValue(providerIds);
|
resolveOwningPluginIdsForProviderMock.mockReturnValue(providerContractPluginIds);
|
||||||
|
|
||||||
resolveNonBundledProviderPluginIdsMock.mockReset();
|
resolveNonBundledProviderPluginIdsMock.mockReset();
|
||||||
resolveNonBundledProviderPluginIdsMock.mockReturnValue([]);
|
resolveNonBundledProviderPluginIdsMock.mockReturnValue([]);
|
||||||
@ -44,12 +38,9 @@ describe("provider catalog contract", () => {
|
|||||||
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
|
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
|
||||||
const onlyPluginIds = params?.onlyPluginIds;
|
const onlyPluginIds = params?.onlyPluginIds;
|
||||||
if (!onlyPluginIds || onlyPluginIds.length === 0) {
|
if (!onlyPluginIds || onlyPluginIds.length === 0) {
|
||||||
return providers;
|
return uniqueProviderContractProviders;
|
||||||
}
|
}
|
||||||
const allowed = new Set(onlyPluginIds);
|
return resolveProviderContractProvidersForPluginIds(onlyPluginIds);
|
||||||
return providerContractRegistry
|
|
||||||
.filter((entry) => allowed.has(entry.pluginId))
|
|
||||||
.map((entry) => entry.provider);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { withBundledPluginAllowlistCompat } from "../bundled-compat.js";
|
import { withBundledPluginAllowlistCompat } from "../bundled-compat.js";
|
||||||
import { __testing as providerTesting } from "../providers.js";
|
import { __testing as providerTesting } from "../providers.js";
|
||||||
import { resolvePluginWebSearchProviders } from "../web-search-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[]) {
|
function uniqueSortedPluginIds(values: string[]) {
|
||||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
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", () => {
|
it("keeps bundled provider compatibility wired to the provider registry", () => {
|
||||||
const providerPluginIds = uniqueSortedPluginIds(
|
const providerPluginIds = uniqueSortedPluginIds(
|
||||||
providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)),
|
providerContractPluginIds.map(normalizeProviderContractPluginId),
|
||||||
);
|
);
|
||||||
const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({
|
const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({
|
||||||
config: {
|
config: {
|
||||||
@ -46,7 +49,7 @@ describe("plugin loader contract", () => {
|
|||||||
|
|
||||||
it("keeps vitest bundled provider enablement wired to the provider registry", () => {
|
it("keeps vitest bundled provider enablement wired to the provider registry", () => {
|
||||||
const providerPluginIds = uniqueSortedPluginIds(
|
const providerPluginIds = uniqueSortedPluginIds(
|
||||||
providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)),
|
providerContractPluginIds.map(normalizeProviderContractPluginId),
|
||||||
);
|
);
|
||||||
const compatConfig = providerTesting.withBundledProviderVitestCompat({
|
const compatConfig = providerTesting.withBundledProviderVitestCompat({
|
||||||
config: undefined,
|
config: undefined,
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadPluginManifestRegistry } from "../manifest-registry.js";
|
||||||
|
import { resolvePluginWebSearchProviders } from "../web-search-providers.js";
|
||||||
import {
|
import {
|
||||||
mediaUnderstandingProviderContractRegistry,
|
mediaUnderstandingProviderContractRegistry,
|
||||||
pluginRegistrationContractRegistry,
|
pluginRegistrationContractRegistry,
|
||||||
|
providerContractPluginIds,
|
||||||
providerContractRegistry,
|
providerContractRegistry,
|
||||||
speechProviderContractRegistry,
|
speechProviderContractRegistry,
|
||||||
webSearchProviderContractRegistry,
|
webSearchProviderContractRegistry,
|
||||||
@ -84,6 +87,27 @@ describe("plugin contract registry", () => {
|
|||||||
expect(ids).toEqual([...new Set(ids)]);
|
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", () => {
|
it("keeps multi-provider plugin ownership explicit", () => {
|
||||||
expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]);
|
expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]);
|
||||||
expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]);
|
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", () => {
|
it("keeps bundled speech voice-list support explicit", () => {
|
||||||
expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function));
|
expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function));
|
||||||
expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function));
|
expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function));
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js";
|
||||||
import anthropicPlugin from "../../../extensions/anthropic/index.js";
|
import anthropicPlugin from "../../../extensions/anthropic/index.js";
|
||||||
import bravePlugin from "../../../extensions/brave/index.js";
|
import bravePlugin from "../../../extensions/brave/index.js";
|
||||||
import byteplusPlugin from "../../../extensions/byteplus/index.js";
|
import byteplusPlugin from "../../../extensions/byteplus/index.js";
|
||||||
@ -72,6 +73,7 @@ type PluginRegistrationContractEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bundledProviderPlugins: RegistrablePlugin[] = [
|
const bundledProviderPlugins: RegistrablePlugin[] = [
|
||||||
|
amazonBedrockPlugin,
|
||||||
anthropicPlugin,
|
anthropicPlugin,
|
||||||
byteplusPlugin,
|
byteplusPlugin,
|
||||||
cloudflareAiGatewayPlugin,
|
cloudflareAiGatewayPlugin,
|
||||||
@ -150,6 +152,35 @@ export const providerContractRegistry: ProviderContractEntry[] = buildCapability
|
|||||||
select: (captured) => captured.providers,
|
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[] =
|
export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] =
|
||||||
bundledWebSearchPlugins.flatMap((plugin) => {
|
bundledWebSearchPlugins.flatMap((plugin) => {
|
||||||
const captured = captureRegistrations(plugin);
|
const captured = captureRegistrations(plugin);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js";
|
import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js";
|
||||||
import type { ProviderRuntimeModel } from "../types.js";
|
import type { ProviderRuntimeModel } from "../types.js";
|
||||||
|
import { requireProviderContractProvider } from "./registry.js";
|
||||||
|
|
||||||
const getOAuthApiKeyMock = vi.hoisted(() => vi.fn());
|
const getOAuthApiKeyMock = vi.hoisted(() => vi.fn());
|
||||||
const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn());
|
const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn());
|
||||||
@ -17,16 +18,6 @@ vi.mock("../../providers/qwen-portal-oauth.js", () => ({
|
|||||||
refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock,
|
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<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
|
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
|
||||||
return {
|
return {
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
@ -45,7 +36,7 @@ function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRun
|
|||||||
describe("provider runtime contract", () => {
|
describe("provider runtime contract", () => {
|
||||||
describe("anthropic", () => {
|
describe("anthropic", () => {
|
||||||
it("owns anthropic 4.6 forward-compat resolution", () => {
|
it("owns anthropic 4.6 forward-compat resolution", () => {
|
||||||
const provider = requireProvider("anthropic");
|
const provider = requireProviderContractProvider("anthropic");
|
||||||
const model = provider.resolveDynamicModel?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
modelId: "claude-sonnet-4.6-20260219",
|
modelId: "claude-sonnet-4.6-20260219",
|
||||||
@ -71,7 +62,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage auth resolution", async () => {
|
it("owns usage auth resolution", async () => {
|
||||||
const provider = requireProvider("anthropic");
|
const provider = requireProviderContractProvider("anthropic");
|
||||||
await expect(
|
await expect(
|
||||||
provider.resolveUsageAuth?.({
|
provider.resolveUsageAuth?.({
|
||||||
config: {} as never,
|
config: {} as never,
|
||||||
@ -88,7 +79,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns auth doctor hint generation", () => {
|
it("owns auth doctor hint generation", () => {
|
||||||
const provider = requireProvider("anthropic");
|
const provider = requireProviderContractProvider("anthropic");
|
||||||
const hint = provider.buildAuthDoctorHint?.({
|
const hint = provider.buildAuthDoctorHint?.({
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
profileId: "anthropic:default",
|
profileId: "anthropic:default",
|
||||||
@ -121,7 +112,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage snapshot fetching", async () => {
|
it("owns usage snapshot fetching", async () => {
|
||||||
const provider = requireProvider("anthropic");
|
const provider = requireProviderContractProvider("anthropic");
|
||||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||||
return makeResponse(200, {
|
return makeResponse(200, {
|
||||||
@ -154,7 +145,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("github-copilot", () => {
|
describe("github-copilot", () => {
|
||||||
it("owns Copilot-specific forward-compat fallbacks", () => {
|
it("owns Copilot-specific forward-compat fallbacks", () => {
|
||||||
const provider = requireProvider("github-copilot");
|
const provider = requireProviderContractProvider("github-copilot");
|
||||||
const model = provider.resolveDynamicModel?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
modelId: "gpt-5.3-codex",
|
modelId: "gpt-5.3-codex",
|
||||||
@ -181,7 +172,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("google", () => {
|
describe("google", () => {
|
||||||
it("owns google direct gemini 3.1 forward-compat resolution", () => {
|
it("owns google direct gemini 3.1 forward-compat resolution", () => {
|
||||||
const provider = requireProvider("google");
|
const provider = requireProviderContractProvider("google");
|
||||||
const model = provider.resolveDynamicModel?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "google",
|
provider: "google",
|
||||||
modelId: "gemini-3.1-pro-preview",
|
modelId: "gemini-3.1-pro-preview",
|
||||||
@ -213,7 +204,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("google-gemini-cli", () => {
|
describe("google-gemini-cli", () => {
|
||||||
it("owns gemini cli 3.1 forward-compat resolution", () => {
|
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?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "google-gemini-cli",
|
provider: "google-gemini-cli",
|
||||||
modelId: "gemini-3.1-pro-preview",
|
modelId: "gemini-3.1-pro-preview",
|
||||||
@ -241,7 +232,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage-token parsing", async () => {
|
it("owns usage-token parsing", async () => {
|
||||||
const provider = requireProvider("google-gemini-cli");
|
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||||
await expect(
|
await expect(
|
||||||
provider.resolveUsageAuth?.({
|
provider.resolveUsageAuth?.({
|
||||||
config: {} as never,
|
config: {} as never,
|
||||||
@ -260,7 +251,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns OAuth auth-profile formatting", () => {
|
it("owns OAuth auth-profile formatting", () => {
|
||||||
const provider = requireProvider("google-gemini-cli");
|
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
provider.formatApiKey?.({
|
provider.formatApiKey?.({
|
||||||
@ -275,7 +266,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage snapshot fetching", async () => {
|
it("owns usage snapshot fetching", async () => {
|
||||||
const provider = requireProvider("google-gemini-cli");
|
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||||
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
|
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
|
||||||
return makeResponse(200, {
|
return makeResponse(200, {
|
||||||
@ -309,7 +300,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("openai", () => {
|
describe("openai", () => {
|
||||||
it("owns openai gpt-5.4 forward-compat resolution", () => {
|
it("owns openai gpt-5.4 forward-compat resolution", () => {
|
||||||
const provider = requireProvider("openai");
|
const provider = requireProviderContractProvider("openai");
|
||||||
const model = provider.resolveDynamicModel?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
modelId: "gpt-5.4-pro",
|
modelId: "gpt-5.4-pro",
|
||||||
@ -337,7 +328,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns direct openai transport normalization", () => {
|
it("owns direct openai transport normalization", () => {
|
||||||
const provider = requireProvider("openai");
|
const provider = requireProviderContractProvider("openai");
|
||||||
expect(
|
expect(
|
||||||
provider.normalizeResolvedModel?.({
|
provider.normalizeResolvedModel?.({
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
@ -360,7 +351,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("openai-codex", () => {
|
describe("openai-codex", () => {
|
||||||
it("owns refresh fallback for accountId extraction failures", async () => {
|
it("owns refresh fallback for accountId extraction failures", async () => {
|
||||||
const provider = requireProvider("openai-codex");
|
const provider = requireProviderContractProvider("openai-codex");
|
||||||
const credential = {
|
const credential = {
|
||||||
type: "oauth" as const,
|
type: "oauth" as const,
|
||||||
provider: "openai-codex",
|
provider: "openai-codex",
|
||||||
@ -376,7 +367,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns forward-compat codex models", () => {
|
it("owns forward-compat codex models", () => {
|
||||||
const provider = requireProvider("openai-codex");
|
const provider = requireProviderContractProvider("openai-codex");
|
||||||
const model = provider.resolveDynamicModel?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "openai-codex",
|
provider: "openai-codex",
|
||||||
modelId: "gpt-5.4",
|
modelId: "gpt-5.4",
|
||||||
@ -403,7 +394,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns codex transport defaults", () => {
|
it("owns codex transport defaults", () => {
|
||||||
const provider = requireProvider("openai-codex");
|
const provider = requireProviderContractProvider("openai-codex");
|
||||||
expect(
|
expect(
|
||||||
provider.prepareExtraParams?.({
|
provider.prepareExtraParams?.({
|
||||||
provider: "openai-codex",
|
provider: "openai-codex",
|
||||||
@ -417,7 +408,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage snapshot fetching", async () => {
|
it("owns usage snapshot fetching", async () => {
|
||||||
const provider = requireProvider("openai-codex");
|
const provider = requireProviderContractProvider("openai-codex");
|
||||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||||
if (url.includes("chatgpt.com/backend-api/wham/usage")) {
|
if (url.includes("chatgpt.com/backend-api/wham/usage")) {
|
||||||
return makeResponse(200, {
|
return makeResponse(200, {
|
||||||
@ -455,7 +446,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("qwen-portal", () => {
|
describe("qwen-portal", () => {
|
||||||
it("owns OAuth refresh", async () => {
|
it("owns OAuth refresh", async () => {
|
||||||
const provider = requireProvider("qwen-portal");
|
const provider = requireProviderContractProvider("qwen-portal");
|
||||||
const credential = {
|
const credential = {
|
||||||
type: "oauth" as const,
|
type: "oauth" as const,
|
||||||
provider: "qwen-portal",
|
provider: "qwen-portal",
|
||||||
@ -478,7 +469,7 @@ describe("provider runtime contract", () => {
|
|||||||
|
|
||||||
describe("zai", () => {
|
describe("zai", () => {
|
||||||
it("owns glm-5 forward-compat resolution", () => {
|
it("owns glm-5 forward-compat resolution", () => {
|
||||||
const provider = requireProvider("zai");
|
const provider = requireProviderContractProvider("zai");
|
||||||
const model = provider.resolveDynamicModel?.({
|
const model = provider.resolveDynamicModel?.({
|
||||||
provider: "zai",
|
provider: "zai",
|
||||||
modelId: "glm-5",
|
modelId: "glm-5",
|
||||||
@ -507,7 +498,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage auth resolution", async () => {
|
it("owns usage auth resolution", async () => {
|
||||||
const provider = requireProvider("zai");
|
const provider = requireProviderContractProvider("zai");
|
||||||
await expect(
|
await expect(
|
||||||
provider.resolveUsageAuth?.({
|
provider.resolveUsageAuth?.({
|
||||||
config: {} as never,
|
config: {} as never,
|
||||||
@ -524,7 +515,7 @@ describe("provider runtime contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns usage snapshot fetching", async () => {
|
it("owns usage snapshot fetching", async () => {
|
||||||
const provider = requireProvider("zai");
|
const provider = requireProviderContractProvider("zai");
|
||||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||||
if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) {
|
if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) {
|
||||||
return makeResponse(200, {
|
return makeResponse(200, {
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ProviderPlugin } from "../types.js";
|
import type { ProviderPlugin } from "../types.js";
|
||||||
import { providerContractRegistry } from "./registry.js";
|
import { providerContractPluginIds, uniqueProviderContractProviders } from "./registry.js";
|
||||||
|
|
||||||
function uniqueProviders(): ProviderPlugin[] {
|
|
||||||
return [
|
|
||||||
...new Map(
|
|
||||||
providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]),
|
|
||||||
).values(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePluginProvidersMock = vi.fn();
|
const resolvePluginProvidersMock = vi.fn();
|
||||||
|
|
||||||
@ -81,18 +73,16 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
|
|||||||
|
|
||||||
describe("provider wizard contract", () => {
|
describe("provider wizard contract", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const providers = uniqueProviders();
|
|
||||||
resolvePluginProvidersMock.mockReset();
|
resolvePluginProvidersMock.mockReset();
|
||||||
resolvePluginProvidersMock.mockReturnValue(providers);
|
resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes every registered provider setup choice through the shared wizard layer", () => {
|
it("exposes every registered provider setup choice through the shared wizard layer", () => {
|
||||||
const providers = uniqueProviders();
|
|
||||||
const options = resolveProviderWizardOptions({
|
const options = resolveProviderWizardOptions({
|
||||||
config: {
|
config: {
|
||||||
plugins: {
|
plugins: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allow: [...new Set(providerContractRegistry.map((entry) => entry.pluginId))],
|
allow: providerContractPluginIds,
|
||||||
slots: {
|
slots: {
|
||||||
memory: "none",
|
memory: "none",
|
||||||
},
|
},
|
||||||
@ -103,18 +93,16 @@ describe("provider wizard contract", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
options.map((option) => option.value).toSorted((left, right) => left.localeCompare(right)),
|
options.map((option) => option.value).toSorted((left, right) => left.localeCompare(right)),
|
||||||
).toEqual(resolveExpectedWizardChoiceValues(providers));
|
).toEqual(resolveExpectedWizardChoiceValues(uniqueProviderContractProviders));
|
||||||
expect(options.map((option) => option.value)).toEqual([
|
expect(options.map((option) => option.value)).toEqual([
|
||||||
...new Set(options.map((option) => option.value)),
|
...new Set(options.map((option) => option.value)),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips every shared wizard choice back to its provider and auth method", () => {
|
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 })) {
|
for (const option of resolveProviderWizardOptions({ config: {}, env: process.env })) {
|
||||||
const resolved = resolveProviderPluginChoice({
|
const resolved = resolveProviderPluginChoice({
|
||||||
providers,
|
providers: uniqueProviderContractProviders,
|
||||||
choice: option.value,
|
choice: option.value,
|
||||||
});
|
});
|
||||||
expect(resolved).not.toBeNull();
|
expect(resolved).not.toBeNull();
|
||||||
@ -124,15 +112,14 @@ describe("provider wizard contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes every registered model-picker entry through the shared wizard layer", () => {
|
it("exposes every registered model-picker entry through the shared wizard layer", () => {
|
||||||
const providers = uniqueProviders();
|
|
||||||
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
|
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)),
|
entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)),
|
||||||
).toEqual(resolveExpectedModelPickerValues(providers));
|
).toEqual(resolveExpectedModelPickerValues(uniqueProviderContractProviders));
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const resolved = resolveProviderPluginChoice({
|
const resolved = resolveProviderPluginChoice({
|
||||||
providers,
|
providers: uniqueProviderContractProviders,
|
||||||
choice: entry.value,
|
choice: entry.value,
|
||||||
});
|
});
|
||||||
expect(resolved).not.toBeNull();
|
expect(resolved).not.toBeNull();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user