



















@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
1515resolveOutboundSessionRoute: vi.fn(),
1616ensureOutboundSessionEntry: vi.fn(async () => undefined),
1717resolveMessageChannelSelection: vi.fn(),
18+dispatchChannelMessageAction: vi.fn(),
1819sendPoll: vi.fn<
1920() => Promise<{
2021messageId: string;
@@ -44,6 +45,10 @@ vi.mock("../../channels/plugins/index.js", () => ({
4445normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
4546}));
464748+vi.mock("../../channels/plugins/message-action-dispatch.js", () => ({
49+dispatchChannelMessageAction: mocks.dispatchChannelMessageAction,
50+}));
51+4752const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace";
4853let sendHandlers: typeof import("./send.js").sendHandlers;
4954@@ -163,6 +168,16 @@ async function runPollWithClient(
163168return { respond };
164169}
165170171+function createDeferred<T>() {
172+let resolve!: (value: T | PromiseLike<T>) => void;
173+let reject!: (reason?: unknown) => void;
174+const promise = new Promise<T>((res, rej) => {
175+resolve = res;
176+reject = rej;
177+});
178+return { promise, resolve, reject };
179+}
180+166181async function runMessageActionRequest(
167182params: Record<string, unknown>,
168183client?: { connect?: { scopes?: string[] } } | null,
@@ -256,8 +271,193 @@ describe("gateway send mirroring", () => {
256271channel: "slack",
257272configured: ["slack"],
258273});
274+mocks.dispatchChannelMessageAction.mockResolvedValue({
275+details: { action: "handled" },
276+});
259277mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
260-mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } });
278+mocks.getChannelPlugin.mockReturnValue({
279+actions: { handleAction: true },
280+outbound: { sendPoll: mocks.sendPoll },
281+});
282+});
283+284+it("dedupes concurrent message.action requests while inflight", async () => {
285+const context = makeContext();
286+const firstRespond = vi.fn();
287+const secondRespond = vi.fn();
288+const actionDeferred = createDeferred<{ details: { action: string } }>();
289+mocks.dispatchChannelMessageAction.mockReturnValueOnce(actionDeferred.promise);
290+291+const firstRequest = sendHandlers["message.action"]({
292+params: {
293+channel: "slack",
294+action: "poll",
295+params: { question: "Q?" },
296+idempotencyKey: "idem-action-concurrent",
297+} as never,
298+respond: firstRespond,
299+ context,
300+req: { type: "req", id: "1", method: "message.action" },
301+client: null as never,
302+isWebchatConnect: () => false,
303+});
304+305+const secondRequest = sendHandlers["message.action"]({
306+params: {
307+channel: "slack",
308+action: "poll",
309+params: { question: "Q?" },
310+idempotencyKey: "idem-action-concurrent",
311+} as never,
312+respond: secondRespond,
313+ context,
314+req: { type: "req", id: "2", method: "message.action" },
315+client: null as never,
316+isWebchatConnect: () => false,
317+});
318+319+await Promise.resolve();
320+expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1);
321+322+actionDeferred.resolve({ details: { action: "handled" } });
323+await Promise.all([firstRequest, secondRequest]);
324+325+expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(1);
326+expect(firstRespond).toHaveBeenCalledWith(
327+true,
328+{ action: "handled" },
329+undefined,
330+expect.objectContaining({ channel: "slack" }),
331+);
332+expect(secondRespond).toHaveBeenCalledWith(
333+true,
334+{ action: "handled" },
335+undefined,
336+expect.objectContaining({ channel: "slack", cached: true }),
337+);
338+});
339+340+it("dedupes concurrent send requests while inflight", async () => {
341+const context = makeContext();
342+const firstRespond = vi.fn();
343+const secondRespond = vi.fn();
344+const deliveryDeferred = createDeferred<Array<{ messageId: string; channel: string }>>();
345+mocks.deliverOutboundPayloads.mockReturnValueOnce(deliveryDeferred.promise);
346+347+const firstRequest = sendHandlers.send({
348+params: {
349+to: "channel:C1",
350+message: "hi",
351+channel: "slack",
352+idempotencyKey: "idem-send-concurrent",
353+} as never,
354+respond: firstRespond,
355+ context,
356+req: { type: "req", id: "1", method: "send" },
357+client: null as never,
358+isWebchatConnect: () => false,
359+});
360+361+const secondRequest = sendHandlers.send({
362+params: {
363+to: "channel:C1",
364+message: "hi",
365+channel: "slack",
366+idempotencyKey: "idem-send-concurrent",
367+} as never,
368+respond: secondRespond,
369+ context,
370+req: { type: "req", id: "2", method: "send" },
371+client: null as never,
372+isWebchatConnect: () => false,
373+});
374+375+await vi.waitFor(() => {
376+expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
377+});
378+379+deliveryDeferred.resolve([{ messageId: "m-concurrent", channel: "slack" }]);
380+await Promise.all([firstRequest, secondRequest]);
381+382+expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
383+expect(firstRespond).toHaveBeenCalledWith(
384+true,
385+expect.objectContaining({ messageId: "m-concurrent", runId: "idem-send-concurrent" }),
386+undefined,
387+expect.objectContaining({ channel: "slack" }),
388+);
389+expect(secondRespond).toHaveBeenCalledWith(
390+true,
391+expect.objectContaining({ messageId: "m-concurrent", runId: "idem-send-concurrent" }),
392+undefined,
393+expect.objectContaining({ channel: "slack", cached: true }),
394+);
395+});
396+397+it("dedupes concurrent poll requests while inflight", async () => {
398+const context = makeContext();
399+const firstRespond = vi.fn();
400+const secondRespond = vi.fn();
401+const pollDeferred = createDeferred<{ messageId: string; pollId: string }>();
402+mocks.sendPoll.mockReturnValueOnce(pollDeferred.promise);
403+404+const firstRequest = sendHandlers.poll({
405+params: {
406+to: "channel:C1",
407+question: "Q?",
408+options: ["A", "B"],
409+channel: "slack",
410+idempotencyKey: "idem-poll-concurrent",
411+} as never,
412+respond: firstRespond,
413+ context,
414+req: { type: "req", id: "1", method: "poll" },
415+client: null as never,
416+isWebchatConnect: () => false,
417+});
418+419+const secondRequest = sendHandlers.poll({
420+params: {
421+to: "channel:C1",
422+question: "Q?",
423+options: ["A", "B"],
424+channel: "slack",
425+idempotencyKey: "idem-poll-concurrent",
426+} as never,
427+respond: secondRespond,
428+ context,
429+req: { type: "req", id: "2", method: "poll" },
430+client: null as never,
431+isWebchatConnect: () => false,
432+});
433+434+await Promise.resolve();
435+expect(mocks.sendPoll).toHaveBeenCalledTimes(1);
436+437+pollDeferred.resolve({ messageId: "poll-concurrent", pollId: "poll-1" });
438+await Promise.all([firstRequest, secondRequest]);
439+440+expect(mocks.sendPoll).toHaveBeenCalledTimes(1);
441+expect(firstRespond).toHaveBeenCalledWith(
442+true,
443+expect.objectContaining({
444+messageId: "poll-concurrent",
445+pollId: "poll-1",
446+runId: "idem-poll-concurrent",
447+}),
448+undefined,
449+expect.objectContaining({ channel: "slack" }),
450+);
451+expect(secondRespond).toHaveBeenCalledWith(
452+true,
453+expect.objectContaining({
454+messageId: "poll-concurrent",
455+pollId: "poll-1",
456+runId: "idem-poll-concurrent",
457+}),
458+undefined,
459+expect.objectContaining({ channel: "slack", cached: true }),
460+);
261461});
262462263463it("accepts media-only sends without message", async () => {
@@ -1016,6 +1216,18 @@ describe("gateway send mirroring", () => {
10161216]),
10171217"send-test-message-action",
10181218);
1219+mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
1220+jsonResult({
1221+ok: true,
1222+messageId: "wamid.1",
1223+requesterSenderId: "trusted-user",
1224+currentMessageId: "wamid.1",
1225+currentGraphChannelId: "graph:team/chan",
1226+replyToMode: "first",
1227+hasRepliedRef: true,
1228+skipCrossContextDecoration: true,
1229+}),
1230+);
1019123110201232const { respond } = await runMessageActionRequest({
10211233channel: "whatsapp",
@@ -1055,7 +1267,6 @@ describe("gateway send mirroring", () => {
10551267});
1056126810571269it("passes agent-scoped media roots to gateway message actions", async () => {
1058-let capturedMediaLocalRoots: readonly string[] | undefined;
10591270const mediaActionPlugin: ChannelPlugin = {
10601271id: "telegram",
10611272meta: {
@@ -1074,10 +1285,7 @@ describe("gateway send mirroring", () => {
10741285actions: {
10751286describeMessageTool: () => ({ actions: ["sendAttachment"] }),
10761287supportsAction: ({ action }) => action === "sendAttachment",
1077-handleAction: async ({ mediaLocalRoots }) => {
1078-capturedMediaLocalRoots = mediaLocalRoots;
1079-return jsonResult({ ok: true });
1080-},
1288+handleAction: async () => jsonResult({ ok: true }),
10811289},
10821290};
10831291mocks.getChannelPlugin.mockReturnValue(mediaActionPlugin);
@@ -1095,7 +1303,11 @@ describe("gateway send mirroring", () => {
10951303});
1096130410971305expect(firstRespondCall(respond)?.[0]).toBe(true);
1098-expect(capturedMediaLocalRoots).toContain(TEST_AGENT_WORKSPACE);
1306+expect(mocks.dispatchChannelMessageAction).toHaveBeenLastCalledWith(
1307+expect.objectContaining({
1308+mediaLocalRoots: expect.arrayContaining([TEST_AGENT_WORKSPACE]),
1309+}),
1310+);
10991311});
1100131211011313it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => {
@@ -1144,7 +1356,9 @@ describe("gateway send mirroring", () => {
11441356},
11451357{ connect: { scopes: ["operator.write"] } },
11461358);
1147-expect(capture.senderIsOwner).toBe(false);
1359+expect(mocks.dispatchChannelMessageAction).toHaveBeenLastCalledWith(
1360+expect.objectContaining({ senderIsOwner: false }),
1361+);
1148136211491363// Full operator (admin-scoped): the trusted runtime is allowed to
11501364// forward the real channel-sender ownership bit. Wire true → true.
@@ -1162,7 +1376,9 @@ describe("gateway send mirroring", () => {
11621376},
11631377{ connect: { scopes: ["operator.admin"] } },
11641378);
1165-expect(capture.senderIsOwner).toBe(true);
1379+expect(mocks.dispatchChannelMessageAction).toHaveBeenLastCalledWith(
1380+expect.objectContaining({ senderIsOwner: true }),
1381+);
1166138211671383// Full operator forwarding a non-owner sender: wire false → false
11681384// (admin scope does not inflate ownership on its own).
@@ -1180,6 +1396,8 @@ describe("gateway send mirroring", () => {
11801396},
11811397{ connect: { scopes: ["operator.admin"] } },
11821398);
1183-expect(capture.senderIsOwner).toBe(false);
1399+expect(mocks.dispatchChannelMessageAction).toHaveBeenLastCalledWith(
1400+expect.objectContaining({ senderIsOwner: false }),
1401+);
11841402});
11851403});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。