Polls: defer shared parsing until plugin fallback

This commit is contained in:
Gustavo Madeira Santana
2026-03-18 02:34:25 +00:00
parent 9e8b9aba1f
commit fa73f5aeb5
8 changed files with 236 additions and 76 deletions

View File

@@ -235,6 +235,17 @@ We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
plugins should import their own local runtime code directly from their
extension-owned modules.
For polls specifically, there are two execution paths:
- `outbound.sendPoll` is the shared baseline for channels that fit the common
poll model
- `actions.handleAction("poll")` is the preferred path for channel-specific
poll semantics or extra poll parameters
Core now defers shared poll parsing until after plugin poll dispatch declines
the action, so plugin-owned poll handlers can accept channel-specific poll
fields without being blocked by the generic poll parser first.
## Capability ownership model
OpenClaw treats a native plugin as the ownership boundary for a **company** or a

View File

@@ -170,6 +170,10 @@ export type ChannelOutboundAdapter = {
) => Promise<OutboundDeliveryResult>;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
/**
* Shared outbound poll adapter for channels that fit the common poll model.
* Channels with extra poll semantics should prefer `actions.handleAction("poll")`.
*/
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
};

View File

@@ -521,6 +521,10 @@ export type ChannelMessageActionAdapter = {
toolContext?: ChannelThreadingToolContext;
}) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
/**
* Prefer this for channel-specific poll semantics or extra poll parameters.
* Core only parses the shared poll model when falling back to `outbound.sendPoll`.
*/
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
};

View File

