mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 17:26:30 +00:00
1985 lines
66 KiB
TypeScript
1985 lines
66 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { jsonResult } from "../../agents/tools/common.js";
|
|
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
|
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
|
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
|
import type { GatewayRequestContext } from "./types.js";
|
|
|
|
type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget;
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
deliverOutboundPayloads: vi.fn(),
|
|
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
|
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
|
|
resolveOutboundTarget: vi.fn<ResolveOutboundTarget>(() => ({ ok: true, to: "resolved" })),
|
|
resolveOutboundSessionRoute: vi.fn(),
|
|
ensureOutboundSessionEntry: vi.fn(async () => undefined),
|
|
resolveMessageChannelSelection: vi.fn(),
|
|
dispatchChannelMessageAction: vi.fn(),
|
|
sendPoll: vi.fn<
|
|
() => Promise<{
|
|
messageId: string;
|
|
toJid?: string;
|
|
channelId?: string;
|
|
conversationId?: string;
|
|
pollId?: string;
|
|
}>
|
|
>(async () => ({ messageId: "poll-1" })),
|
|
getChannelPlugin: vi.fn(),
|
|
loadOpenClawPlugins: vi.fn(),
|
|
applyPluginAutoEnable: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../config/config.js", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
|
return {
|
|
...actual,
|
|
getRuntimeConfig: () => ({}),
|
|
};
|
|
});
|
|
|
|
vi.mock("../../channels/plugins/index.js", () => ({
|
|
getLoadedChannelPlugin: mocks.getChannelPlugin,
|
|
getChannelPlugin: mocks.getChannelPlugin,
|
|
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
|
|
}));
|
|
|
|
vi.mock("../../channels/plugins/message-action-dispatch.js", () => ({
|
|
dispatchChannelMessageAction: mocks.dispatchChannelMessageAction,
|
|
}));
|
|
|
|
const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace";
|
|
let sendHandlers: typeof import("./send.js").sendHandlers;
|
|
|
|
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";
|
|
}
|
|
|
|
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, env }: { config: unknown; env?: unknown }) =>
|
|
mocks.applyPluginAutoEnable({ config, env }),
|
|
}));
|
|
|
|
vi.mock("../../plugins/loader.js", () => ({
|
|
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
|
|
resolveRuntimePluginRegistry: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/channel-bootstrap.runtime.js", () => ({
|
|
bootstrapOutboundChannelPlugin: vi.fn(),
|
|
resetOutboundChannelBootstrapStateForTests: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/targets.js", () => ({
|
|
resolveOutboundTarget: mocks.resolveOutboundTarget,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/outbound-session.js", () => ({
|
|
resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
|
|
ensureOutboundSessionEntry: mocks.ensureOutboundSessionEntry,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/channel-selection.js", () => ({
|
|
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/deliver.js", () => ({
|
|
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
|
deliverOutboundPayloadsInternal: 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,
|
|
};
|
|
});
|
|
|
|
async function loadSendHandlersForTest() {
|
|
({ sendHandlers } = await import("./send.js"));
|
|
}
|
|
|
|
const makeContext = (): GatewayRequestContext =>
|
|
({
|
|
dedupe: new Map(),
|
|
getRuntimeConfig: () => ({}),
|
|
}) as unknown as GatewayRequestContext;
|
|
|
|
async function runSend(params: Record<string, unknown>) {
|
|
return await runSendWithClient(params);
|
|
}
|
|
|
|
async function runSendWithClient(
|
|
params: Record<string, unknown>,
|
|
client?: { connect?: { scopes?: string[] } } | null,
|
|
) {
|
|
const respond = vi.fn();
|
|
await sendHandlers.send({
|
|
params: params as never,
|
|
respond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "send" },
|
|
client: (client ?? null) as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
return { respond };
|
|
}
|
|
|
|
async function runPoll(params: Record<string, unknown>) {
|
|
return await runPollWithClient(params);
|
|
}
|
|
|
|
async function runPollWithClient(
|
|
params: Record<string, unknown>,
|
|
client?: { connect?: { scopes?: string[] } } | null,
|
|
) {
|
|
const respond = vi.fn();
|
|
await sendHandlers.poll({
|
|
params: params as never,
|
|
respond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "poll" },
|
|
client: (client ?? null) as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
return { respond };
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function runMessageActionRequest(
|
|
params: Record<string, unknown>,
|
|
client?: { connect?: { scopes?: string[] } } | null,
|
|
) {
|
|
const respond = vi.fn();
|
|
await sendHandlers["message.action"]({
|
|
params: params as never,
|
|
respond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "message.action" },
|
|
client: (client ?? null) as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
return { respond };
|
|
}
|
|
|
|
function deliveryCall(index = 0): Record<string, any> | undefined {
|
|
const calls = mocks.deliverOutboundPayloads.mock.calls as unknown as Array<[Record<string, any>]>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function appendTranscriptCall(index = 0): Record<string, any> | undefined {
|
|
const calls = mocks.appendAssistantMessageToSessionTranscript.mock.calls as unknown as Array<
|
|
[Record<string, any>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function firstRespondCall(respond: ReturnType<typeof vi.fn>) {
|
|
const calls = respond.mock.calls as unknown as Array<
|
|
[
|
|
boolean,
|
|
Record<string, any> | undefined,
|
|
Record<string, any> | undefined,
|
|
Record<string, any> | undefined,
|
|
]
|
|
>;
|
|
const call = calls[0];
|
|
if (!call) {
|
|
throw new Error("Expected respond call");
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function lastDispatchChannelMessageActionCall(): Record<string, any> | undefined {
|
|
const calls = mocks.dispatchChannelMessageAction.mock.calls as unknown as Array<
|
|
[Record<string, any>]
|
|
>;
|
|
return calls.at(-1)?.[0];
|
|
}
|
|
|
|
function pollCall(index = 0): Record<string, any> {
|
|
const calls = mocks.sendPoll.mock.calls as unknown as Array<[Record<string, any>]>;
|
|
const call = calls[index]?.[0];
|
|
if (!call) {
|
|
throw new Error(`Expected poll call at index ${index}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function outboundRouteCall(index = 0): Record<string, any> | undefined {
|
|
const calls = mocks.resolveOutboundSessionRoute.mock.calls as unknown as Array<
|
|
[Record<string, any>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function ensureSessionEntryCall(index = 0): Record<string, any> | undefined {
|
|
const calls = mocks.ensureOutboundSessionEntry.mock.calls as unknown as Array<
|
|
[Record<string, any>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function expectDeliverySessionMirror(params: { agentId: string; sessionKey: string }) {
|
|
const call = deliveryCall();
|
|
expect(call?.session?.agentId).toBe(params.agentId);
|
|
expect(call?.session?.key).toBe(params.sessionKey);
|
|
expect(call?.mirror?.sessionKey).toBe(params.sessionKey);
|
|
expect(call?.mirror?.agentId).toBe(params.agentId);
|
|
}
|
|
|
|
function mockDeliverySuccess(messageId: string) {
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId, channel: "slack" }]);
|
|
}
|
|
|
|
describe("gateway send mirroring", () => {
|
|
let registrySeq = 0;
|
|
|
|
beforeAll(async () => {
|
|
await loadSendHandlersForTest();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
registrySeq += 1;
|
|
setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`);
|
|
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({
|
|
config,
|
|
changes: [],
|
|
autoEnabledReasons: {},
|
|
}));
|
|
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
|
|
mocks.resolveOutboundSessionRoute.mockImplementation(
|
|
async ({ agentId, channel }: { agentId?: string; channel?: string }) => ({
|
|
sessionKey:
|
|
channel === "slack"
|
|
? `agent:${agentId ?? "main"}:slack:channel:resolved`
|
|
: `agent:${agentId ?? "main"}:${channel ?? "main"}:resolved`,
|
|
}),
|
|
);
|
|
mocks.resolveMessageChannelSelection.mockResolvedValue({
|
|
channel: "slack",
|
|
configured: ["slack"],
|
|
});
|
|
mocks.dispatchChannelMessageAction.mockResolvedValue({
|
|
details: { action: "handled" },
|
|
});
|
|
mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
|
|
mocks.getChannelPlugin.mockReturnValue({
|
|
actions: { handleAction: true },
|
|
outbound: { sendPoll: mocks.sendPoll },
|
|
});
|
|
});
|
|
|
|
it("dedupes concurrent message.action requests while inflight", async () => {
|
|
const context = makeContext();
|
|
const firstRespond = vi.fn();
|
|
const secondRespond = vi.fn();
|
|
const actionDeferred = createDeferred<{ details: { action: string } }>();
|
|
mocks.dispatchChannelMessageAction.mockReturnValueOnce(actionDeferred.promise);
|
|
|
|
const firstRequest = sendHandlers["message.action"]({
|
|
params: {
|
|
channel: "slack",
|
|
action: "poll",
|
|
params: { question: "Q?" },
|
|
idempotencyKey: "idem-action-concurrent",
|
|
} as never,
|
|
respond: firstRespond,
|
|
context,
|
|
req: { type: "req", id: "1", method: "message.action" },
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
const secondRequest = sendHandlers["message.action"]({
|
|
params: {
|
|
channel: "slack",
|
|
action: "poll",
|
|
params: { question: "Q?" },
|
|
idempotencyKey: "idem-action-concurrent",
|
|
} as never,
|
|
respond: secondRespond,
|
|
context,
|
|
req: { type: "req", id: "2", method: "message.action" },
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1);
|
|
|
|
actionDeferred.resolve({ details: { action: "handled" } });
|
|
await Promise.all([firstRequest, secondRequest]);
|
|
|
|
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1);
|
|
expect(firstRespond).toHaveBeenCalledTimes(1);
|
|
expect(secondRespond).toHaveBeenCalledTimes(1);
|
|
const firstCall = firstRespondCall(firstRespond);
|
|
expect(firstCall?.[0]).toBe(true);
|
|
expect(firstCall?.[1]).toEqual({ action: "handled" });
|
|
expect(firstCall?.[2]).toBeUndefined();
|
|
expect(firstCall?.[3]?.channel).toBe("slack");
|
|
expect(firstCall?.[3]?.cached).toBeUndefined();
|
|
const secondCall = firstRespondCall(secondRespond);
|
|
expect(secondCall?.[0]).toBe(true);
|
|
expect(secondCall?.[1]).toEqual({ action: "handled" });
|
|
expect(secondCall?.[2]).toBeUndefined();
|
|
expect(secondCall?.[3]?.channel).toBe("slack");
|
|
expect(secondCall?.[3]?.cached).toBe(true);
|
|
});
|
|
|
|
it("dedupes concurrent send requests while inflight", async () => {
|
|
const context = makeContext();
|
|
const firstRespond = vi.fn();
|
|
const secondRespond = vi.fn();
|
|
const deliveryDeferred = createDeferred<Array<{ messageId: string; channel: string }>>();
|
|
mocks.deliverOutboundPayloads.mockReturnValueOnce(deliveryDeferred.promise);
|
|
|
|
const firstRequest = sendHandlers.send({
|
|
params: {
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-send-concurrent",
|
|
} as never,
|
|
respond: firstRespond,
|
|
context,
|
|
req: { type: "req", id: "1", method: "send" },
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
const secondRequest = sendHandlers.send({
|
|
params: {
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-send-concurrent",
|
|
} as never,
|
|
respond: secondRespond,
|
|
context,
|
|
req: { type: "req", id: "2", method: "send" },
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
deliveryDeferred.resolve([{ messageId: "m-concurrent", channel: "slack" }]);
|
|
await Promise.all([firstRequest, secondRequest]);
|
|
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
|
expect(firstRespond).toHaveBeenCalledTimes(1);
|
|
expect(secondRespond).toHaveBeenCalledTimes(1);
|
|
const firstCall = firstRespondCall(firstRespond);
|
|
expect(firstCall?.[0]).toBe(true);
|
|
expect(firstCall?.[1]?.messageId).toBe("m-concurrent");
|
|
expect(firstCall?.[1]?.runId).toBe("idem-send-concurrent");
|
|
expect(firstCall?.[2]).toBeUndefined();
|
|
expect(firstCall?.[3]?.channel).toBe("slack");
|
|
expect(firstCall?.[3]?.cached).toBeUndefined();
|
|
const secondCall = firstRespondCall(secondRespond);
|
|
expect(secondCall?.[0]).toBe(true);
|
|
expect(secondCall?.[1]?.messageId).toBe("m-concurrent");
|
|
expect(secondCall?.[1]?.runId).toBe("idem-send-concurrent");
|
|
expect(secondCall?.[2]).toBeUndefined();
|
|
expect(secondCall?.[3]?.channel).toBe("slack");
|
|
expect(secondCall?.[3]?.cached).toBe(true);
|
|
});
|
|
|
|
it("dedupes concurrent poll requests while inflight", async () => {
|
|
const context = makeContext();
|
|
const firstRespond = vi.fn();
|
|
const secondRespond = vi.fn();
|
|
const pollDeferred = createDeferred<{ messageId: string; pollId: string }>();
|
|
mocks.sendPoll.mockReturnValueOnce(pollDeferred.promise);
|
|
|
|
const firstRequest = sendHandlers.poll({
|
|
params: {
|
|
to: "channel:C1",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
channel: "slack",
|
|
idempotencyKey: "idem-poll-concurrent",
|
|
} as never,
|
|
respond: firstRespond,
|
|
context,
|
|
req: { type: "req", id: "1", method: "poll" },
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
const secondRequest = sendHandlers.poll({
|
|
params: {
|
|
to: "channel:C1",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
channel: "slack",
|
|
idempotencyKey: "idem-poll-concurrent",
|
|
} as never,
|
|
respond: secondRespond,
|
|
context,
|
|
req: { type: "req", id: "2", method: "poll" },
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(mocks.sendPoll).toHaveBeenCalledTimes(1);
|
|
|
|
pollDeferred.resolve({ messageId: "poll-concurrent", pollId: "poll-1" });
|
|
await Promise.all([firstRequest, secondRequest]);
|
|
|
|
expect(mocks.sendPoll).toHaveBeenCalledTimes(1);
|
|
expect(firstRespond).toHaveBeenCalledTimes(1);
|
|
expect(secondRespond).toHaveBeenCalledTimes(1);
|
|
const firstCall = firstRespondCall(firstRespond);
|
|
expect(firstCall?.[0]).toBe(true);
|
|
expect(firstCall?.[1]?.messageId).toBe("poll-concurrent");
|
|
expect(firstCall?.[1]?.pollId).toBe("poll-1");
|
|
expect(firstCall?.[1]?.runId).toBe("idem-poll-concurrent");
|
|
expect(firstCall?.[2]).toBeUndefined();
|
|
expect(firstCall?.[3]?.channel).toBe("slack");
|
|
expect(firstCall?.[3]?.cached).toBeUndefined();
|
|
const secondCall = firstRespondCall(secondRespond);
|
|
expect(secondCall?.[0]).toBe(true);
|
|
expect(secondCall?.[1]?.messageId).toBe("poll-concurrent");
|
|
expect(secondCall?.[1]?.pollId).toBe("poll-1");
|
|
expect(secondCall?.[1]?.runId).toBe("idem-poll-concurrent");
|
|
expect(secondCall?.[2]).toBeUndefined();
|
|
expect(secondCall?.[3]?.channel).toBe("slack");
|
|
expect(secondCall?.[3]?.cached).toBe(true);
|
|
});
|
|
|
|
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(deliveryCall()?.payloads).toEqual([
|
|
{ text: "", mediaUrl: "https://example.com/a.png", mediaUrls: undefined },
|
|
]);
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]?.messageId).toBe("m-media");
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
it("passes outbound session context for gateway media sends", async () => {
|
|
mockDeliverySuccess("m-whatsapp-media");
|
|
|
|
await runSend({
|
|
to: "+15551234567",
|
|
message: "caption",
|
|
mediaUrl: "file:///tmp/workspace/photo.png",
|
|
channel: "whatsapp",
|
|
agentId: "work",
|
|
idempotencyKey: "idem-whatsapp-media",
|
|
});
|
|
|
|
expect(deliveryCall()?.channel).toBe("whatsapp");
|
|
expect(deliveryCall()?.payloads).toEqual([
|
|
{
|
|
text: "caption",
|
|
mediaUrl: "file:///tmp/workspace/photo.png",
|
|
mediaUrls: undefined,
|
|
},
|
|
]);
|
|
expect(deliveryCall()?.session?.agentId).toBe("work");
|
|
expect(deliveryCall()?.session?.key).toBe("agent:work:whatsapp:resolved");
|
|
});
|
|
|
|
it("maps gateway asVoice sends onto outbound audioAsVoice payloads", async () => {
|
|
mockDeliverySuccess("m-voice");
|
|
|
|
const { respond } = await runSend({
|
|
to: "channel:C1",
|
|
message: "voice note",
|
|
mediaUrl: "file:///tmp/openclaw-voice.ogg",
|
|
asVoice: true,
|
|
channel: "slack",
|
|
idempotencyKey: "idem-voice",
|
|
});
|
|
|
|
expect(deliveryCall()?.payloads?.[0]?.text).toBe("voice note");
|
|
expect(deliveryCall()?.payloads?.[0]?.mediaUrl).toBe("file:///tmp/openclaw-voice.ogg");
|
|
expect(deliveryCall()?.payloads?.[0]?.audioAsVoice).toBe(true);
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]?.messageId).toBe("m-voice");
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
it("forwards gateway client scopes into outbound delivery", async () => {
|
|
mockDeliverySuccess("m-scope");
|
|
|
|
await runSendWithClient(
|
|
{
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-scope",
|
|
},
|
|
{ connect: { scopes: ["operator.write"] } },
|
|
);
|
|
|
|
expect(deliveryCall()?.channel).toBe("slack");
|
|
expect(deliveryCall()?.gatewayClientScopes).toEqual(["operator.write"]);
|
|
});
|
|
|
|
it("forwards an empty gateway scope array into outbound delivery", async () => {
|
|
mockDeliverySuccess("m-empty-scope");
|
|
|
|
await runSendWithClient(
|
|
{
|
|
to: "channel:C1",
|
|
message: "hi",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-empty-scope",
|
|
},
|
|
{ connect: { scopes: [] } },
|
|
);
|
|
|
|
expect(deliveryCall()?.channel).toBe("slack");
|
|
expect(deliveryCall()?.gatewayClientScopes).toEqual([]);
|
|
});
|
|
|
|
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();
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(false);
|
|
expect(response?.[1]).toBeUndefined();
|
|
expect(response?.[2]?.message).toContain("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();
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(false);
|
|
expect(response?.[1]).toBeUndefined();
|
|
expect(response?.[2]?.message).toContain("unsupported channel: webchat");
|
|
expect(response?.[2]?.message).toContain("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();
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]?.messageId).toBe("m-single-send");
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
it("auto-picks the single configured channel from the auto-enabled config snapshot for send", async () => {
|
|
const autoEnabledConfig = { channels: { slack: {} }, plugins: { allow: ["slack"] } };
|
|
mocks.applyPluginAutoEnable.mockReturnValue({
|
|
config: autoEnabledConfig,
|
|
changes: [],
|
|
autoEnabledReasons: {},
|
|
});
|
|
mockDeliverySuccess("m-single-send-auto");
|
|
|
|
const { respond } = await runSend({
|
|
to: "x",
|
|
message: "hi",
|
|
idempotencyKey: "idem-missing-channel-auto-enabled",
|
|
});
|
|
|
|
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
|
|
config: {},
|
|
});
|
|
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({
|
|
cfg: autoEnabledConfig,
|
|
});
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]?.messageId).toBe("m-single-send-auto");
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("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();
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(false);
|
|
expect(response?.[1]).toBeUndefined();
|
|
expect(response?.[2]?.message).toContain("Channel is required");
|
|
});
|
|
|
|
it("forwards gateway client scopes into outbound poll delivery", async () => {
|
|
await runPollWithClient(
|
|
{
|
|
to: "channel:C1",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
channel: "slack",
|
|
idempotencyKey: "idem-poll-scope",
|
|
},
|
|
{ connect: { scopes: ["operator.admin"] } },
|
|
);
|
|
|
|
const call = pollCall();
|
|
if (call.cfg === undefined) {
|
|
throw new Error("Expected poll delivery config");
|
|
}
|
|
expect(call.to).toBe("resolved");
|
|
expect(call.gatewayClientScopes).toEqual(["operator.admin"]);
|
|
});
|
|
|
|
it("forwards an empty gateway scope array into outbound poll delivery", async () => {
|
|
await runPollWithClient(
|
|
{
|
|
to: "channel:C1",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
channel: "slack",
|
|
idempotencyKey: "idem-poll-empty-scope",
|
|
},
|
|
{ connect: { scopes: [] } },
|
|
);
|
|
|
|
const call = pollCall();
|
|
if (call.cfg === undefined) {
|
|
throw new Error("Expected poll delivery config");
|
|
}
|
|
expect(call.to).toBe("resolved");
|
|
expect(call.gatewayClientScopes).toEqual([]);
|
|
});
|
|
|
|
it("includes optional poll delivery identifiers in the gateway payload", async () => {
|
|
mocks.sendPoll.mockResolvedValue({
|
|
messageId: "poll-rich",
|
|
channelId: "C123",
|
|
conversationId: "conv-1",
|
|
toJid: "jid-1",
|
|
pollId: "poll-meta-1",
|
|
});
|
|
|
|
const { respond } = await runPoll({
|
|
to: "channel:C1",
|
|
question: "Q?",
|
|
options: ["A", "B"],
|
|
channel: "slack",
|
|
idempotencyKey: "idem-poll-rich",
|
|
});
|
|
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]).toEqual({
|
|
runId: "idem-poll-rich",
|
|
messageId: "poll-rich",
|
|
channel: "slack",
|
|
channelId: "C123",
|
|
conversationId: "conv-1",
|
|
toJid: "jid-1",
|
|
pollId: "poll-meta-1",
|
|
});
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
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();
|
|
const response = firstRespondCall(respond);
|
|
expect(response[0]).toBe(true);
|
|
if (response[1] === undefined) {
|
|
throw new Error("Expected poll missing-channel response payload");
|
|
}
|
|
expect(response[2]).toBeUndefined();
|
|
expect(response[3]).toEqual({ 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",
|
|
});
|
|
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(false);
|
|
expect(response?.[1]).toBeUndefined();
|
|
expect(response?.[2]?.message).toContain("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(deliveryCall()?.mirror?.sessionKey).toBe("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(deliveryCall()?.mirror?.sessionKey).toBe("agent:main:main");
|
|
expect(deliveryCall()?.mirror?.text).toBe("caption");
|
|
expect(deliveryCall()?.mirror?.mediaUrls).toEqual([
|
|
"https://example.com/files/report.pdf?sig=1",
|
|
]);
|
|
expect(deliveryCall()?.mirror?.idempotencyKey).toBe("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(deliveryCall()?.mirror?.sessionKey).toBe("agent:main:main");
|
|
expect(deliveryCall()?.mirror?.text).toBe("Here");
|
|
expect(deliveryCall()?.mirror?.mediaUrls).toEqual(["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(deliveryCall()?.mirror?.sessionKey).toBe("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(deliveryCall()?.mirror?.sessionKey).toBe("agent:main:slack:channel:resolved");
|
|
expect(deliveryCall()?.mirror?.agentId).toBe("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(deliveryCall()?.session?.agentId).toBe("work");
|
|
expect(deliveryCall()?.session?.key).toBe("agent:work:slack:channel:resolved");
|
|
expect(deliveryCall()?.mirror?.sessionKey).toBe("agent:work:slack:channel:resolved");
|
|
expect(deliveryCall()?.mirror?.agentId).toBe("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("still resolves outbound routing metadata when a sessionKey is provided", async () => {
|
|
mockDeliverySuccess("m-matrix-session-route");
|
|
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
|
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
|
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
|
|
peer: { kind: "channel", id: "!dm:example.org" },
|
|
chatType: "direct",
|
|
from: "matrix:@alice:example.org",
|
|
to: "room:!dm:example.org",
|
|
});
|
|
|
|
await runSend({
|
|
to: "@alice:example.org",
|
|
message: "hello",
|
|
channel: "matrix",
|
|
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
|
idempotencyKey: "idem-matrix-session-route",
|
|
});
|
|
|
|
expect(outboundRouteCall()?.channel).toBe("matrix");
|
|
expect(outboundRouteCall()?.target).toBe("resolved");
|
|
expect(outboundRouteCall()?.currentSessionKey).toBe(
|
|
"agent:main:matrix:channel:!dm:example.org",
|
|
);
|
|
expect(ensureSessionEntryCall()?.route?.sessionKey).toBe(
|
|
"agent:main:matrix:channel:!dm:example.org",
|
|
);
|
|
expect(ensureSessionEntryCall()?.route?.baseSessionKey).toBe(
|
|
"agent:main:matrix:channel:!dm:example.org",
|
|
);
|
|
expect(ensureSessionEntryCall()?.route?.to).toBe("room:!dm:example.org");
|
|
expectDeliverySessionMirror({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:matrix:channel:!dm:example.org",
|
|
});
|
|
});
|
|
|
|
it("falls back to the provided sessionKey when outbound route lookup returns null", async () => {
|
|
mockDeliverySuccess("m-session-fallback");
|
|
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce(null);
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "hello",
|
|
channel: "slack",
|
|
sessionKey: "agent:work:slack:channel:c1",
|
|
idempotencyKey: "idem-session-fallback",
|
|
});
|
|
|
|
expect(mocks.ensureOutboundSessionEntry).not.toHaveBeenCalled();
|
|
expect(deliveryCall()?.session?.agentId).toBe("work");
|
|
expect(deliveryCall()?.session?.key).toBe("agent:work:slack:channel:c1");
|
|
expect(deliveryCall()?.mirror?.sessionKey).toBe("agent:work:slack:channel:c1");
|
|
expect(deliveryCall()?.mirror?.agentId).toBe("work");
|
|
});
|
|
|
|
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(deliveryCall()?.session?.agentId).toBe("work");
|
|
expect(deliveryCall()?.session?.key).toBe("agent:main:slack:channel:c1");
|
|
expect(deliveryCall()?.mirror?.sessionKey).toBe("agent:main:slack:channel:c1");
|
|
expect(deliveryCall()?.mirror?.agentId).toBe("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(deliveryCall()?.threadId).toBe("1710000000.9999");
|
|
});
|
|
|
|
it("forwards gateway send delivery options to outbound delivery", async () => {
|
|
mockDeliverySuccess("m-options");
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "<b>report</b>",
|
|
channel: "slack",
|
|
forceDocument: true,
|
|
silent: true,
|
|
parseMode: "HTML",
|
|
idempotencyKey: "idem-send-options",
|
|
});
|
|
|
|
const options = mocks.deliverOutboundPayloads.mock.calls.at(0)?.[0];
|
|
expect(options?.forceDocument).toBe(true);
|
|
expect(options?.silent).toBe(true);
|
|
expect(options?.formatting).toEqual({ parseMode: "HTML" });
|
|
});
|
|
|
|
it("updates mirror session keys and delivery thread ids when Slack routing derives a thread", async () => {
|
|
mockDeliverySuccess("m-thread-derived");
|
|
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
|
sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
|
|
baseSessionKey: "agent:main:slack:channel:c1",
|
|
peer: { kind: "channel", id: "c1" },
|
|
chatType: "channel",
|
|
from: "slack:channel:C1",
|
|
to: "channel:C1",
|
|
threadId: "1710000000.9999",
|
|
});
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "threaded",
|
|
channel: "slack",
|
|
sessionKey: "agent:main:slack:channel:c1",
|
|
idempotencyKey: "idem-thread-derived",
|
|
});
|
|
|
|
expect(ensureSessionEntryCall()?.route?.sessionKey).toBe(
|
|
"agent:main:slack:channel:c1:thread:1710000000.9999",
|
|
);
|
|
expect(ensureSessionEntryCall()?.route?.baseSessionKey).toBe("agent:main:slack:channel:c1");
|
|
expect(ensureSessionEntryCall()?.route?.threadId).toBe("1710000000.9999");
|
|
expect(deliveryCall()?.threadId).toBe("1710000000.9999");
|
|
expect(deliveryCall()?.mirror?.sessionKey).toBe(
|
|
"agent:main:slack:channel:c1:thread:1710000000.9999",
|
|
);
|
|
});
|
|
|
|
it("preserves the provided session when Slack derives a thread for a different base session", async () => {
|
|
mockDeliverySuccess("m-thread-mismatch");
|
|
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
|
sessionKey: "agent:main:slack:channel:c2:thread:1710000000.9999",
|
|
baseSessionKey: "agent:main:slack:channel:c2",
|
|
peer: { kind: "channel", id: "c2" },
|
|
chatType: "channel",
|
|
from: "slack:channel:C2",
|
|
to: "channel:C2",
|
|
threadId: "1710000000.9999",
|
|
});
|
|
|
|
await runSend({
|
|
to: "channel:C2",
|
|
message: "threaded",
|
|
channel: "slack",
|
|
sessionKey: "agent:main:slack:channel:c1",
|
|
threadId: "1710000000.9999",
|
|
idempotencyKey: "idem-thread-mismatch",
|
|
});
|
|
|
|
expect(deliveryCall()?.threadId).toBe("1710000000.9999");
|
|
expect(deliveryCall()?.session?.key).toBe("agent:main:slack:channel:c1");
|
|
expect(deliveryCall()?.mirror?.sessionKey).toBe("agent:main:slack:channel:c1");
|
|
});
|
|
|
|
it("preserves derived thread delivery for existing thread-scoped Slack session keys", async () => {
|
|
mockDeliverySuccess("m-thread-session");
|
|
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
|
sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
|
|
baseSessionKey: "agent:main:slack:channel:c1",
|
|
peer: { kind: "channel", id: "c1" },
|
|
chatType: "channel",
|
|
from: "slack:channel:C1",
|
|
to: "channel:C1",
|
|
threadId: "1710000000.9999",
|
|
});
|
|
|
|
await runSend({
|
|
to: "channel:C1",
|
|
message: "threaded",
|
|
channel: "slack",
|
|
sessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
|
|
idempotencyKey: "idem-thread-session",
|
|
});
|
|
|
|
expect(deliveryCall()?.threadId).toBe("1710000000.9999");
|
|
expect(deliveryCall()?.session?.key).toBe("agent:main:slack:channel:c1:thread:1710000000.9999");
|
|
});
|
|
|
|
it("preserves numeric derived thread ids for non-Slack channels", async () => {
|
|
mockDeliverySuccess("m-topic-derived");
|
|
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
|
sessionKey: "agent:main:telegram:group:-100123:thread:77",
|
|
baseSessionKey: "agent:main:telegram:group:-100123",
|
|
peer: { kind: "group", id: "-100123" },
|
|
chatType: "group",
|
|
from: "telegram:group:-100123",
|
|
to: "channel:-100123",
|
|
threadId: 77,
|
|
});
|
|
|
|
await runSend({
|
|
to: "-100123:topic:77",
|
|
message: "topic message",
|
|
channel: "telegram",
|
|
idempotencyKey: "idem-topic-derived",
|
|
});
|
|
|
|
expect(deliveryCall()?.threadId).toBe(77);
|
|
});
|
|
|
|
it("returns invalid request when outbound target resolution fails", async () => {
|
|
mocks.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();
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(false);
|
|
expect(response?.[1]).toBeUndefined();
|
|
expect(response?.[2]?.message).toContain("target not found");
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
it("recovers cold plugin resolution for threaded sends", async () => {
|
|
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" });
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([
|
|
{ messageId: "m-threaded", channel: "slack" },
|
|
]);
|
|
const outboundPlugin = { outbound: { sendPoll: mocks.sendPoll } };
|
|
mocks.getChannelPlugin
|
|
.mockReturnValueOnce(undefined)
|
|
.mockReturnValueOnce(outboundPlugin)
|
|
.mockReturnValue(outboundPlugin);
|
|
|
|
const { respond } = await runSend({
|
|
to: "123",
|
|
message: "threaded completion",
|
|
channel: "slack",
|
|
threadId: "1710000000.9999",
|
|
idempotencyKey: "idem-cold-thread",
|
|
});
|
|
|
|
expect(deliveryCall()?.channel).toBe("slack");
|
|
expect(deliveryCall()?.to).toBe("123");
|
|
expect(deliveryCall()?.threadId).toBe("1710000000.9999");
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]?.messageId).toBe("m-threaded");
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
it("forwards replyToId on gateway sends", async () => {
|
|
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" });
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-reply", channel: "slack" }]);
|
|
const outboundPlugin = { outbound: { sendPoll: mocks.sendPoll } };
|
|
mocks.getChannelPlugin.mockReturnValue(outboundPlugin);
|
|
|
|
const { respond } = await runSend({
|
|
to: "123",
|
|
message: "threaded completion",
|
|
channel: "slack",
|
|
replyToId: "wamid.42",
|
|
idempotencyKey: "idem-reply-to",
|
|
});
|
|
|
|
expect(deliveryCall()?.channel).toBe("slack");
|
|
expect(deliveryCall()?.to).toBe("123");
|
|
expect(deliveryCall()?.replyToId).toBe("wamid.42");
|
|
expect(outboundRouteCall()?.channel).toBe("slack");
|
|
expect(outboundRouteCall()?.target).toBe("123");
|
|
expect(outboundRouteCall()?.replyToId).toBe("wamid.42");
|
|
const response = firstRespondCall(respond);
|
|
expect(response?.[0]).toBe(true);
|
|
expect(response?.[1]?.messageId).toBe("m-reply");
|
|
expect(response?.[2]).toBeUndefined();
|
|
expect(response?.[3]?.channel).toBe("slack");
|
|
});
|
|
|
|
it("dispatches message actions through the gateway for plugin-owned channels", async () => {
|
|
const reactPlugin: ChannelPlugin = {
|
|
id: "whatsapp",
|
|
meta: {
|
|
id: "whatsapp",
|
|
label: "WhatsApp",
|
|
selectionLabel: "WhatsApp",
|
|
docsPath: "/channels/whatsapp",
|
|
blurb: "WhatsApp action dispatch test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"], reactions: true },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["react"] }),
|
|
supportsAction: ({ action }) => action === "react",
|
|
handleAction: async ({ params, requesterSenderId, toolContext }) =>
|
|
jsonResult({
|
|
ok: true,
|
|
messageId: params.messageId,
|
|
requesterSenderId,
|
|
currentMessageId: toolContext?.currentMessageId,
|
|
currentGraphChannelId: toolContext?.currentGraphChannelId,
|
|
replyToMode: toolContext?.replyToMode,
|
|
hasRepliedRef: toolContext?.hasRepliedRef?.value,
|
|
skipCrossContextDecoration: toolContext?.skipCrossContextDecoration,
|
|
}),
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(reactPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "whatsapp",
|
|
source: "test",
|
|
plugin: reactPlugin,
|
|
},
|
|
]),
|
|
"send-test-message-action",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({
|
|
ok: true,
|
|
messageId: "wamid.1",
|
|
requesterSenderId: "trusted-user",
|
|
currentMessageId: "wamid.1",
|
|
currentGraphChannelId: "graph:team/chan",
|
|
replyToMode: "first",
|
|
hasRepliedRef: true,
|
|
skipCrossContextDecoration: true,
|
|
}),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "whatsapp",
|
|
action: "react",
|
|
params: {
|
|
chatJid: "+15551234567",
|
|
messageId: "wamid.1",
|
|
emoji: "✅",
|
|
},
|
|
requesterSenderId: "trusted-user",
|
|
inboundTurnKind: "room_event",
|
|
toolContext: {
|
|
currentGraphChannelId: "graph:team/chan",
|
|
currentChannelProvider: "whatsapp",
|
|
currentMessageId: "wamid.1",
|
|
replyToMode: "first",
|
|
hasRepliedRef: { value: true },
|
|
skipCrossContextDecoration: true,
|
|
},
|
|
idempotencyKey: "idem-message-action",
|
|
});
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
ok: true,
|
|
messageId: "wamid.1",
|
|
requesterSenderId: "trusted-user",
|
|
currentMessageId: "wamid.1",
|
|
currentGraphChannelId: "graph:team/chan",
|
|
replyToMode: "first",
|
|
hasRepliedRef: true,
|
|
skipCrossContextDecoration: true,
|
|
},
|
|
undefined,
|
|
{ channel: "whatsapp" },
|
|
);
|
|
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledWith(
|
|
expect.objectContaining({ inboundEventKind: "room_event" }),
|
|
);
|
|
});
|
|
|
|
it("mirrors successful source-conversation message.action sends into the assistant transcript", async () => {
|
|
const telegramPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram source send transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-1" }),
|
|
},
|
|
threading: {
|
|
resolveCurrentChannelId: ({ to, threadId }) =>
|
|
threadId == null ? to : `${to}:topic:${threadId}`,
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
|
"send-test-source-message-action-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "visible source reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-source-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
text: "visible source reply",
|
|
mediaUrls: undefined,
|
|
idempotencyKey: "idem-source-message-action",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("mirrors accepted source send text aliases", async () => {
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-content-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
content: "visible content alias reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-content-source-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
text: "visible content alias reply",
|
|
mediaUrls: undefined,
|
|
idempotencyKey: "idem-content-source-message-action",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("keeps delivered source sends successful when transcript mirroring fails", async () => {
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-mirror-failed" }),
|
|
);
|
|
mocks.appendAssistantMessageToSessionTranscript.mockRejectedValueOnce(
|
|
new Error("transcript unavailable"),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "visible source reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-source-message-action-mirror-failed",
|
|
});
|
|
|
|
const call = firstRespondCall(respond);
|
|
expect(call[0]).toBe(true);
|
|
expect(call[1]).toEqual({ ok: true, messageId: "tg-mirror-failed" });
|
|
expect(call[2]).toBeUndefined();
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("mirrors caption-only source sends with media", async () => {
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-caption-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
mediaUrl: "https://example.com/image.png",
|
|
caption: "visible media caption",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-caption-source-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
text: "visible media caption",
|
|
mediaUrls: ["https://example.com/image.png"],
|
|
idempotencyKey: "idem-caption-source-message-action",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("waits for source transcript mirroring before responding to message.action", async () => {
|
|
const telegramPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram async source send transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-async-1" }),
|
|
},
|
|
};
|
|
const mirrorDeferred = createDeferred<{ ok: boolean; sessionFile: string }>();
|
|
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
|
"send-test-source-message-action-async-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-async-1" }),
|
|
);
|
|
mocks.appendAssistantMessageToSessionTranscript.mockReturnValueOnce(mirrorDeferred.promise);
|
|
|
|
const respond = vi.fn();
|
|
const request = sendHandlers["message.action"]({
|
|
params: {
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "visible media caption",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-async-source-message-action",
|
|
} as never,
|
|
respond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "message.action" },
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(respond).not.toHaveBeenCalled();
|
|
|
|
mirrorDeferred.resolve({ ok: true, sessionFile: "x" });
|
|
await request;
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
});
|
|
|
|
it("preserves source transcript mirror order before message.action responses", async () => {
|
|
const telegramPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram ordered async source send transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-ordered" }),
|
|
},
|
|
};
|
|
const firstMirrorDeferred = createDeferred<{ ok: boolean; sessionFile: string }>();
|
|
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
|
"send-test-source-message-action-ordered-async-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValue(
|
|
jsonResult({ ok: true, messageId: "tg-ordered" }),
|
|
);
|
|
mocks.appendAssistantMessageToSessionTranscript
|
|
.mockReturnValueOnce(firstMirrorDeferred.promise)
|
|
.mockResolvedValueOnce({ ok: true, sessionFile: "x" });
|
|
|
|
const firstRespond = vi.fn();
|
|
const secondRespond = vi.fn();
|
|
const first = sendHandlers["message.action"]({
|
|
params: {
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "first visible reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-ordered-source-message-action-1",
|
|
} as never,
|
|
respond: firstRespond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "1", method: "message.action" },
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledTimes(1);
|
|
});
|
|
const second = sendHandlers["message.action"]({
|
|
params: {
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "second visible reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-ordered-source-message-action-2",
|
|
} as never,
|
|
respond: secondRespond,
|
|
context: makeContext(),
|
|
req: { type: "req", id: "2", method: "message.action" },
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledTimes(1);
|
|
expect(firstRespond).not.toHaveBeenCalled();
|
|
expect(secondRespond).not.toHaveBeenCalled();
|
|
expect(appendTranscriptCall(0)).toEqual(
|
|
expect.objectContaining({ text: "first visible reply" }),
|
|
);
|
|
|
|
firstMirrorDeferred.resolve({ ok: true, sessionFile: "x" });
|
|
await first;
|
|
await second;
|
|
|
|
expect(firstRespondCall(firstRespond)[0]).toBe(true);
|
|
expect(firstRespondCall(secondRespond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledTimes(2);
|
|
expect(appendTranscriptCall(1)).toEqual(
|
|
expect.objectContaining({ text: "second visible reply" }),
|
|
);
|
|
});
|
|
|
|
it("mirrors presentation-only source-conversation message.action sends", async () => {
|
|
const telegramPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram source send rich transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-rich-1" }),
|
|
},
|
|
threading: {
|
|
resolveCurrentChannelId: ({ to, threadId }) =>
|
|
threadId == null ? to : `${to}:topic:${threadId}`,
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
|
"send-test-rich-source-message-action-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-rich-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
presentation: {
|
|
title: "Approval needed",
|
|
blocks: [
|
|
{ type: "text", text: "Review the deployment request" },
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{ label: "Approve", value: "approve" },
|
|
{ label: "Reject", value: "reject" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-rich-source-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
text: "Approval needed\nReview the deployment request\nApprove\nReject",
|
|
mediaUrls: undefined,
|
|
idempotencyKey: "idem-rich-source-message-action",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("mirrors title-only source-conversation presentation sends", async () => {
|
|
const telegramPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram source send title-only transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-title-1" }),
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
|
"send-test-title-only-source-message-action-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-title-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
presentation: {
|
|
title: "Title-only approval",
|
|
},
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-title-only-source-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
text: "Title-only approval",
|
|
mediaUrls: undefined,
|
|
idempotencyKey: "idem-title-only-source-message-action",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("mirrors auto-threaded Telegram source sends into the topic transcript", async () => {
|
|
const telegramTopicPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram topic source send transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["group"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-topic-1" }),
|
|
},
|
|
threading: {
|
|
resolveCurrentChannelId: ({ to, threadId }) =>
|
|
threadId == null ? to : `${to}:topic:${threadId}`,
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(telegramTopicPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramTopicPlugin }]),
|
|
"send-test-topic-source-message-action-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-topic-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "visible topic source reply",
|
|
messageThreadId: "77",
|
|
},
|
|
sessionKey: "agent:main:telegram:group:chat-123:topic:77",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123:topic:77",
|
|
currentThreadTs: "77",
|
|
},
|
|
idempotencyKey: "idem-topic-source-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:group:chat-123:topic:77",
|
|
text: "visible topic source reply",
|
|
mediaUrls: undefined,
|
|
idempotencyKey: "idem-topic-source-message-action",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("does not mirror topic context when delivery params target the parent chat", async () => {
|
|
const telegramTopicPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram parent send transcript mirror test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["group"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
supportsAction: ({ action }) => action === "send",
|
|
handleAction: async () => jsonResult({ ok: true, messageId: "tg-parent-1" }),
|
|
},
|
|
threading: {
|
|
resolveCurrentChannelId: ({ to, threadId }) =>
|
|
threadId == null ? to : `${to}:topic:${threadId}`,
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(telegramTopicPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramTopicPlugin }]),
|
|
"send-test-topic-context-parent-message-action-mirror",
|
|
);
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-parent-1" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "visible parent source reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:group:chat-123:topic:77",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123:topic:77",
|
|
currentThreadTs: "77",
|
|
},
|
|
idempotencyKey: "idem-topic-context-parent-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not mirror message.action sends to a different target", async () => {
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: true, messageId: "tg-external" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "other-chat",
|
|
message: "external visible reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-external-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not mirror explicitly failed message.action sends", async () => {
|
|
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
|
jsonResult({ ok: false, error: "delivery failed" }),
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest({
|
|
channel: "telegram",
|
|
action: "send",
|
|
params: {
|
|
to: "chat-123",
|
|
message: "failed source reply",
|
|
},
|
|
sessionKey: "agent:main:telegram:direct:chat-123",
|
|
agentId: "main",
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "chat-123",
|
|
},
|
|
idempotencyKey: "idem-failed-message-action",
|
|
});
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes agent-scoped media roots to gateway message actions", async () => {
|
|
const mediaActionPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram media action dispatch test plugin.",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({ enabled: true }),
|
|
isConfigured: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["sendAttachment"] }),
|
|
supportsAction: ({ action }) => action === "sendAttachment",
|
|
handleAction: async () => jsonResult({ ok: true }),
|
|
},
|
|
};
|
|
mocks.getChannelPlugin.mockReturnValue(mediaActionPlugin);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: mediaActionPlugin }]),
|
|
"send-test-message-action-media-roots",
|
|
);
|
|
|
|
const { respond } = await runMessageActionRequest(
|
|
{
|
|
channel: "telegram",
|
|
action: "sendAttachment",
|
|
params: { chatId: "123", mediaUrl: `${TEST_AGENT_WORKSPACE}/render.png` },
|
|
agentId: "work",
|
|
idempotencyKey: "idem-message-action-media-roots",
|
|
},
|
|
{ connect: { scopes: ["operator.write"] } },
|
|
);
|
|
|
|
expect(firstRespondCall(respond)[0]).toBe(true);
|
|
const actionCall = lastDispatchChannelMessageActionCall();
|
|
expect(actionCall?.mediaLocalRoots).toContain(TEST_AGENT_WORKSPACE);
|
|
expect(actionCall?.gatewayClientScopes).toEqual(["operator.write"]);
|
|
});
|
|
});
|