mirror of https://github.com/openclaw/openclaw.git
test: split extension-owned core coverage
This commit is contained in:
parent
6ade9c474c
commit
cd92549119
|
|
@ -0,0 +1,90 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
|
||||
const runtimeModule = await import("./runtime.js");
|
||||
const handleDiscordActionMock = vi
|
||||
.spyOn(runtimeModule, "handleDiscordAction")
|
||||
.mockResolvedValue({ content: [], details: { ok: true } });
|
||||
const { handleDiscordMessageAction } = await import("./handle-action.js");
|
||||
|
||||
describe("handleDiscordMessageAction", () => {
|
||||
beforeEach(() => {
|
||||
handleDiscordActionMock.mockClear();
|
||||
});
|
||||
|
||||
it("uses trusted requesterSenderId for moderation and ignores params senderUserId", async () => {
|
||||
await handleDiscordMessageAction({
|
||||
action: "timeout",
|
||||
params: {
|
||||
guildId: "guild-1",
|
||||
userId: "user-2",
|
||||
durationMin: 5,
|
||||
senderUserId: "spoofed-admin-id",
|
||||
},
|
||||
cfg: {
|
||||
channels: { discord: { token: "tok", actions: { moderation: true } } },
|
||||
} as OpenClawConfig,
|
||||
requesterSenderId: "trusted-sender-id",
|
||||
toolContext: { currentChannelProvider: "discord" },
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "timeout",
|
||||
guildId: "guild-1",
|
||||
userId: "user-2",
|
||||
durationMinutes: 5,
|
||||
senderUserId: "trusted-sender-id",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
discord: expect.objectContaining({
|
||||
token: "tok",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to toolContext.currentMessageId for reactions", async () => {
|
||||
await handleDiscordMessageAction({
|
||||
action: "react",
|
||||
params: {
|
||||
channelId: "123",
|
||||
emoji: "ok",
|
||||
},
|
||||
cfg: {
|
||||
channels: { discord: { token: "tok" } },
|
||||
} as OpenClawConfig,
|
||||
toolContext: { currentMessageId: "9001" },
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "react",
|
||||
channelId: "123",
|
||||
messageId: "9001",
|
||||
emoji: "ok",
|
||||
}),
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reactions when no message id source is available", async () => {
|
||||
await expect(
|
||||
handleDiscordMessageAction({
|
||||
action: "react",
|
||||
params: {
|
||||
channelId: "123",
|
||||
emoji: "ok",
|
||||
},
|
||||
cfg: {
|
||||
channels: { discord: { token: "tok" } },
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
).rejects.toThrow(/messageId required/i);
|
||||
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendReactionsModule = await import("./send-reactions.js");
|
||||
const sendReactionSignalMock = vi
|
||||
.spyOn(sendReactionsModule, "sendReactionSignal")
|
||||
.mockResolvedValue({ ok: true });
|
||||
const removeReactionSignalMock = vi
|
||||
.spyOn(sendReactionsModule, "removeReactionSignal")
|
||||
.mockResolvedValue({ ok: true });
|
||||
const { signalMessageActions } = await import("./message-actions.js");
|
||||
|
||||
function createSignalAccountOverrideCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
signal: {
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
work: { account: "+15550001111", actions: { reactions: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("signalMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
sendReactionSignalMock.mockClear();
|
||||
removeReactionSignalMock.mockClear();
|
||||
});
|
||||
|
||||
it("lists actions based on configured accounts and reaction gates", () => {
|
||||
expect(
|
||||
signalMessageActions.describeMessageTool?.({ cfg: {} as OpenClawConfig })?.actions ?? [],
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
signalMessageActions.describeMessageTool?.({
|
||||
cfg: {
|
||||
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
|
||||
} as OpenClawConfig,
|
||||
})?.actions,
|
||||
).toEqual(["send"]);
|
||||
|
||||
expect(
|
||||
signalMessageActions.describeMessageTool?.({ cfg: createSignalAccountOverrideCfg() })
|
||||
?.actions,
|
||||
).toEqual(["send", "react"]);
|
||||
});
|
||||
|
||||
it("skips send for plugin dispatch", () => {
|
||||
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
|
||||
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reactions when the action gate is disabled", async () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction?.({
|
||||
channel: "signal",
|
||||
action: "react",
|
||||
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/actions\.reactions/);
|
||||
});
|
||||
|
||||
it("maps reaction targets into sendReactionSignal calls", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "uses account-level actions when enabled",
|
||||
cfg: createSignalAccountOverrideCfg(),
|
||||
accountId: "work",
|
||||
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
|
||||
expectedRecipient: "+15550001111",
|
||||
expectedTimestamp: 123,
|
||||
expectedEmoji: "👍",
|
||||
expectedOptions: { accountId: "work" },
|
||||
},
|
||||
{
|
||||
name: "normalizes uuid recipients",
|
||||
cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig,
|
||||
params: {
|
||||
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
messageId: "123",
|
||||
emoji: "🔥",
|
||||
},
|
||||
expectedRecipient: "123e4567-e89b-12d3-a456-426614174000",
|
||||
expectedTimestamp: 123,
|
||||
expectedEmoji: "🔥",
|
||||
expectedOptions: {},
|
||||
},
|
||||
{
|
||||
name: "passes groupId and targetAuthor for group reactions",
|
||||
cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig,
|
||||
params: {
|
||||
to: "signal:group:group-id",
|
||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
messageId: "123",
|
||||
emoji: "✅",
|
||||
},
|
||||
expectedRecipient: "",
|
||||
expectedTimestamp: 123,
|
||||
expectedEmoji: "✅",
|
||||
expectedOptions: {
|
||||
groupId: "group-id",
|
||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "falls back to toolContext.currentMessageId when messageId is omitted",
|
||||
cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig,
|
||||
params: { to: "+15559999999", emoji: "🔥" },
|
||||
expectedRecipient: "+15559999999",
|
||||
expectedTimestamp: 1737630212345,
|
||||
expectedEmoji: "🔥",
|
||||
expectedOptions: {},
|
||||
toolContext: { currentMessageId: "1737630212345" },
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
sendReactionSignalMock.mockClear();
|
||||
await signalMessageActions.handleAction?.({
|
||||
channel: "signal",
|
||||
action: "react",
|
||||
params: testCase.params,
|
||||
cfg: testCase.cfg,
|
||||
accountId: "accountId" in testCase ? testCase.accountId : undefined,
|
||||
toolContext: "toolContext" in testCase ? testCase.toolContext : undefined,
|
||||
});
|
||||
|
||||
expect(sendReactionSignalMock, testCase.name).toHaveBeenCalledWith(
|
||||
testCase.expectedRecipient,
|
||||
testCase.expectedTimestamp,
|
||||
testCase.expectedEmoji,
|
||||
expect.objectContaining({
|
||||
cfg: testCase.cfg,
|
||||
...testCase.expectedOptions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid reaction inputs before dispatch", async () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction?.({
|
||||
channel: "signal",
|
||||
action: "react",
|
||||
params: { to: "+15559999999", emoji: "✅" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/messageId.*required/);
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction?.({
|
||||
channel: "signal",
|
||||
action: "react",
|
||||
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/targetAuthor/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { telegramMessageActions, telegramMessageActionRuntime } from "./channel-actions.js";
|
||||
|
||||
const handleTelegramActionMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -58,4 +59,211 @@ describe("telegramMessageActions", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes poll/topic action availability from config gates", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "configured telegram enables poll",
|
||||
cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig,
|
||||
expectPoll: true,
|
||||
expectTopicEdit: true,
|
||||
},
|
||||
{
|
||||
name: "sendMessage disabled hides poll",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
actions: { sendMessage: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectPoll: false,
|
||||
expectTopicEdit: true,
|
||||
},
|
||||
{
|
||||
name: "poll gate disabled hides poll",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
actions: { poll: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectPoll: false,
|
||||
expectTopicEdit: true,
|
||||
},
|
||||
{
|
||||
name: "split account gates do not expose poll",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
senderOnly: {
|
||||
botToken: "tok-send",
|
||||
actions: {
|
||||
sendMessage: true,
|
||||
poll: false,
|
||||
},
|
||||
},
|
||||
pollOnly: {
|
||||
botToken: "tok-poll",
|
||||
actions: {
|
||||
sendMessage: false,
|
||||
poll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectPoll: false,
|
||||
expectTopicEdit: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const actions =
|
||||
telegramMessageActions.describeMessageTool?.({
|
||||
cfg: testCase.cfg,
|
||||
})?.actions ?? [];
|
||||
if (testCase.expectPoll) {
|
||||
expect(actions, testCase.name).toContain("poll");
|
||||
} else {
|
||||
expect(actions, testCase.name).not.toContain("poll");
|
||||
}
|
||||
if (testCase.expectTopicEdit) {
|
||||
expect(actions, testCase.name).toContain("topic-edit");
|
||||
} else {
|
||||
expect(actions, testCase.name).not.toContain("topic-edit");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("lists sticker actions only when enabled by config", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default config",
|
||||
cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig,
|
||||
expectSticker: false,
|
||||
},
|
||||
{
|
||||
name: "per-account sticker enabled",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
media: { botToken: "tok", actions: { sticker: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectSticker: true,
|
||||
},
|
||||
{
|
||||
name: "all accounts omit sticker",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
a: { botToken: "tok1" },
|
||||
b: { botToken: "tok2" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectSticker: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const actions =
|
||||
telegramMessageActions.describeMessageTool?.({
|
||||
cfg: testCase.cfg,
|
||||
})?.actions ?? [];
|
||||
if (testCase.expectSticker) {
|
||||
expect(actions, testCase.name).toEqual(
|
||||
expect.arrayContaining(["sticker", "sticker-search"]),
|
||||
);
|
||||
} else {
|
||||
expect(actions, testCase.name).not.toContain("sticker");
|
||||
expect(actions, testCase.name).not.toContain("sticker-search");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes reaction message identifiers before dispatch", async () => {
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig;
|
||||
const cases = [
|
||||
{
|
||||
name: "numeric channelId/messageId",
|
||||
params: {
|
||||
channelId: 123,
|
||||
messageId: 456,
|
||||
emoji: "ok",
|
||||
},
|
||||
expectedChannelField: "channelId",
|
||||
expectedChannelValue: "123",
|
||||
expectedMessageId: "456",
|
||||
},
|
||||
{
|
||||
name: "snake_case message_id",
|
||||
params: {
|
||||
channelId: 123,
|
||||
message_id: "456",
|
||||
emoji: "ok",
|
||||
},
|
||||
expectedChannelField: "channelId",
|
||||
expectedChannelValue: "123",
|
||||
expectedMessageId: "456",
|
||||
},
|
||||
{
|
||||
name: "toolContext fallback",
|
||||
params: {
|
||||
chatId: "123",
|
||||
emoji: "ok",
|
||||
},
|
||||
toolContext: { currentMessageId: "9001" },
|
||||
expectedChannelField: "chatId",
|
||||
expectedChannelValue: "123",
|
||||
expectedMessageId: "9001",
|
||||
},
|
||||
{
|
||||
name: "missing messageId soft-falls through",
|
||||
params: {
|
||||
chatId: "123",
|
||||
emoji: "ok",
|
||||
},
|
||||
expectedChannelField: "chatId",
|
||||
expectedChannelValue: "123",
|
||||
expectedMessageId: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
handleTelegramActionMock.mockClear();
|
||||
await telegramMessageActions.handleAction?.({
|
||||
channel: "telegram",
|
||||
action: "react",
|
||||
params: testCase.params,
|
||||
cfg,
|
||||
toolContext: "toolContext" in testCase ? testCase.toolContext : undefined,
|
||||
});
|
||||
|
||||
const call = handleTelegramActionMock.mock.calls[0]?.[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(call, testCase.name).toBeDefined();
|
||||
expect(call?.action, testCase.name).toBe("react");
|
||||
expect(String(call?.[testCase.expectedChannelField]), testCase.name).toBe(
|
||||
testCase.expectedChannelValue,
|
||||
);
|
||||
if (testCase.expectedMessageId === undefined) {
|
||||
expect(call?.messageId, testCase.name).toBeUndefined();
|
||||
} else {
|
||||
expect(String(call?.messageId), testCase.name).toBe(testCase.expectedMessageId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let runtimeStub: {
|
||||
config: { toNumber?: string };
|
||||
manager: {
|
||||
initiateCall: ReturnType<typeof vi.fn>;
|
||||
continueCall: ReturnType<typeof vi.fn>;
|
||||
speak: ReturnType<typeof vi.fn>;
|
||||
endCall: ReturnType<typeof vi.fn>;
|
||||
getCall: ReturnType<typeof vi.fn>;
|
||||
getCallByProviderCallId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("./runtime-entry.js", () => ({
|
||||
createVoiceCallRuntime: vi.fn(async () => runtimeStub),
|
||||
}));
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
const noopLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
type Registered = {
|
||||
methods: Map<string, unknown>;
|
||||
tools: unknown[];
|
||||
};
|
||||
type RegisterVoiceCall = (api: Record<string, unknown>) => void | Promise<void>;
|
||||
type RegisterCliContext = {
|
||||
program: Command;
|
||||
config: Record<string, unknown>;
|
||||
workspaceDir?: string;
|
||||
logger: typeof noopLogger;
|
||||
};
|
||||
|
||||
function captureStdout() {
|
||||
let output = "";
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
return {
|
||||
output: () => output,
|
||||
restore: () => writeSpy.mockRestore(),
|
||||
};
|
||||
}
|
||||
function setup(config: Record<string, unknown>): Registered {
|
||||
const methods = new Map<string, unknown>();
|
||||
const tools: unknown[] = [];
|
||||
void plugin.register({
|
||||
id: "voice-call",
|
||||
name: "Voice Call",
|
||||
description: "test",
|
||||
version: "0",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: config,
|
||||
runtime: { tts: { textToSpeechTelephony: vi.fn() } } as unknown as Parameters<
|
||||
typeof plugin.register
|
||||
>[0]["runtime"],
|
||||
logger: noopLogger,
|
||||
registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler),
|
||||
registerTool: (tool: unknown) => tools.push(tool),
|
||||
registerCli: () => {},
|
||||
registerService: () => {},
|
||||
resolvePath: (p: string) => p,
|
||||
} as unknown as Parameters<typeof plugin.register>[0]);
|
||||
return { methods, tools };
|
||||
}
|
||||
|
||||
async function registerVoiceCallCli(program: Command) {
|
||||
const { register } = plugin as unknown as {
|
||||
register: RegisterVoiceCall;
|
||||
};
|
||||
await register({
|
||||
id: "voice-call",
|
||||
name: "Voice Call",
|
||||
description: "test",
|
||||
version: "0",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: { provider: "mock" },
|
||||
runtime: { tts: { textToSpeechTelephony: vi.fn() } },
|
||||
logger: noopLogger,
|
||||
registerGatewayMethod: () => {},
|
||||
registerTool: () => {},
|
||||
registerCli: (fn: (ctx: RegisterCliContext) => void) =>
|
||||
fn({
|
||||
program,
|
||||
config: {},
|
||||
workspaceDir: undefined,
|
||||
logger: noopLogger,
|
||||
}),
|
||||
registerService: () => {},
|
||||
resolvePath: (p: string) => p,
|
||||
});
|
||||
}
|
||||
|
||||
describe("voice-call plugin", () => {
|
||||
beforeEach(() => {
|
||||
runtimeStub = {
|
||||
config: { toNumber: "+15550001234" },
|
||||
manager: {
|
||||
initiateCall: vi.fn(async () => ({ callId: "call-1", success: true })),
|
||||
continueCall: vi.fn(async () => ({
|
||||
success: true,
|
||||
transcript: "hello",
|
||||
})),
|
||||
speak: vi.fn(async () => ({ success: true })),
|
||||
endCall: vi.fn(async () => ({ success: true })),
|
||||
getCall: vi.fn((id: string) => (id === "call-1" ? { callId: "call-1" } : undefined)),
|
||||
getCallByProviderCallId: vi.fn(() => undefined),
|
||||
},
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("registers gateway methods", () => {
|
||||
const { methods } = setup({ provider: "mock" });
|
||||
expect(methods.has("voicecall.initiate")).toBe(true);
|
||||
expect(methods.has("voicecall.continue")).toBe(true);
|
||||
expect(methods.has("voicecall.speak")).toBe(true);
|
||||
expect(methods.has("voicecall.end")).toBe(true);
|
||||
expect(methods.has("voicecall.status")).toBe(true);
|
||||
expect(methods.has("voicecall.start")).toBe(true);
|
||||
});
|
||||
|
||||
it("initiates a call via voicecall.initiate", async () => {
|
||||
const { methods } = setup({ provider: "mock" });
|
||||
const handler = methods.get("voicecall.initiate") as
|
||||
| ((ctx: {
|
||||
params: Record<string, unknown>;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const respond = vi.fn();
|
||||
await handler?.({ params: { message: "Hi" }, respond });
|
||||
expect(runtimeStub.manager.initiateCall).toHaveBeenCalled();
|
||||
const [ok, payload] = respond.mock.calls[0];
|
||||
expect(ok).toBe(true);
|
||||
expect(payload.callId).toBe("call-1");
|
||||
});
|
||||
|
||||
it("returns call status", async () => {
|
||||
const { methods } = setup({ provider: "mock" });
|
||||
const handler = methods.get("voicecall.status") as
|
||||
| ((ctx: {
|
||||
params: Record<string, unknown>;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const respond = vi.fn();
|
||||
await handler?.({ params: { callId: "call-1" }, respond });
|
||||
const [ok, payload] = respond.mock.calls[0];
|
||||
expect(ok).toBe(true);
|
||||
expect(payload.found).toBe(true);
|
||||
});
|
||||
|
||||
it("tool get_status returns json payload", async () => {
|
||||
const { tools } = setup({ provider: "mock" });
|
||||
const tool = tools[0] as {
|
||||
execute: (id: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
const result = (await tool.execute("id", {
|
||||
action: "get_status",
|
||||
callId: "call-1",
|
||||
})) as { details: { found?: boolean } };
|
||||
expect(result.details.found).toBe(true);
|
||||
});
|
||||
|
||||
it("legacy tool status without sid returns error payload", async () => {
|
||||
const { tools } = setup({ provider: "mock" });
|
||||
const tool = tools[0] as {
|
||||
execute: (id: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
const result = (await tool.execute("id", { mode: "status" })) as {
|
||||
details: { error?: unknown };
|
||||
};
|
||||
expect(String(result.details.error)).toContain("sid required");
|
||||
});
|
||||
|
||||
it("CLI latency summarizes turn metrics from JSONL", async () => {
|
||||
const program = new Command();
|
||||
const tmpFile = path.join(os.tmpdir(), `voicecall-latency-${Date.now()}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
[
|
||||
JSON.stringify({ metadata: { lastTurnLatencyMs: 100, lastTurnListenWaitMs: 70 } }),
|
||||
JSON.stringify({ metadata: { lastTurnLatencyMs: 200, lastTurnListenWaitMs: 110 } }),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const stdout = captureStdout();
|
||||
|
||||
try {
|
||||
await registerVoiceCallCli(program);
|
||||
|
||||
await program.parseAsync(["voicecall", "latency", "--file", tmpFile, "--last", "10"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const printed = stdout.output();
|
||||
expect(printed).toContain('"recordsScanned": 2');
|
||||
expect(printed).toContain('"p50Ms": 100');
|
||||
expect(printed).toContain('"p95Ms": 200');
|
||||
} finally {
|
||||
stdout.restore();
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
});
|
||||
|
||||
it("CLI start prints JSON", async () => {
|
||||
const program = new Command();
|
||||
const stdout = captureStdout();
|
||||
await registerVoiceCallCli(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["voicecall", "start", "--to", "+1", "--message", "Hello"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(stdout.output()).toContain('"callId": "call-1"');
|
||||
} finally {
|
||||
stdout.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { slackPlugin } from "../../../extensions/slack/index.js";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { buildThreadingToolContext } from "./agent-runner-utils.js";
|
||||
import { applyReplyThreading } from "./reply-payloads.js";
|
||||
|
|
@ -15,6 +18,20 @@ import {
|
|||
sortSubagentRuns,
|
||||
} from "./subagents-utils.js";
|
||||
|
||||
function createSlackThreadingPlugin(): ChannelPlugin {
|
||||
return {
|
||||
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
|
||||
threading: {
|
||||
buildToolContext: ({ context }) => ({
|
||||
currentChannelId: context.To?.replace(/^channel:/, ""),
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||
replyToMode: "all",
|
||||
}),
|
||||
},
|
||||
} as ChannelPlugin;
|
||||
}
|
||||
|
||||
describe("buildThreadingToolContext", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
|
|
@ -157,7 +174,9 @@ describe("buildThreadingToolContext", () => {
|
|||
|
||||
it("uses Slack plugin threading context when the plugin registry is active", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "slack", plugin: slackPlugin, source: "test" }]),
|
||||
createTestRegistry([
|
||||
{ pluginId: "slack", plugin: createSlackThreadingPlugin(), source: "test" },
|
||||
]),
|
||||
);
|
||||
const sessionCtx = {
|
||||
Provider: "slack",
|
||||
|
|
@ -244,7 +263,8 @@ describe("applyReplyThreading auto-threading", () => {
|
|||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].replyToId).toBe("42");
|
||||
expect(result[0].replyToId).toBeUndefined();
|
||||
expect(result[0].replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps explicit tags for Telegram when off mode is enabled", () => {
|
||||
|
|
@ -256,7 +276,7 @@ describe("applyReplyThreading auto-threading", () => {
|
|||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].replyToId).toBe("42");
|
||||
expect(result[0].replyToId).toBeUndefined();
|
||||
expect(result[0].replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,18 @@
|
|||
import fs from "node:fs/promises";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import anthropicPlugin from "../../extensions/anthropic/index.js";
|
||||
import chutesPlugin from "../../extensions/chutes/index.js";
|
||||
import cloudflareAiGatewayPlugin from "../../extensions/cloudflare-ai-gateway/index.js";
|
||||
import googlePlugin from "../../extensions/google/index.js";
|
||||
import huggingfacePlugin from "../../extensions/huggingface/index.js";
|
||||
import kimiCodingPlugin from "../../extensions/kimi-coding/index.js";
|
||||
import litellmPlugin from "../../extensions/litellm/index.js";
|
||||
import minimaxPlugin from "../../extensions/minimax/index.js";
|
||||
import mistralPlugin from "../../extensions/mistral/index.js";
|
||||
import moonshotPlugin from "../../extensions/moonshot/index.js";
|
||||
import ollamaPlugin from "../../extensions/ollama/index.js";
|
||||
import openAIPlugin from "../../extensions/openai/index.js";
|
||||
import opencodeGoPlugin from "../../extensions/opencode-go/index.js";
|
||||
import opencodePlugin from "../../extensions/opencode/index.js";
|
||||
import openrouterPlugin from "../../extensions/openrouter/index.js";
|
||||
import qianfanPlugin from "../../extensions/qianfan/index.js";
|
||||
import syntheticPlugin from "../../extensions/synthetic/index.js";
|
||||
import togetherPlugin from "../../extensions/together/index.js";
|
||||
import venicePlugin from "../../extensions/venice/index.js";
|
||||
import vercelAiGatewayPlugin from "../../extensions/vercel-ai-gateway/index.js";
|
||||
import xaiPlugin from "../../extensions/xai/index.js";
|
||||
import xiaomiPlugin from "../../extensions/xiaomi/index.js";
|
||||
import { setDetectZaiEndpointForTesting } from "../../extensions/zai/detect.js";
|
||||
import zaiPlugin from "../../extensions/zai/index.js";
|
||||
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
import type { ModelProviderConfig } from "../config/types.models.js";
|
||||
import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
|
||||
import { providerApiKeyAuthRuntime } from "../plugins/provider-api-key-auth.runtime.js";
|
||||
import {
|
||||
MINIMAX_CN_API_BASE_URL,
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_CODING_GLOBAL_BASE_URL,
|
||||
} from "../plugins/provider-model-definitions.js";
|
||||
import type { ProviderPlugin } from "../plugins/types.js";
|
||||
import { registerProviderPlugins } from "../test-utils/plugin-registration.js";
|
||||
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
|
||||
import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js";
|
||||
|
|
@ -50,10 +29,6 @@ import {
|
|||
|
||||
type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint;
|
||||
|
||||
vi.mock("../../extensions/github-copilot/login.js", () => ({
|
||||
githubCopilotLoginCommand: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const loginOpenAICodexOAuth = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<OAuthCredentials | null>>(async () => null),
|
||||
);
|
||||
|
|
@ -87,32 +62,512 @@ type StoredAuthProfile = {
|
|||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function providerConfigPatch(
|
||||
providerId: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Partial<OpenClawConfig> {
|
||||
const providers: Record<string, ModelProviderConfig> = {
|
||||
[providerId]: patch as ModelProviderConfig,
|
||||
};
|
||||
return {
|
||||
models: {
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApiKeyProvider(params: {
|
||||
providerId: string;
|
||||
label: string;
|
||||
choiceId: string;
|
||||
optionKey: string;
|
||||
flagName: `--${string}`;
|
||||
envVar: string;
|
||||
promptMessage: string;
|
||||
defaultModel?: string;
|
||||
profileId?: string;
|
||||
profileIds?: string[];
|
||||
expectedProviders?: string[];
|
||||
noteMessage?: string;
|
||||
noteTitle?: string;
|
||||
applyConfig?: Partial<OpenClawConfig>;
|
||||
}): ProviderPlugin {
|
||||
return {
|
||||
id: params.providerId,
|
||||
label: params.label,
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: params.providerId,
|
||||
methodId: "api-key",
|
||||
label: params.label,
|
||||
optionKey: params.optionKey,
|
||||
flagName: params.flagName,
|
||||
envVar: params.envVar,
|
||||
promptMessage: params.promptMessage,
|
||||
...(params.profileId ? { profileId: params.profileId } : {}),
|
||||
...(params.profileIds ? { profileIds: params.profileIds } : {}),
|
||||
...(params.defaultModel ? { defaultModel: params.defaultModel } : {}),
|
||||
...(params.expectedProviders ? { expectedProviders: params.expectedProviders } : {}),
|
||||
...(params.noteMessage ? { noteMessage: params.noteMessage } : {}),
|
||||
...(params.noteTitle ? { noteTitle: params.noteTitle } : {}),
|
||||
...(params.applyConfig ? { applyConfig: () => params.applyConfig as OpenClawConfig } : {}),
|
||||
wizard: {
|
||||
choiceId: params.choiceId,
|
||||
choiceLabel: params.label,
|
||||
groupId: params.providerId,
|
||||
groupLabel: params.label,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createFixedChoiceProvider(params: {
|
||||
providerId: string;
|
||||
label: string;
|
||||
choiceId: string;
|
||||
method: ProviderAuthMethod;
|
||||
}): ProviderPlugin {
|
||||
return {
|
||||
id: params.providerId,
|
||||
label: params.label,
|
||||
auth: [
|
||||
{
|
||||
...params.method,
|
||||
wizard: {
|
||||
choiceId: params.choiceId,
|
||||
choiceLabel: params.label,
|
||||
groupId: params.providerId,
|
||||
groupLabel: params.label,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultProviderPlugins() {
|
||||
return registerProviderPlugins(
|
||||
anthropicPlugin,
|
||||
chutesPlugin,
|
||||
cloudflareAiGatewayPlugin,
|
||||
googlePlugin,
|
||||
huggingfacePlugin,
|
||||
kimiCodingPlugin,
|
||||
litellmPlugin,
|
||||
minimaxPlugin,
|
||||
mistralPlugin,
|
||||
moonshotPlugin,
|
||||
ollamaPlugin,
|
||||
openAIPlugin,
|
||||
opencodeGoPlugin,
|
||||
opencodePlugin,
|
||||
openrouterPlugin,
|
||||
qianfanPlugin,
|
||||
syntheticPlugin,
|
||||
togetherPlugin,
|
||||
venicePlugin,
|
||||
vercelAiGatewayPlugin,
|
||||
xaiPlugin,
|
||||
xiaomiPlugin,
|
||||
zaiPlugin,
|
||||
);
|
||||
const buildApiKeyCredential = providerApiKeyAuthRuntime.buildApiKeyCredential;
|
||||
const ensureApiKeyFromOptionEnvOrPrompt =
|
||||
providerApiKeyAuthRuntime.ensureApiKeyFromOptionEnvOrPrompt;
|
||||
const normalizeApiKeyInput = providerApiKeyAuthRuntime.normalizeApiKeyInput;
|
||||
const validateApiKeyInput = providerApiKeyAuthRuntime.validateApiKeyInput;
|
||||
|
||||
const createZaiMethod = (choiceId: "zai-api-key" | "zai-coding-global"): ProviderAuthMethod => ({
|
||||
id: choiceId === "zai-api-key" ? "api-key" : "coding-global",
|
||||
label: "Z.AI API key",
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId,
|
||||
choiceLabel: "Z.AI API key",
|
||||
groupId: "zai",
|
||||
groupLabel: "Z.AI",
|
||||
},
|
||||
run: async (ctx) => {
|
||||
const token = normalizeText(await ctx.prompter.text({ message: "Enter Z.AI API key" }));
|
||||
const detectResult = await detectZaiEndpoint(
|
||||
choiceId === "zai-coding-global"
|
||||
? { apiKey: token, endpoint: "coding-global" }
|
||||
: { apiKey: token },
|
||||
);
|
||||
let baseUrl = detectResult?.baseUrl;
|
||||
let modelId = detectResult?.modelId;
|
||||
if (!baseUrl || !modelId) {
|
||||
if (choiceId === "zai-coding-global") {
|
||||
baseUrl = ZAI_CODING_GLOBAL_BASE_URL;
|
||||
modelId = "glm-5";
|
||||
} else {
|
||||
const endpoint = await ctx.prompter.select({
|
||||
message: "Select Z.AI endpoint",
|
||||
initialValue: "global",
|
||||
options: [
|
||||
{ label: "Global", value: "global" },
|
||||
{ label: "Coding CN", value: "coding-cn" },
|
||||
],
|
||||
});
|
||||
baseUrl = endpoint === "coding-cn" ? ZAI_CODING_CN_BASE_URL : ZAI_CODING_GLOBAL_BASE_URL;
|
||||
modelId = "glm-5";
|
||||
}
|
||||
}
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "zai:default",
|
||||
credential: buildApiKeyCredential("zai", token),
|
||||
},
|
||||
],
|
||||
configPatch: providerConfigPatch("zai", { baseUrl }) as OpenClawConfig,
|
||||
defaultModel: `zai/${modelId}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const cloudflareAiGatewayMethod: ProviderAuthMethod = {
|
||||
id: "api-key",
|
||||
label: "Cloudflare AI Gateway API key",
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId: "cloudflare-ai-gateway-api-key",
|
||||
choiceLabel: "Cloudflare AI Gateway API key",
|
||||
groupId: "cloudflare-ai-gateway",
|
||||
groupLabel: "Cloudflare AI Gateway",
|
||||
},
|
||||
run: async (ctx) => {
|
||||
const opts = (ctx.opts ?? {}) as Record<string, unknown>;
|
||||
const accountId =
|
||||
normalizeText(opts.cloudflareAiGatewayAccountId) ||
|
||||
normalizeText(await ctx.prompter.text({ message: "Enter Cloudflare account ID" }));
|
||||
const gatewayId =
|
||||
normalizeText(opts.cloudflareAiGatewayGatewayId) ||
|
||||
normalizeText(await ctx.prompter.text({ message: "Enter Cloudflare gateway ID" }));
|
||||
let capturedSecretInput = "";
|
||||
let capturedMode: "plaintext" | "ref" | undefined;
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token:
|
||||
normalizeText(opts.cloudflareAiGatewayApiKey) ||
|
||||
normalizeText(ctx.opts?.token) ||
|
||||
undefined,
|
||||
tokenProvider: "cloudflare-ai-gateway",
|
||||
secretInputMode:
|
||||
ctx.allowSecretRefPrompt === false
|
||||
? (ctx.secretInputMode ?? "plaintext")
|
||||
: ctx.secretInputMode,
|
||||
config: ctx.config,
|
||||
expectedProviders: ["cloudflare-ai-gateway"],
|
||||
provider: "cloudflare-ai-gateway",
|
||||
envLabel: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
promptMessage: "Enter Cloudflare AI Gateway API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: ctx.prompter,
|
||||
setCredential: async (apiKey, mode) => {
|
||||
capturedSecretInput = typeof apiKey === "string" ? apiKey : "";
|
||||
capturedMode = mode;
|
||||
},
|
||||
});
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "cloudflare-ai-gateway:default",
|
||||
credential: buildApiKeyCredential(
|
||||
"cloudflare-ai-gateway",
|
||||
capturedSecretInput,
|
||||
{ accountId, gatewayId },
|
||||
capturedMode ? { secretInputMode: capturedMode } : undefined,
|
||||
),
|
||||
},
|
||||
],
|
||||
defaultModel: "cloudflare-ai-gateway/claude-sonnet-4-5",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const chutesOAuthMethod: ProviderAuthMethod = {
|
||||
id: "oauth",
|
||||
label: "Chutes OAuth",
|
||||
kind: "device_code",
|
||||
wizard: {
|
||||
choiceId: "chutes",
|
||||
choiceLabel: "Chutes",
|
||||
groupId: "chutes",
|
||||
groupLabel: "Chutes",
|
||||
},
|
||||
run: async (ctx) => {
|
||||
const state = "state-test";
|
||||
ctx.runtime.log(`Open this URL: https://api.chutes.ai/idp/authorize?state=${state}`);
|
||||
const redirect = String(
|
||||
await ctx.prompter.text({ message: "Paste the redirect URL or code" }),
|
||||
);
|
||||
const params = new URLSearchParams(redirect.startsWith("?") ? redirect.slice(1) : redirect);
|
||||
const code = params.get("code") ?? redirect;
|
||||
const tokenResponse = await fetch("https://api.chutes.ai/idp/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, client_id: process.env.CHUTES_CLIENT_ID }),
|
||||
});
|
||||
const tokenJson = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
const userResponse = await fetch("https://api.chutes.ai/idp/userinfo", {
|
||||
headers: { Authorization: `Bearer ${tokenJson.access_token}` },
|
||||
});
|
||||
const userJson = (await userResponse.json()) as { username: string };
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: `chutes:${userJson.username}`,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "chutes",
|
||||
access: tokenJson.access_token,
|
||||
refresh: tokenJson.refresh_token,
|
||||
expires: Date.now() + tokenJson.expires_in * 1000,
|
||||
email: userJson.username,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
createApiKeyProvider({
|
||||
providerId: "anthropic",
|
||||
label: "Anthropic API key",
|
||||
choiceId: "apiKey",
|
||||
optionKey: "anthropicApiKey",
|
||||
flagName: "--anthropic-api-key",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
promptMessage: "Enter Anthropic API key",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "google",
|
||||
label: "Gemini API key",
|
||||
choiceId: "gemini-api-key",
|
||||
optionKey: "geminiApiKey",
|
||||
flagName: "--gemini-api-key",
|
||||
envVar: "GEMINI_API_KEY",
|
||||
promptMessage: "Enter Gemini API key",
|
||||
defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "huggingface",
|
||||
label: "Hugging Face API key",
|
||||
choiceId: "huggingface-api-key",
|
||||
optionKey: "huggingfaceApiKey",
|
||||
flagName: "--huggingface-api-key",
|
||||
envVar: "HUGGINGFACE_HUB_TOKEN",
|
||||
promptMessage: "Enter Hugging Face API key",
|
||||
defaultModel: "huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "litellm",
|
||||
label: "LiteLLM API key",
|
||||
choiceId: "litellm-api-key",
|
||||
optionKey: "litellmApiKey",
|
||||
flagName: "--litellm-api-key",
|
||||
envVar: "LITELLM_API_KEY",
|
||||
promptMessage: "Enter LiteLLM API key",
|
||||
defaultModel: "litellm/anthropic/claude-opus-4.6",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "minimax",
|
||||
label: "MiniMax API key (Global)",
|
||||
choiceId: "minimax-global-api",
|
||||
optionKey: "minimaxApiKey",
|
||||
flagName: "--minimax-api-key",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter MiniMax API key",
|
||||
profileId: "minimax:global",
|
||||
defaultModel: "minimax/MiniMax-M2.7",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "minimax",
|
||||
label: "MiniMax API key (CN)",
|
||||
choiceId: "minimax-cn-api",
|
||||
optionKey: "minimaxApiKey",
|
||||
flagName: "--minimax-api-key",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter MiniMax CN API key",
|
||||
profileId: "minimax:cn",
|
||||
defaultModel: "minimax/MiniMax-M2.7",
|
||||
applyConfig: providerConfigPatch("minimax", { baseUrl: MINIMAX_CN_API_BASE_URL }),
|
||||
expectedProviders: ["minimax", "minimax-cn"],
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "mistral",
|
||||
label: "Mistral API key",
|
||||
choiceId: "mistral-api-key",
|
||||
optionKey: "mistralApiKey",
|
||||
flagName: "--mistral-api-key",
|
||||
envVar: "MISTRAL_API_KEY",
|
||||
promptMessage: "Enter Mistral API key",
|
||||
defaultModel: "mistral/mistral-large-latest",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "moonshot",
|
||||
label: "Moonshot API key",
|
||||
choiceId: "moonshot-api-key",
|
||||
optionKey: "moonshotApiKey",
|
||||
flagName: "--moonshot-api-key",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
promptMessage: "Enter Moonshot API key",
|
||||
defaultModel: "moonshot/moonshot-v1-128k",
|
||||
}),
|
||||
createFixedChoiceProvider({
|
||||
providerId: "ollama",
|
||||
label: "Ollama",
|
||||
choiceId: "ollama",
|
||||
method: {
|
||||
id: "local",
|
||||
label: "Ollama",
|
||||
kind: "custom",
|
||||
run: async () => ({ profiles: [] }),
|
||||
},
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "openai",
|
||||
label: "OpenAI API key",
|
||||
choiceId: "openai-api-key",
|
||||
optionKey: "openaiApiKey",
|
||||
flagName: "--openai-api-key",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
promptMessage: "Enter OpenAI API key",
|
||||
defaultModel: "openai/gpt-5.4",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "opencode",
|
||||
label: "OpenCode Zen",
|
||||
choiceId: "opencode-zen",
|
||||
optionKey: "opencodeZenApiKey",
|
||||
flagName: "--opencode-zen-api-key",
|
||||
envVar: "OPENCODE_API_KEY",
|
||||
promptMessage: "Enter OpenCode API key",
|
||||
profileIds: ["opencode:default", "opencode-go:default"],
|
||||
defaultModel: "opencode/claude-opus-4-6",
|
||||
expectedProviders: ["opencode", "opencode-go"],
|
||||
noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.",
|
||||
noteTitle: "OpenCode",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "opencode-go",
|
||||
label: "OpenCode Go",
|
||||
choiceId: "opencode-go",
|
||||
optionKey: "opencodeGoApiKey",
|
||||
flagName: "--opencode-go-api-key",
|
||||
envVar: "OPENCODE_API_KEY",
|
||||
promptMessage: "Enter OpenCode API key",
|
||||
profileIds: ["opencode-go:default", "opencode:default"],
|
||||
defaultModel: "opencode-go/kimi-k2.5",
|
||||
expectedProviders: ["opencode", "opencode-go"],
|
||||
noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.",
|
||||
noteTitle: "OpenCode",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "openrouter",
|
||||
label: "OpenRouter API key",
|
||||
choiceId: "openrouter-api-key",
|
||||
optionKey: "openrouterApiKey",
|
||||
flagName: "--openrouter-api-key",
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
promptMessage: "Enter OpenRouter API key",
|
||||
defaultModel: "openrouter/auto",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "qianfan",
|
||||
label: "Qianfan API key",
|
||||
choiceId: "qianfan-api-key",
|
||||
optionKey: "qianfanApiKey",
|
||||
flagName: "--qianfan-api-key",
|
||||
envVar: "QIANFAN_API_KEY",
|
||||
promptMessage: "Enter Qianfan API key",
|
||||
defaultModel: "qianfan/ernie-4.5-8k",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "synthetic",
|
||||
label: "Synthetic API key",
|
||||
choiceId: "synthetic-api-key",
|
||||
optionKey: "syntheticApiKey",
|
||||
flagName: "--synthetic-api-key",
|
||||
envVar: "SYNTHETIC_API_KEY",
|
||||
promptMessage: "Enter Synthetic API key",
|
||||
defaultModel: "synthetic/Synthetic-1",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "together",
|
||||
label: "Together API key",
|
||||
choiceId: "together-api-key",
|
||||
optionKey: "togetherApiKey",
|
||||
flagName: "--together-api-key",
|
||||
envVar: "TOGETHER_API_KEY",
|
||||
promptMessage: "Enter Together API key",
|
||||
defaultModel: "together/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "venice",
|
||||
label: "Venice AI",
|
||||
choiceId: "venice-api-key",
|
||||
optionKey: "veniceApiKey",
|
||||
flagName: "--venice-api-key",
|
||||
envVar: "VENICE_API_KEY",
|
||||
promptMessage: "Enter Venice AI API key",
|
||||
defaultModel: "venice/venice-uncensored",
|
||||
noteMessage: "Venice is a privacy-focused inference service.",
|
||||
noteTitle: "Venice AI",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "vercel-ai-gateway",
|
||||
label: "AI Gateway API key",
|
||||
choiceId: "ai-gateway-api-key",
|
||||
optionKey: "aiGatewayApiKey",
|
||||
flagName: "--ai-gateway-api-key",
|
||||
envVar: "AI_GATEWAY_API_KEY",
|
||||
promptMessage: "Enter AI Gateway API key",
|
||||
defaultModel: "vercel-ai-gateway/anthropic/claude-opus-4.6",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "xai",
|
||||
label: "xAI API key",
|
||||
choiceId: "xai-api-key",
|
||||
optionKey: "xaiApiKey",
|
||||
flagName: "--xai-api-key",
|
||||
envVar: "XAI_API_KEY",
|
||||
promptMessage: "Enter xAI API key",
|
||||
defaultModel: "xai/grok-4",
|
||||
}),
|
||||
createApiKeyProvider({
|
||||
providerId: "xiaomi",
|
||||
label: "Xiaomi API key",
|
||||
choiceId: "xiaomi-api-key",
|
||||
optionKey: "xiaomiApiKey",
|
||||
flagName: "--xiaomi-api-key",
|
||||
envVar: "XIAOMI_API_KEY",
|
||||
promptMessage: "Enter Xiaomi API key",
|
||||
defaultModel: "xiaomi/mimo-v2-flash",
|
||||
}),
|
||||
{
|
||||
id: "zai",
|
||||
label: "Z.AI",
|
||||
auth: [createZaiMethod("zai-api-key"), createZaiMethod("zai-coding-global")],
|
||||
},
|
||||
{
|
||||
id: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
auth: [cloudflareAiGatewayMethod],
|
||||
},
|
||||
{
|
||||
id: "chutes",
|
||||
label: "Chutes",
|
||||
auth: [chutesOAuthMethod],
|
||||
},
|
||||
createApiKeyProvider({
|
||||
providerId: "kimi",
|
||||
label: "Kimi Code API key",
|
||||
choiceId: "kimi-code-api-key",
|
||||
optionKey: "kimiApiKey",
|
||||
flagName: "--kimi-api-key",
|
||||
envVar: "KIMI_API_KEY",
|
||||
promptMessage: "Enter Kimi Code API key",
|
||||
defaultModel: "kimi/kimi-k2.5",
|
||||
expectedProviders: ["kimi", "kimi-code", "kimi-coding"],
|
||||
}),
|
||||
createFixedChoiceProvider({
|
||||
providerId: "github-copilot",
|
||||
label: "GitHub Copilot",
|
||||
choiceId: "github-copilot",
|
||||
method: {
|
||||
id: "device",
|
||||
label: "GitHub device login",
|
||||
kind: "device_code",
|
||||
run: async () => ({ profiles: [] }),
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe("applyAuthChoice", () => {
|
||||
|
|
@ -190,14 +645,12 @@ describe("applyAuthChoice", () => {
|
|||
resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins());
|
||||
detectZaiEndpoint.mockReset();
|
||||
detectZaiEndpoint.mockResolvedValue(null);
|
||||
setDetectZaiEndpointForTesting(detectZaiEndpoint);
|
||||
loginOpenAICodexOAuth.mockReset();
|
||||
loginOpenAICodexOAuth.mockResolvedValue(null);
|
||||
await lifecycle.cleanup();
|
||||
activeStateDir = null;
|
||||
});
|
||||
|
||||
setDetectZaiEndpointForTesting(detectZaiEndpoint);
|
||||
resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins());
|
||||
|
||||
it("does not throw when openai-codex oauth fails", async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ async function loadFreshHealthModulesForTest() {
|
|||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
updateLastRoute: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.doMock("../../extensions/whatsapp/runtime-api.js", () => ({
|
||||
vi.doMock("../plugins/runtime/runtime-whatsapp-boundary.js", () => ({
|
||||
webAuthExists: vi.fn(async () => true),
|
||||
getWebAuthAgeMs: vi.fn(() => 1234),
|
||||
readWebSelfId: vi.fn(() => ({ e164: null, jid: null })),
|
||||
|
|
|
|||
|
|
@ -37,54 +37,13 @@ vi.mock("../gateway/call.js", () => ({
|
|||
randomIdempotencyKey: () => "idem-1",
|
||||
}));
|
||||
|
||||
const webAuthExists = vi.hoisted(() => vi.fn(async () => false));
|
||||
vi.mock("../../extensions/whatsapp/runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../extensions/whatsapp/runtime-api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
webAuthExists,
|
||||
};
|
||||
});
|
||||
|
||||
const handleDiscordAction = vi.hoisted(() =>
|
||||
vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })),
|
||||
);
|
||||
vi.mock("../../extensions/discord/runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../extensions/discord/runtime-api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
handleDiscordAction,
|
||||
};
|
||||
});
|
||||
|
||||
const handleSlackAction = vi.hoisted(() =>
|
||||
vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })),
|
||||
);
|
||||
vi.mock("../../extensions/slack/runtime-api.js", () => ({
|
||||
handleSlackAction,
|
||||
}));
|
||||
|
||||
const handleTelegramAction = vi.hoisted(() =>
|
||||
vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })),
|
||||
);
|
||||
vi.mock("../../extensions/telegram/test-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../extensions/telegram/test-api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
handleTelegramAction,
|
||||
};
|
||||
});
|
||||
|
||||
const handleWhatsAppAction = vi.hoisted(() =>
|
||||
vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })),
|
||||
);
|
||||
vi.mock("../../extensions/whatsapp/runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../extensions/whatsapp/runtime-api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
handleWhatsAppAction,
|
||||
};
|
||||
});
|
||||
|
||||
let messageCommand: typeof import("./message.js").messageCommand;
|
||||
|
||||
|
|
@ -103,11 +62,8 @@ beforeEach(() => {
|
|||
testConfig = {};
|
||||
setActivePluginRegistry(EMPTY_TEST_REGISTRY);
|
||||
callGatewayMock.mockClear();
|
||||
webAuthExists.mockClear().mockResolvedValue(false);
|
||||
handleDiscordAction.mockClear();
|
||||
handleSlackAction.mockClear();
|
||||
handleTelegramAction.mockClear();
|
||||
handleWhatsAppAction.mockClear();
|
||||
resolveCommandSecretRefsViaGateway.mockClear();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ vi.mock("../channels/plugins/index.js", () => ({
|
|||
},
|
||||
] as unknown,
|
||||
}));
|
||||
vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({
|
||||
vi.mock("../plugins/runtime/runtime-whatsapp-boundary.js", () => ({
|
||||
webAuthExists: mocks.webAuthExists,
|
||||
getWebAuthAgeMs: mocks.getWebAuthAgeMs,
|
||||
readWebSelfId: mocks.readWebSelfId,
|
||||
|
|
@ -404,6 +404,12 @@ vi.mock("../daemon/service.js", () => ({
|
|||
sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist",
|
||||
}),
|
||||
}),
|
||||
readGatewayServiceState: async () => ({
|
||||
installed: true,
|
||||
loaded: true,
|
||||
running: true,
|
||||
runtime: { status: "running", pid: 1234 },
|
||||
}),
|
||||
}));
|
||||
vi.mock("../daemon/node-service.js", () => ({
|
||||
resolveNodeService: () => ({
|
||||
|
|
|
|||
|
|
@ -26,24 +26,6 @@ async function loadExecApprovalSurfaceModule() {
|
|||
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
|
||||
listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args),
|
||||
}));
|
||||
vi.doMock("../../extensions/discord/index.js", () => ({
|
||||
discordPlugin: {},
|
||||
}));
|
||||
vi.doMock("../../extensions/telegram/index.js", () => ({
|
||||
telegramPlugin: {},
|
||||
}));
|
||||
vi.doMock("../../extensions/slack/index.js", () => ({
|
||||
slackPlugin: {},
|
||||
}));
|
||||
vi.doMock("../../extensions/whatsapp/index.js", () => ({
|
||||
whatsappPlugin: {},
|
||||
}));
|
||||
vi.doMock("../../extensions/signal/index.js", () => ({
|
||||
signalPlugin: {},
|
||||
}));
|
||||
vi.doMock("../../extensions/imessage/index.js", () => ({
|
||||
imessagePlugin: {},
|
||||
}));
|
||||
vi.doMock("../utils/message-channel.js", () => ({
|
||||
INTERNAL_MESSAGE_CHANNEL: "web",
|
||||
normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
getMatrixScopedEnvVarNames,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../extensions/matrix/runtime-api.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
} from "../plugin-sdk/matrix.js";
|
||||
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
|
||||
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
|
||||
const createBackupArchiveMock = vi.hoisted(() => vi.fn());
|
||||
|
|
|
|||
Loading…
Reference in New Issue