fix: route direct outbound polls through channel adapters (#99950)

* fix: route direct outbound polls through channel adapters

* docs: note direct poll delivery

* test: isolate direct poll formatter label

---------

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
NianJiu
2026-07-07 18:46:26 +08:00
committed by GitHub
parent 5511b6fcaf
commit 040ac31546
6 changed files with 229 additions and 21 deletions
+1
View File
@@ -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.
+47 -1
View File
@@ -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",
]);
});
});
+16
View File
@@ -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({
+4 -2
View File
@@ -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})`;
+100 -16
View File
@@ -234,21 +234,27 @@ describe("sendMessage replyToId threading", () => {
});
});
function setDemoPollRegistry(
outboundOptions: Parameters<typeof createDemoAliasOutbound>[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["sendPoll"]>;
}): 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" })),
}
: {}),
});
+61 -2
View File
@@ -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<ReturnType<typeof resolveRequiredPlugin>["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<MessagePollRe
if (!outbound?.sendPoll) {
throw new Error(`Unsupported poll channel: ${channel}`);
}
const deliveryMode = outbound.deliveryMode ?? "direct";
const normalized = outbound.pollMaxOptions
? normalizePollInput(pollInput, { maxOptions: outbound.pollMaxOptions })
: normalizePollInput(pollInput);
@@ -493,10 +512,49 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
channel,
to: params.to,
normalized,
via: deliveryMode === "gateway" ? "gateway" : "direct",
dryRun: true,
});
}
assertPollOptionSupport({
channel,
outbound,
durationSeconds: params.durationSeconds,
isAnonymous: params.isAnonymous,
});
if (deliveryMode !== "gateway") {
const resolvedTarget = resolveOutboundTarget({
channel,
to: params.to,
cfg,
accountId: params.accountId,
mode: "explicit",
});
if (!resolvedTarget.ok) {
throw resolvedTarget.error;
}
const result = await outbound.sendPoll({
cfg,
to: resolvedTarget.to,
poll: normalized,
accountId: params.accountId,
threadId: params.threadId,
silent: params.silent,
isAnonymous: params.isAnonymous,
});
return buildMessagePollResult({
channel,
to: params.to,
normalized,
via: "direct",
result,
});
}
const result = await callMessageGateway<{
messageId: string;
toJid?: string;
@@ -526,6 +584,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
channel,
to: params.to,
normalized,
via: "gateway",
result,
});
}