@@ -408,6 +408,99 @@ describe("runMessageAction plugin dispatch", () => {
});
});
describe("plugin-owned poll semantics", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
forwarded: {
to: params.to ?? null,
pollQuestion: params.pollQuestion ?? null,
pollOption: params.pollOption ?? null,
pollDurationSeconds: params.pollDurationSeconds ?? null,
pollPublic: params.pollPublic ?? null,
},
}),
);
const discordPollPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord plugin-owned poll test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
supportsAction: ({ action }) => action === "poll",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: discordPollPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("lets non-telegram plugins own extra poll fields", async () => {
const result = await runMessageAction({
cfg: {
channels: {
discord: {
token: "tok",
},
},
} as OpenClawConfig,
action: "poll",
params: {
channel: "discord",
target: "channel:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
},
dryRun: false,
});
expect(result.kind).toBe("poll");
expect(result.handledBy).toBe("plugin");
expect(handleAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
channel: "discord",
params: expect.objectContaining({
to: "channel:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
}),
}),
);
});
});
describe("components parsing", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({

View File

@@ -34,15 +34,24 @@ async function runPollAction(params: {
params: params.actionParams as never,
toolContext: params.toolContext as never,
});
return mocks.executePollAction.mock.calls[0]?.[0] as
const call = mocks.executePollAction.mock.calls[0]?.[0] as
| {
durationSeconds?: number;
maxSelections?: number;
threadId?: string;
isAnonymous?: boolean;
resolveCorePoll?: () => {
durationSeconds?: number;
maxSelections?: number;
threadId?: string;
isAnonymous?: boolean;
};
ctx?: { params?: Record<string, unknown> };
}
| undefined;
if (!call) {
return undefined;
}
return {
...call.resolveCorePoll?.(),
ctx: call.ctx,
};
}
describe("runMessageAction poll handling", () => {
beforeEach(async () => {
@@ -55,11 +64,11 @@ describe("runMessageAction poll handling", () => {
telegramConfig,
} = await import("./message-action-runner.test-helpers.js"));
installMessageActionRunnerTestRegistry();
mocks.executePollAction.mockResolvedValue({
mocks.executePollAction.mockImplementation(async (input) => ({
handledBy: "core",
payload: { ok: true },
payload: { ok: true, corePoll: input.resolveCorePoll() },
pollResult: { ok: true },
});
}));
});
afterEach(() => {
@@ -105,7 +114,7 @@ describe("runMessageAction poll handling", () => {
},
])("$name", async ({ getCfg, actionParams, message }) => {
await expect(runPollAction({ cfg: getCfg(), actionParams })).rejects.toThrow(message);
expect(mocks.executePollAction).not.toHaveBeenCalled();
expect(mocks.executePollAction).toHaveBeenCalledTimes(1);
});
it("passes Telegram durationSeconds, visibility, and auto threadId to executePollAction", async () => {

View File

@@ -591,34 +591,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
throwIfAborted(abortSignal);
const action: ChannelMessageActionName = "poll";
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options = readStringArrayParam(params, "pollOption", { required: true });
if (options.length < 2) {
throw new Error("pollOption requires at least two values");
}
const silent = readBooleanParam(params, "silent");
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
const maxSelections = resolvePollMaxSelections(options.length, allowMultiselect);
if (durationSeconds !== undefined && channel !== "telegram") {
throw new Error("pollDurationSeconds is only supported for Telegram polls");
}
if (isAnonymous !== undefined && channel !== "telegram") {
throw new Error("pollAnonymous/pollPublic are only supported for Telegram polls");
}
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
cfg,
@@ -652,14 +625,45 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
dryRun,
silent: silent ?? undefined,
},
to,
question,
options,
maxSelections,
durationSeconds: durationSeconds ?? undefined,
durationHours: durationHours ?? undefined,
threadId: resolvedThreadId ?? undefined,
isAnonymous,
resolveCorePoll: () => {
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options = readStringArrayParam(params, "pollOption", { required: true });
if (options.length < 2) {
throw new Error("pollOption requires at least two values");
}
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
if (durationSeconds !== undefined && channel !== "telegram") {
throw new Error("pollDurationSeconds is only supported for Telegram polls");
}
if (isAnonymous !== undefined && channel !== "telegram") {
throw new Error("pollAnonymous/pollPublic are only supported for Telegram polls");
}
return {
to,
question,
options,
maxSelections: resolvePollMaxSelections(options.length, allowMultiselect),
durationSeconds: durationSeconds ?? undefined,
durationHours: durationHours ?? undefined,
threadId: resolvedThreadId ?? undefined,
isAnonymous,
};
},
});
return {

View File

@@ -143,16 +143,44 @@ describe("executeSendAction", () => {
params: {},
dryRun: false,
},
to: "channel:123",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
resolveCorePoll: () => ({
to: "channel:123",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
}),
});
expect(result.handledBy).toBe("plugin");
expect(mocks.sendPoll).not.toHaveBeenCalled();
});
it("does not invoke shared poll parsing before plugin poll dispatch", async () => {
mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin"));
const resolveCorePoll = vi.fn(() => {
throw new Error("shared poll fallback should not run");
});
const result = await executePollAction({
ctx: {
cfg: {},
channel: "discord",
params: {
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 90,
pollPublic: true,
},
dryRun: false,
},
resolveCorePoll,
});
expect(result.handledBy).toBe("plugin");
expect(resolveCorePoll).not.toHaveBeenCalled();
expect(mocks.sendPoll).not.toHaveBeenCalled();
});
it("passes agent-scoped media local roots to plugin dispatch", async () => {
mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin"));
@@ -270,13 +298,15 @@ describe("executeSendAction", () => {
accountId: "acc-1",
dryRun: false,
},
to: "channel:123",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
durationSeconds: 300,
threadId: "thread-1",
isAnonymous: true,
resolveCorePoll: () => ({
to: "channel:123",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
durationSeconds: 300,
threadId: "thread-1",
isAnonymous: true,
}),
});
expect(mocks.sendPoll).toHaveBeenCalledWith(
@@ -321,11 +351,13 @@ describe("executeSendAction", () => {
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
},
to: "channel:123",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
durationHours: 6,
resolveCorePoll: () => ({
to: "channel:123",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
durationHours: 6,
}),
});
expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled();

View File

@@ -152,14 +152,16 @@ export async function executeSendAction(params: {
export async function executePollAction(params: {
ctx: OutboundSendContext;
to: string;
question: string;
options: string[];
maxSelections: number;
durationSeconds?: number;
durationHours?: number;
threadId?: string;
isAnonymous?: boolean;
resolveCorePoll: () => {
to: string;
question: string;
options: string[];
maxSelections: number;
durationSeconds?: number;
durationHours?: number;
threadId?: string;
isAnonymous?: boolean;
};
}): Promise<{
handledBy: "plugin" | "core";
payload: unknown;
@@ -174,19 +176,20 @@ export async function executePollAction(params: {
return pluginHandled;
}
const corePoll = params.resolveCorePoll();
const result: MessagePollResult = await sendPoll({
cfg: params.ctx.cfg,
to: params.to,
question: params.question,
options: params.options,
maxSelections: params.maxSelections,
durationSeconds: params.durationSeconds ?? undefined,
durationHours: params.durationHours ?? undefined,
to: corePoll.to,
question: corePoll.question,
options: corePoll.options,
maxSelections: corePoll.maxSelections,
durationSeconds: corePoll.durationSeconds ?? undefined,
durationHours: corePoll.durationHours ?? undefined,
channel: params.ctx.channel,
accountId: params.ctx.accountId ?? undefined,
threadId: params.threadId ?? undefined,
threadId: corePoll.threadId ?? undefined,
silent: params.ctx.silent ?? undefined,
isAnonymous: params.isAnonymous ?? undefined,
isAnonymous: corePoll.isAnonymous ?? undefined,
dryRun: params.ctx.dryRun,
gateway: params.ctx.gateway,
});