mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 17:02:46 +00:00
Merged via squash.
Prepared head SHA: 8e6d254cc4
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
571 lines
16 KiB
TypeScript
571 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
|
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
|
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
|
import { sendHandlers } from "./send.js";
|
|
import type { GatewayRequestContext } from "./types.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
deliverOutboundPayloads: vi.fn(),
|
|
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
|
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
|
|
resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })),
|
|
resolveMessageChannelSelection: vi.fn(),
|
|
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
|
|
getChannelPlugin: vi.fn(),
|
|
loadOpenClawPlugins: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../config/config.js", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
|
return {
|
|
...actual,
|
|
loadConfig: () => ({}),
|
|
};
|
|
});
|
|
|
|
vi.mock("../../channels/plugins/index.js", () => ({
|
|
getChannelPlugin: mocks.getChannelPlugin,
|
|
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
|
|
}));
|
|
|
|
const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace";
|
|
|
|
function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string {
|
|
if (typeof params.sessionKey === "string") {
|
|
const match = params.sessionKey.match(/^agent:([^:]+)/i);
|
|
if (match?.[1]) {
|
|
return match[1];
|
|
}
|
|
}
|
|
return "main";
|
|
}
|
|
|
|
function passthroughPluginAutoEnable(config: unknown) {
|
|
return { config, changes: [] as unknown[] };
|
|
}
|
|
|
|
vi.mock("../../agents/agent-scope.js", () => ({
|
|
resolveSessionAgentId: ({
|
|
sessionKey,
|
|
}: {
|
|
sessionKey?: string;
|
|
config?: unknown;
|
|
agentId?: string;
|
|
}) => resolveAgentIdFromSessionKeyForTests({ sessionKey }),
|
|
resolveDefaultAgentId: () => "main",
|
|
resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE,
|
|
}));
|
|
|
|
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
|
applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
|
|
}));
|
|
|
|
vi.mock("../../plugins/loader.js", () => ({
|
|
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/targets.js", () => ({
|
|
resolveOutboundTarget: mocks.resolveOutboundTarget,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/channel-selection.js", () => ({
|
|
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/deliver.js", () => ({
|
|
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
|
}));
|
|
|
|
vi.mock("../../config/sessions.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
|
"../../config/sessions.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
|
recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound,
|
|
};
|
|
});
|
|
|
|
const makeContext = (): GatewayRequestContext =>
|
|
({
|
|
dedupe: new Map(),
|
|
}) as unknown as GatewayRequestContext;
|
|
|
|
async function runSend(params: Record<string, unknown>) {
|
|
const respond = vi.fn();
|
|
await sendHandlers.send({
|
|
params: params as never,
|
|
respond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "send" },
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
return { respond };
|
|
}
|
|
|
|
async function runPoll(params: Record<string, unknown>) {
|
|
const respond = vi.fn();
|
|
await sendHandlers.poll({
|
|
params: params as never,
|
|
respond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "poll" },
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
return { respond };
|
|
}
|
|
|
|
function expectDeliverySessionMirror(params: { agentId: string; sessionKey: string }) {
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
session: expect.objectContaining({
|
|
agentId: params.agentId,
|
|
key: params.sessionKey,
|
|
}),
|
|
mirror: expect.objectContaining({
|
|
sessionKey: params.sessionKey,
|
|
agentId: params.agentId,
|
|
}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function mockDeliverySuccess(messageId: string) {
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId, channel: "slack" }]);
|
|
}
|
|
|
|
describe("gateway send mirroring", () => {
|
|
let registrySeq = 0;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
registrySeq += 1;
|
|
setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`);
|
|
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
|
|
mocks.resolveMessageChannelSelection.mockResolvedValue({
|
|
channel: "slack",
|
|
configured: ["slack"],
|
|
});
|
|
mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
|
|
mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } });
|
|
});
|
|
|
|
it("accepts media-only sends without message", async () => {
|
|
mockDeliverySuccess("m-media");
|
|
|
|
const { respond } = await runSend({
|
|
to: "channel:C1",
|
|
mediaUrl: "https://example.com/a.png",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-media-only",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payloads: [{ text: "", mediaUrl: "https://example.com/a.png", mediaUrls: undefined }],
|
|
}),
|
|
);
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
expect.objectContaining({ messageId: "m-media" }),
|
|
undefined,
|
|
expect.objectContaining({ channel: "slack" }),
|
|
);
|
|
});
|
|
|
|
it("rejects empty sends when neither text nor media is present", async () => {
|
|
const { respond } = await runSend({
|
|
to: "channel:C1",
|
|
message: " ",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-empty",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
undefined,
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("text or media is required"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns actionable guidance when channel is internal webchat", async () => {
|
|
const { respond } = await runSend({
|
|
to: "x",
|
|
message: "hi",
|
|
channel: "webchat",
|
|
idempotencyKey: "idem-webchat",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
undefined,
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("unsupported channel: webchat"),
|
|
}),
|
|
);
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
undefined,
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("Use `chat.send`"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("auto-picks the single configured channel for send", async () => {
|
|
mockDeliverySuccess("m-single-send");
|
|
|
|
const { respond } = await runSend({
|
|
to: "x",
|
|
message: "hi",
|
|
idempotencyKey: "idem-missing-channel",
|
|
});
|
|
|
|
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalled();
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
expect.objectContaining({ messageId: "m-single-send" }),
|
|
undefined,
|
|
expect.objectContaining({ channel: "slack" }),
|
|
);
|
|
});
|
|
|
|
it("returns invalid request when send channel selection is ambiguous", async () => {
|
|
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
|
|
new Error("Channel is required when multiple channels are configured: telegram, slack"),
|
|
);
|
|
|
|
const { respond } = await runSend({
|
|
to: "x",
|
|
message: "hi",
|
|
idempotencyKey: "idem-missing-channel-ambiguous",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
undefined,
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("Channel is required"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("auto-picks the single configured channel for poll", async () => {
|
|
const { respond } = await runPoll({
|
|
to: "x",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
idempotencyKey: "idem-poll-missing-channel",
|
|
});
|
|
|
|
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
|
|
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined, {
|
|
channel: "slack",
|
|
});
|
|
});
|
|
|
|
it("returns invalid request when poll channel selection is ambiguous", async () => {
|
|
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
|
|
new Error("Channel is required when multiple channels are configured: telegram, slack"),
|
|
);
|
|
|
|
const { respond } = await runPoll({
|
|
to: "x",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
idempotencyKey: "idem-poll-missing-channel-ambiguous",
|
|
});
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
undefined,
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("Channel is required"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not mirror when delivery returns no results", async () => {
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-1",
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:main:main",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("mirrors media filenames when delivery succeeds", async () => {
|
|
mockDeliverySuccess("m1");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "caption",
|
|
mediaUrl: "https://example.com/files/report.pdf?sig=1",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-2",
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:main:main",
|
|
text: "caption",
|
|
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
|
|
idempotencyKey: "idem-2",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("mirrors MEDIA tags as attachments", async () => {
|
|
mockDeliverySuccess("m2");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "Here\nMEDIA:https://example.com/image.png",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-3",
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:main:main",
|
|
text: "Here",
|
|
mediaUrls: ["https://example.com/image.png"],
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lowercases provided session keys for mirroring", async () => {
|
|
mockDeliverySuccess("m-lower");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-lower",
|
|
sessionKey: "agent:main:slack:channel:C123",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:main:slack:channel:c123",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("derives a target session key when none is provided", async () => {
|
|
mockDeliverySuccess("m3");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hello",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-4",
|
|
});
|
|
|
|
expect(mocks.recordSessionMetaFromInbound).toHaveBeenCalled();
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:main:slack:channel:resolved",
|
|
agentId: "main",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses explicit agentId for delivery when sessionKey is not provided", async () => {
|
|
mockDeliverySuccess("m-agent");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hello",
|
|
channel: "slack",
|
|
agentId: "work",
|
|
idempotencyKey: "idem-agent-explicit",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
session: expect.objectContaining({
|
|
agentId: "work",
|
|
key: "agent:work:slack:channel:resolved",
|
|
}),
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:work:slack:channel:resolved",
|
|
agentId: "work",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses sessionKey agentId when explicit agentId is omitted", async () => {
|
|
mockDeliverySuccess("m-session-agent");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hello",
|
|
channel: "slack",
|
|
sessionKey: "agent:work:slack:channel:c1",
|
|
idempotencyKey: "idem-session-agent",
|
|
});
|
|
|
|
expectDeliverySessionMirror({
|
|
agentId: "work",
|
|
sessionKey: "agent:work:slack:channel:c1",
|
|
});
|
|
});
|
|
|
|
it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => {
|
|
mockDeliverySuccess("m-agent-precedence");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hello",
|
|
channel: "slack",
|
|
agentId: "work",
|
|
sessionKey: "agent:main:slack:channel:c1",
|
|
idempotencyKey: "idem-agent-precedence",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
session: expect.objectContaining({
|
|
agentId: "work",
|
|
key: "agent:main:slack:channel:c1",
|
|
}),
|
|
mirror: expect.objectContaining({
|
|
sessionKey: "agent:main:slack:channel:c1",
|
|
agentId: "work",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("ignores blank explicit agentId and falls back to sessionKey agent", async () => {
|
|
mockDeliverySuccess("m-agent-blank");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hello",
|
|
channel: "slack",
|
|
agentId: " ",
|
|
sessionKey: "agent:work:slack:channel:c1",
|
|
idempotencyKey: "idem-agent-blank",
|
|
});
|
|
|
|
expectDeliverySessionMirror({
|
|
agentId: "work",
|
|
sessionKey: "agent:work:slack:channel:c1",
|
|
});
|
|
});
|
|
|
|
it("forwards threadId to outbound delivery when provided", async () => {
|
|
mockDeliverySuccess("m-thread");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
threadId: "1710000000.9999",
|
|
idempotencyKey: "idem-thread",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
threadId: "1710000000.9999",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns invalid request when outbound target resolution fails", async () => {
|
|
vi.mocked(resolveOutboundTarget).mockReturnValue({
|
|
ok: false,
|
|
error: new Error("target not found"),
|
|
});
|
|
|
|
const { respond } = await runSend({
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-target-fail",
|
|
});
|
|
|
|
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
|
expect(respond).toHaveBeenCalledWith(
|
|
false,
|
|
undefined,
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("target not found"),
|
|
}),
|
|
expect.objectContaining({
|
|
channel: "slack",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("recovers cold plugin resolution for telegram threaded sends", async () => {
|
|
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" });
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([
|
|
{ messageId: "m-telegram", channel: "telegram" },
|
|
]);
|
|
const telegramPlugin = { outbound: { sendPoll: mocks.sendPoll } };
|
|
mocks.getChannelPlugin
|
|
.mockReturnValueOnce(undefined)
|
|
.mockReturnValueOnce(telegramPlugin)
|
|
.mockReturnValue(telegramPlugin);
|
|
|
|
const { respond } = await runSend({
|
|
to: "123",
|
|
message: "forum completion",
|
|
channel: "telegram",
|
|
threadId: "42",
|
|
idempotencyKey: "idem-cold-telegram-thread",
|
|
});
|
|
|
|
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
to: "123",
|
|
threadId: "42",
|
|
}),
|
|
);
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
expect.objectContaining({ messageId: "m-telegram" }),
|
|
undefined,
|
|
expect.objectContaining({ channel: "telegram" }),
|
|
);
|
|
});
|
|
});
|