Skip to content

Commit 073343e

Browse files
fix(outbound): ignore schema-padded poll metadata on send
Ignore schema-padded poll metadata on plain send actions unless content-bearing poll fields are present. Co-authored-by: 鄧 偉程 <[email protected]>
1 parent aa0d710 commit 073343e

4 files changed

Lines changed: 44 additions & 92 deletions

File tree

src/infra/outbound/message-action-runner.core-send.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,11 @@ describe("runMessageAction core send routing", () => {
172172
media: "https://example.com/file.txt",
173173
message: "hello",
174174
pollDurationHours: 0,
175-
pollDurationSeconds: 0,
175+
pollDurationSeconds: 60,
176176
pollMulti: false,
177+
pollPublic: true,
178+
pollAnonymous: false,
179+
pollOptionIndex: 0,
177180
pollQuestion: "",
178181
pollOption: [],
179182
},

src/infra/outbound/message-action-runner.send-validation.test.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -326,17 +326,7 @@ describe("runMessageAction send validation", () => {
326326
},
327327
},
328328
{
329-
name: "string-encoded poll params",
330-
actionParams: {
331-
channel: "workspace",
332-
target: "#C12345678",
333-
message: "hi",
334-
pollDurationSeconds: "60",
335-
pollPublic: "true",
336-
},
337-
},
338-
{
339-
name: "snake_case poll params",
329+
name: "snake_case content poll params",
340330
actionParams: {
341331
channel: "workspace",
342332
target: "#C12345678",
@@ -347,12 +337,15 @@ describe("runMessageAction send validation", () => {
347337
},
348338
},
349339
{
350-
name: "negative poll duration params",
340+
name: "channel-extra poll params with content",
351341
actionParams: {
352342
channel: "workspace",
353343
target: "#C12345678",
354344
message: "hi",
345+
pollQuestion: "Ready?",
346+
pollOption: ["Yes", "No"],
355347
pollDurationSeconds: -5,
348+
pollPublic: "true",
356349
},
357350
},
358351
])("rejects send actions that include $name", async ({ actionParams }) => {
@@ -387,6 +380,24 @@ describe("runMessageAction send validation", () => {
387380

388381
expect(result.kind).toBe("send");
389382
});
383+
384+
it("allows send when only schema-padded channel-extra poll metadata is present", async () => {
385+
const result = await runDrySend({
386+
cfg: workspaceConfig,
387+
actionParams: {
388+
channel: "workspace",
389+
target: "#C12345678",
390+
message: "hello",
391+
pollDurationSeconds: 60,
392+
pollPublic: true,
393+
pollAnonymous: false,
394+
pollOptionIndex: 0,
395+
},
396+
toolContext: { currentChannelId: "C12345678" },
397+
});
398+
399+
expect(result.kind).toBe("send");
400+
});
390401
});
391402

392403
describe("message body alias normalization", () => {

src/poll-params.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,22 @@ describe("poll params", () => {
1414
});
1515

1616
it.each([{ key: "pollAnonymous" }, { key: "pollPublic" }])(
17-
"treats channel-extra $key=true as poll creation intent",
17+
"does not treat channel-extra $key=true as poll creation intent",
1818
({ key }) => {
1919
expect(
2020
hasPollCreationParams({
2121
[key]: true,
2222
}),
23-
).toBe(true);
23+
).toBe(false);
2424
},
2525
);
2626

27-
it("treats non-zero finite numeric channel-extra poll params as poll creation intent", () => {
28-
expect(hasPollCreationParams({ pollDurationSeconds: 60 })).toBe(true);
29-
expect(hasPollCreationParams({ pollDurationSeconds: "60" })).toBe(true);
30-
expect(hasPollCreationParams({ pollDurationSeconds: "+60" })).toBe(true);
31-
expect(hasPollCreationParams({ pollDurationSeconds: "1e3" })).toBe(true);
32-
expect(hasPollCreationParams({ pollDurationSeconds: "-5" })).toBe(true);
27+
it("does not treat channel-extra poll metadata as poll creation intent", () => {
28+
expect(hasPollCreationParams({ pollDurationSeconds: 60 })).toBe(false);
29+
expect(hasPollCreationParams({ pollDurationSeconds: "60" })).toBe(false);
30+
expect(hasPollCreationParams({ pollDurationSeconds: "+60" })).toBe(false);
31+
expect(hasPollCreationParams({ pollDurationSeconds: "1e3" })).toBe(false);
32+
expect(hasPollCreationParams({ pollDurationSeconds: "-5" })).toBe(false);
3333
expect(hasPollCreationParams({ pollDurationSeconds: Infinity })).toBe(false);
3434
expect(hasPollCreationParams({ pollDurationSeconds: "60abc" })).toBe(false);
3535
expect(hasPollCreationParams({ pollDurationSeconds: "0x10" })).toBe(false);
@@ -64,8 +64,8 @@ describe("poll params", () => {
6464
expect(hasPollCreationParams({ pollOption: ["Yes", "No"], pollMulti: true })).toBe(true);
6565
});
6666

67-
it("treats string-encoded boolean poll params as poll creation intent when true", () => {
68-
expect(hasPollCreationParams({ pollPublic: "true" })).toBe(true);
67+
it("does not treat string-encoded channel-extra booleans as poll creation intent", () => {
68+
expect(hasPollCreationParams({ pollPublic: "true" })).toBe(false);
6969
expect(hasPollCreationParams({ pollAnonymous: "false" })).toBe(false);
7070
});
7171

@@ -76,8 +76,8 @@ describe("poll params", () => {
7676
it("detects snake_case poll fields as poll creation intent", () => {
7777
expect(hasPollCreationParams({ poll_question: "Lunch?" })).toBe(true);
7878
expect(hasPollCreationParams({ poll_option: ["Pizza", "Sushi"] })).toBe(true);
79-
expect(hasPollCreationParams({ poll_duration_seconds: "60" })).toBe(true);
80-
expect(hasPollCreationParams({ poll_public: "true" })).toBe(true);
79+
expect(hasPollCreationParams({ poll_duration_seconds: "60" })).toBe(false);
80+
expect(hasPollCreationParams({ poll_public: "true" })).toBe(false);
8181
});
8282

8383
it("ignores poll vote params when deciding whether send should become poll", () => {

src/poll-params.ts

Lines changed: 5 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
// Parses poll command parameters into validated polling options.
2-
import { parseStrictFiniteNumber } from "@openclaw/normalization-core/number-coercion";
3-
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
42
import { readSnakeCaseParamRaw } from "./param-key.js";
53

64
type PollCreationParamKind = "string" | "stringArray" | "positiveInteger" | "boolean";
@@ -24,65 +22,17 @@ type SharedPollCreationParamName = keyof typeof SHARED_POLL_CREATION_PARAM_DEFS;
2422
export const SHARED_POLL_CREATION_PARAM_NAMES = Object.keys(
2523
SHARED_POLL_CREATION_PARAM_DEFS,
2624
) as SharedPollCreationParamName[];
27-
const SHARED_POLL_CREATION_PARAM_KEY_SET = new Set(
28-
SHARED_POLL_CREATION_PARAM_NAMES.map(normalizePollParamKey),
29-
);
30-
const POLL_VOTE_PARAM_KEY_SET = new Set(
31-
["pollId", "pollOptionId", "pollOptionIds", "pollOptionIndex", "pollOptionIndexes"].map(
32-
normalizePollParamKey,
33-
),
34-
);
3525

3626
function readPollParamRaw(params: Record<string, unknown>, key: string): unknown {
3727
return readSnakeCaseParamRaw(params, key);
3828
}
3929

40-
function normalizePollParamKey(key: string): string {
41-
return normalizeLowercaseStringOrEmpty(key.replaceAll("_", ""));
42-
}
43-
44-
function isChannelPollCreationParamName(key: string): boolean {
45-
const normalized = normalizePollParamKey(key);
46-
return (
47-
normalized.startsWith("poll") &&
48-
!SHARED_POLL_CREATION_PARAM_KEY_SET.has(normalized) &&
49-
!POLL_VOTE_PARAM_KEY_SET.has(normalized)
50-
);
51-
}
52-
53-
function hasExplicitUnknownPollValue(key: string, value: unknown): boolean {
54-
if (value === true) {
55-
return true;
56-
}
57-
if (typeof value === "number") {
58-
return Number.isFinite(value) && value !== 0;
59-
}
60-
if (typeof value === "string") {
61-
const trimmed = value.trim();
62-
if (trimmed.length === 0) {
63-
return false;
64-
}
65-
if (normalizePollParamKey(key).includes("duration")) {
66-
const parsed = parseStrictFiniteNumber(trimmed);
67-
return Number.isFinite(parsed) && parsed !== 0;
68-
}
69-
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
70-
return normalized !== "false" && normalized !== "0";
71-
}
72-
if (Array.isArray(value)) {
73-
return value.some((entry) => hasExplicitUnknownPollValue(key, entry));
74-
}
75-
return false;
76-
}
77-
7830
// Among the shared poll params, only the content-bearing fields (pollQuestion,
7931
// pollOption) signal poll intent on their own. The modifier fields
80-
// (pollDurationHours, pollMulti) are exposed by the shared `message` tool
81-
// schema for both `send` and `poll` actions, so LLMs routinely echo their
82-
// schema-implied defaults (`1`, `false`) on plain `send` calls — see issue
83-
// for context. Treating those modifier defaults as "the agent meant to create
84-
// a poll" produces false positives and blocks routine sends. The modifiers
85-
// only count when accompanied by a content-bearing field.
32+
// (pollDurationHours, pollMulti) and channel-specific metadata
33+
// (pollDurationSeconds, pollPublic, pollAnonymous) are also exposed on the
34+
// shared `message` tool schema, so schema-padded plain sends may echo them.
35+
// Only content fields count here; action="poll" validates modifiers later.
8636
const CONTENT_BEARING_SHARED_POLL_PARAM_NAMES = ["pollQuestion", "pollOption"] as const;
8737

8838
function hasContentBearingPollCreationParam(params: Record<string, unknown>): boolean {
@@ -108,17 +58,5 @@ function hasContentBearingPollCreationParam(params: Record<string, unknown>): bo
10858
}
10959

11060
export function hasPollCreationParams(params: Record<string, unknown>): boolean {
111-
if (hasContentBearingPollCreationParam(params)) {
112-
return true;
113-
}
114-
// Channel-specific poll-prefixed params (e.g. pollDurationSeconds,
115-
// pollPublic) are not part of the shared schema, so an explicit value still
116-
// indicates deliberate poll intent and continues to trigger the validator
117-
// even without a pollQuestion/pollOption.
118-
for (const [key, value] of Object.entries(params)) {
119-
if (isChannelPollCreationParamName(key) && hasExplicitUnknownPollValue(key, value)) {
120-
return true;
121-
}
122-
}
123-
return false;
61+
return hasContentBearingPollCreationParam(params);
12462
}

0 commit comments

Comments
 (0)