diff --git a/CHANGELOG.md b/CHANGELOG.md index cba86704d796..a465df4e0c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes - **Exec approval prompts:** keep background-disabled fallback warnings out of pending gateway/node approvals and show them only after a command actually runs in the foreground. (#78184) Thanks @vincentkoc. +- **Direct poll delivery:** route direct and hybrid channel polls through the owning outbound adapter while preserving gateway-mode routing and channel option checks. (#99950) Thanks @NianJiuZst. - **Agent wait hard-timeout snapshots:** preserve canonical hard-timeout phase and timestamps when the outer `agent.wait` timer wins the retry-grace race, while leaving queue, draining, and restart-cancelled waits correctable. (#89367) Thanks @Pick-cat. - **Control UI typed approvals:** send `/approve` commands immediately through the authorized Gateway command path while an agent run is blocked instead of queueing the command behind that run. (#77672) Thanks @vincentkoc. - **Microsoft Teams Graph response bounds:** cap successful file-upload and chat JSON reads so oversized Microsoft Graph responses cannot be buffered without limit. (#97784) Thanks @Alix-007. diff --git a/src/commands/message-format.test.ts b/src/commands/message-format.test.ts index 2711e7d1c63a..9d2cf18f4d92 100644 --- a/src/commands/message-format.test.ts +++ b/src/commands/message-format.test.ts @@ -1,7 +1,19 @@ // Tests for CLI message text formatting helpers (renderMessageList, formatMessageCliText). -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js"; import { formatMessageCliText } from "./message-format.js"; +const getChannelPluginMock = vi.hoisted(() => + vi.fn((channel: string) => + channel === "directchat" ? { meta: { label: "Direct Chat" } } : undefined, + ), +); + +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: getChannelPluginMock, + getLoadedChannelPlugin: getChannelPluginMock, +})); + function readResultPayload(payload: unknown) { return { kind: "action" as const, @@ -189,3 +201,37 @@ describe("renderPaginationHint", () => { expect(out).not.toContain("More results available"); }); }); + +describe("formatMessageCliText poll results", () => { + it("formats direct core poll results as direct deliveries", () => { + const result = { + kind: "poll", + action: "poll", + channel: "directchat", + to: "room-1", + handledBy: "core", + payload: {}, + dryRun: false, + pollResult: { + channel: "directchat", + to: "room-1", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationSeconds: null, + durationHours: null, + via: "direct", + result: { + messageId: "p1", + conversationId: "conv-1", + pollId: "poll-1", + }, + }, + } satisfies MessageActionRunResult; + + expect(formatMessageCliText(result)).toEqual([ + "✅ Poll sent via Direct Chat. Message ID: p1 (conversation conv-1)", + "Poll id: poll-1", + ]); + }); +}); diff --git a/src/commands/message-format.ts b/src/commands/message-format.ts index 32a177c0168b..cab061f615e4 100644 --- a/src/commands/message-format.ts +++ b/src/commands/message-format.ts @@ -341,6 +341,22 @@ export function formatMessageCliText( const poll = result.pollResult; const pollId = (poll.result as { pollId?: string } | undefined)?.pollId; const msgId = poll.result?.messageId ?? null; + if (poll.via === "direct") { + const directResult = poll.result + ? ({ ...poll.result, channel: poll.channel } satisfies OutboundDeliveryResult) + : undefined; + const lines = [ + ok( + formatOutboundDeliverySummary(poll.channel, directResult, { + action: "Poll sent", + }), + ), + ]; + if (pollId) { + lines.push(ok(`Poll id: ${pollId}`)); + } + return lines; + } const lines = [ ok( formatGatewaySummary({ diff --git a/src/infra/outbound/format.ts b/src/infra/outbound/format.ts index 4b711efb2af4..c97d04895b38 100644 --- a/src/infra/outbound/format.ts +++ b/src/infra/outbound/format.ts @@ -43,13 +43,15 @@ const resolveChannelLabel = (channel: string) => { export function formatOutboundDeliverySummary( channel: string, result?: OutboundDeliveryResult, + opts?: { action?: string }, ): string { + const action = opts?.action ?? "Sent"; if (!result) { - return `✅ Sent via ${resolveChannelLabel(channel)}. Message ID: unknown`; + return `✅ ${action} via ${resolveChannelLabel(channel)}. Message ID: unknown`; } const label = resolveChannelLabel(result.channel); - const base = `✅ Sent via ${label}. Message ID: ${result.messageId}`; + const base = `✅ ${action} via ${label}. Message ID: ${result.messageId}`; if ("chatId" in result) { return `${base} (chat ${result.chatId})`; diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 665b6cb9d1bd..70760140063a 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -234,21 +234,27 @@ describe("sendMessage replyToId threading", () => { }); }); +function setDemoPollRegistry( + outboundOptions: Parameters[0] = {}, +) { + setRegistry( + createTestRegistry([ + { + pluginId: "demo-alias-channel", + source: "test", + plugin: createDemoAliasPlugin({ + aliases: ["workspace-chat"], + outbound: createDemoAliasOutbound({ includePoll: true, ...outboundOptions }), + }), + }, + ]), + ); +} + describe("sendPoll channel normalization", () => { - it("normalizes plugin aliases for polls", async () => { + it("normalizes plugin aliases for gateway polls", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); - setRegistry( - createTestRegistry([ - { - pluginId: "demo-alias-channel", - source: "test", - plugin: createDemoAliasPlugin({ - aliases: ["workspace-chat"], - outbound: createDemoAliasOutbound({ includePoll: true }), - }), - }, - ]), - ); + setDemoPollRegistry({ deliveryMode: "gateway" }); const result = await sendPoll({ cfg: {}, @@ -260,6 +266,76 @@ describe("sendPoll channel normalization", () => { expect(gatewayCall()?.params?.channel).toBe("demo-alias-channel"); expect(result.channel).toBe("demo-alias-channel"); + expect(result.via).toBe("gateway"); + }); + + it("uses direct poll fallback for direct channel plugins", async () => { + const cfg = { channels: {} }; + const sendPollMock = vi.fn(async () => ({ messageId: "p1" })); + setDemoPollRegistry({ supportsAnonymousPolls: true, sendPoll: sendPollMock }); + + const result = await sendPoll({ + cfg, + to: "conversation:demo-target", + question: "Lunch?", + options: ["Pizza", "Sushi"], + channel: "Workspace-Chat", + accountId: "acct-1", + threadId: "thread-1", + silent: true, + isAnonymous: false, + }); + + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + channel: "demo-alias-channel", + to: "conversation:demo-target", + via: "direct", + result: { messageId: "p1" }, + }); + expect(sendPollMock).toHaveBeenCalledWith({ + cfg, + to: "conversation:demo-target", + poll: { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }, + accountId: "acct-1", + threadId: "thread-1", + silent: true, + isAnonymous: false, + }); + }); + + it.each([ + { + name: "durationSeconds", + params: { durationSeconds: 300 }, + message: "durationSeconds is not supported for demo-alias-channel polls", + }, + { + name: "isAnonymous", + params: { isAnonymous: false }, + message: "isAnonymous is not supported for demo-alias-channel polls", + }, + ])("rejects unsupported direct poll option $name", async ({ params, message }) => { + const sendPollMock = vi.fn(async () => ({ messageId: "p1" })); + setDemoPollRegistry({ sendPoll: sendPollMock }); + + await expect( + sendPoll({ + cfg: {}, + to: "conversation:demo-target", + question: "Lunch?", + options: ["Pizza", "Sushi"], + channel: "Workspace-Chat", + ...params, + }), + ).rejects.toThrow(message); + + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(sendPollMock).not.toHaveBeenCalled(); }); }); @@ -457,8 +533,14 @@ const createLocalChatAliasPlugin = (): ChannelPlugin => ({ }, }); -const createDemoAliasOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({ - deliveryMode: "direct", +const createDemoAliasOutbound = (opts?: { + deliveryMode?: ChannelOutboundAdapter["deliveryMode"]; + includePoll?: boolean; + supportsAnonymousPolls?: boolean; + supportsPollDurationSeconds?: boolean; + sendPoll?: NonNullable; +}): ChannelOutboundAdapter => ({ + deliveryMode: opts?.deliveryMode ?? "direct", sendText: async ({ deps, to, text }) => { const send = deps?.["demo-alias-channel"] as | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) @@ -482,7 +564,9 @@ const createDemoAliasOutbound = (opts?: { includePoll?: boolean }): ChannelOutbo ...(opts?.includePoll ? { pollMaxOptions: 12, - sendPoll: async () => ({ channel: "demo-alias-channel", messageId: "p1" }), + ...(opts.supportsAnonymousPolls ? { supportsAnonymousPolls: true } : {}), + ...(opts.supportsPollDurationSeconds ? { supportsPollDurationSeconds: true } : {}), + sendPoll: opts.sendPoll ?? (async () => ({ messageId: "p1" })), } : {}), }); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 9c6d0e2675fe..9b9461583c1a 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -134,7 +134,7 @@ export type MessagePollResult = { maxSelections: number; durationSeconds: number | null; durationHours: number | null; - via: "gateway"; + via: "direct" | "gateway"; result?: { messageId: string; toJid?: string; @@ -155,6 +155,7 @@ function buildMessagePollResult(params: { durationSeconds?: number | null; durationHours?: number | null; }; + via: MessagePollResult["via"]; result?: MessagePollResult["result"]; dryRun?: boolean; }): MessagePollResult { @@ -166,11 +167,28 @@ function buildMessagePollResult(params: { maxSelections: params.normalized.maxSelections, durationSeconds: params.normalized.durationSeconds ?? null, durationHours: params.normalized.durationHours ?? null, - via: "gateway", + via: params.via, ...(params.dryRun ? { dryRun: true } : { result: params.result }), }; } +function assertPollOptionSupport(params: { + channel: string; + outbound: NonNullable["outbound"]>; + durationSeconds?: number; + isAnonymous?: boolean; +}): void { + if ( + typeof params.durationSeconds === "number" && + params.outbound.supportsPollDurationSeconds !== true + ) { + throw new Error(`durationSeconds is not supported for ${params.channel} polls`); + } + if (typeof params.isAnonymous === "boolean" && params.outbound.supportsAnonymousPolls !== true) { + throw new Error(`isAnonymous is not supported for ${params.channel} polls`); + } +} + async function resolveRequiredChannel(params: { cfg: OpenClawConfig; channel?: string; @@ -484,6 +502,7 @@ export async function sendPoll(params: MessagePollParams): Promise