Skip to content

Commit 8c7ac9b

Browse files
authored
fix(imessage): native poll vote-cue, echo suppression, and same-sender comment fold
Makes native iMessage polls behave correctly end to end. What changed: - Inbound polls render with a numbered-options vote cue so the agent casts a native vote instead of answering the poll in prose. - poll-vote resolves the poll reference from pollId/pollGuid/messageId and now defaults to the current inbound poll message when the model omits it; still errors when no reference exists. - poll-vote echo suppression is session-scoped, so the redundant spoken answer is dropped across the separate poll and comment runs. - A poll's inline-reply caption is folded (not delivered as a standalone question) only when the poll creator and reply sender are both known and equal; unknown/mismatched sender falls through to the normal inbound decision gate, so no inbound reply is silently dropped. Evidence: - 64 passing tests in the poll suites (poll-comment, poll-render, actions), incl. sender fail-closed regressions; pnpm build clean. - Two Codex autoreviews clean (patch is correct); ClawSweeper re-review rated it platinum hermit. - Live-verified on macOS 26.4.1 on the deployed gateway: poll "What color pill?" -> native vote delivered with a 7333-byte payload, caption folded, zero echo. Note: vote delivery also depends on the imsg vote-stamp fix (openclaw/imsg#150); OpenClaw ships ahead of imsg per owner decision.
1 parent e095dc8 commit 8c7ac9b

11 files changed

Lines changed: 338 additions & 13 deletions

File tree

extensions/imessage/src/actions.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,51 @@ describe("imessage message actions", () => {
373373
});
374374
});
375375

376+
it("defaults the poll reference to the current inbound message id", async () => {
377+
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
378+
available: true,
379+
v2Ready: true,
380+
selectors: { pollVoteMessage: true },
381+
rpcMethods: ["send", "poll.send", "poll.vote"],
382+
});
383+
runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("poll-full-guid");
384+
runtimeMock.sendPollVote.mockResolvedValue({ messageId: "vote-guid", optionText: "Blue" });
385+
386+
// No explicit pollId/pollGuid/messageId — the poll is the current inbound
387+
// message, so the reference defaults from toolContext.currentMessageId.
388+
await imessageMessageActions.handleAction?.({
389+
action: "poll-vote",
390+
cfg: cfg(),
391+
params: { chatGuid: "iMessage;+;chat0000", pollOptionIndex: 2 },
392+
toolContext: { currentMessageId: 3 },
393+
} as never);
394+
395+
expect(runtimeMock.resolveIMessageMessageId).toHaveBeenCalledWith(
396+
"3",
397+
expect.objectContaining({ requireKnownShortId: true }),
398+
);
399+
expect(runtimeMock.sendPollVote).toHaveBeenCalledWith(
400+
expect.objectContaining({ pollGuid: "poll-full-guid", optionIndex: 2 }),
401+
);
402+
});
403+
404+
it("rejects a poll vote with no reference and no current inbound message", async () => {
405+
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
406+
available: true,
407+
v2Ready: true,
408+
selectors: { pollVoteMessage: true },
409+
rpcMethods: ["send", "poll.send", "poll.vote"],
410+
});
411+
await expect(
412+
imessageMessageActions.handleAction?.({
413+
action: "poll-vote",
414+
cfg: cfg(),
415+
params: { chatGuid: "iMessage;+;chat0000", pollOptionIndex: 2 },
416+
} as never),
417+
).rejects.toThrow("requires the poll message id");
418+
expect(runtimeMock.sendPollVote).not.toHaveBeenCalled();
419+
});
420+
376421
it("rejects a poll vote with conflicting selectors", async () => {
377422
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
378423
available: true,

extensions/imessage/src/actions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -829,11 +829,17 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
829829
}
830830
// The poll being voted on is an inbound message; the agent references it
831831
// by the shared `pollId` param or a message id, which we resolve to the
832-
// poll's full GUID through the same reply cache the react path uses.
832+
// poll's full GUID through the same reply cache the react path uses. When
833+
// the model omits an explicit reference, default to the current inbound
834+
// message id — the poll it is replying to — mirroring how reaction-like
835+
// actions default their target (resolveReactionMessageId). Without this a
836+
// vote that names only the option index fails the required-reference
837+
// check below even though the intended poll is unambiguous.
833838
const pollRef =
834839
readStringParam(params, "pollId") ??
835840
readStringParam(params, "pollGuid") ??
836-
readStringParam(params, "messageId");
841+
readStringParam(params, "messageId") ??
842+
(toolContext?.currentMessageId != null ? String(toolContext.currentMessageId) : undefined);
837843
if (!pollRef) {
838844
throw new Error("iMessage poll-vote requires the poll message id (pollId or messageId).");
839845
}

extensions/imessage/src/monitor/monitor-provider.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
104104
import { stageIMessageAttachments } from "./media-staging.js";
105105
import { parseIMessageNotification } from "./parse-notification.js";
106+
import { createPollCommentFolder } from "./poll-comment.js";
106107
import { renderIMessagePollBody } from "./poll-render.js";
107108
import { enqueueIMessageReactionSystemEvent } from "./reaction-system-event.js";
108109
import { advanceIMessageRecoveryCursor, loadIMessageRecoveryCursor } from "./recovery-cursor.js";
@@ -832,6 +833,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
832833
}
833834
}
834835

836+
// iMessage delivers a poll's comment as a separate inline reply to the poll
837+
// balloon; fold it into the poll so the agent votes once instead of also
838+
// replying to the caption in prose (a redundant restatement of the vote).
839+
const pollCommentFolder = createPollCommentFolder();
840+
835841
function resolveIMessageInboundBodyText(message: IMessagePayload) {
836842
// Native poll balloons carry only a 0xFFFD placeholder in `text`; render the
837843
// decoded poll (question/options/votes) so the agent sees the actual poll.
@@ -885,6 +891,25 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
885891
return;
886892
}
887893

894+
// Remember native polls so a caption reply that lands WITH the poll is
895+
// recognized and folded. The poll balloon (rendered with options + a vote
896+
// cue) is still delivered; only the near-simultaneous comment is dropped so
897+
// the agent votes without also answering it as a standalone question. A
898+
// deliberate later inline reply to the poll falls outside the window and is
899+
// delivered normally.
900+
const pollFoldAtMs = message.created_at ? Date.parse(message.created_at) : Number.NaN;
901+
if (message.poll) {
902+
pollCommentFolder.rememberPoll(message.guid, pollFoldAtMs, message.sender);
903+
} else if (
904+
message.reply_to_guid != null &&
905+
pollCommentFolder.isPollComment(message.reply_to_guid, pollFoldAtMs, message.sender)
906+
) {
907+
logVerbose(
908+
"imessage: folding poll comment (inline reply sent with a poll) into the poll; not delivering standalone",
909+
);
910+
return;
911+
}
912+
888913
const {
889914
messageText,
890915
bodyText,

extensions/imessage/src/monitor/parse-notification.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null
7070
!isOptionalBoolean(message.is_from_me) ||
7171
!isOptionalString(message.text) ||
7272
!isOptionalStringOrNumber(message.reply_to_id) ||
73+
!isOptionalString(message.reply_to_guid) ||
7374
!isOptionalString(message.reply_to_text) ||
7475
!isOptionalString(message.reply_to_sender) ||
7576
!isOptionalString(message.created_at) ||
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Covers the poll-comment folder: a native poll's caption is an inline reply to
2+
// the poll balloon (its reply_to_guid == the poll's guid) that lands WITH the
3+
// poll, and must be folded (dropped) rather than delivered as a standalone
4+
// message the agent answers in prose. A deliberate later reply, or a different
5+
// sender's reply, must NOT be folded.
6+
import { describe, expect, it } from "vitest";
7+
import { createPollCommentFolder } from "./poll-comment.js";
8+
9+
const POLL_GUID = "75A8F623-947D-4611-A23D-4DDD6D17BC0F";
10+
const T0 = 1_000_000; // arbitrary base timestamp (ms)
11+
12+
describe("createPollCommentFolder", () => {
13+
it("folds a caption whose reply_to_guid targets a poll that lands with it", () => {
14+
const folder = createPollCommentFolder();
15+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
16+
// Caption ships with the poll — same instant, same sender.
17+
expect(folder.isPollComment(POLL_GUID, T0 + 500, "+15551110000")).toBe(true);
18+
});
19+
20+
it("does NOT fold a deliberate later inline reply to the poll", () => {
21+
const folder = createPollCommentFolder({ windowMs: 15_000 });
22+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
23+
// A real "I can't make it" reply a minute later must be delivered.
24+
expect(folder.isPollComment(POLL_GUID, T0 + 60_000, "+15551110000")).toBe(false);
25+
});
26+
27+
it("does NOT fold an in-window reply from a different sender (group member)", () => {
28+
const folder = createPollCommentFolder();
29+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
30+
expect(folder.isPollComment(POLL_GUID, T0 + 500, "+15559998888")).toBe(false);
31+
});
32+
33+
it("does NOT fold when the reply sender is known but the poll sender is unknown", () => {
34+
// Fail closed: an unknown-sender poll row must not turn a real in-window
35+
// reply from an identified participant into a dropped message. This fold
36+
// runs before the normal missing-sender/allowlist gate.
37+
const folder = createPollCommentFolder();
38+
folder.rememberPoll(POLL_GUID, T0, undefined);
39+
expect(folder.isPollComment(POLL_GUID, T0 + 500, "+15551110000")).toBe(false);
40+
});
41+
42+
it("does NOT fold when the reply sender is unknown", () => {
43+
const folder = createPollCommentFolder();
44+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
45+
expect(folder.isPollComment(POLL_GUID, T0 + 500, undefined)).toBe(false);
46+
});
47+
48+
it("does not fold a reply to an unrelated message", () => {
49+
const folder = createPollCommentFolder();
50+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
51+
expect(folder.isPollComment("SOME-OTHER-GUID", T0, "+15551110000")).toBe(false);
52+
});
53+
54+
it("does not fold a non-reply or a reply with no usable timestamp", () => {
55+
const folder = createPollCommentFolder();
56+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
57+
expect(folder.isPollComment(null, T0)).toBe(false);
58+
expect(folder.isPollComment("", T0)).toBe(false);
59+
expect(folder.isPollComment(POLL_GUID, Number.NaN)).toBe(false);
60+
});
61+
62+
it("does not track a poll without a usable timestamp or guid", () => {
63+
const folder = createPollCommentFolder();
64+
folder.rememberPoll(POLL_GUID, Number.NaN, "+15551110000");
65+
expect(folder.isPollComment(POLL_GUID, T0)).toBe(false);
66+
folder.rememberPoll(null, T0, "+15551110000");
67+
expect(folder.isPollComment("", T0)).toBe(false);
68+
});
69+
70+
it("does not fold before the poll has been seen (ordering safety)", () => {
71+
const folder = createPollCommentFolder();
72+
expect(folder.isPollComment(POLL_GUID, T0, "+15551110000")).toBe(false);
73+
folder.rememberPoll(POLL_GUID, T0, "+15551110000");
74+
expect(folder.isPollComment(POLL_GUID, T0, "+15551110000")).toBe(true);
75+
});
76+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// A native iMessage poll's comment/caption is delivered as a separate inbound
2+
// message that is an INLINE REPLY to the poll balloon (its `reply_to_guid` is
3+
// the poll's guid). Modern imsg emits balloon metadata, so the same-sender coalesce
4+
// path deliberately flushes the poll and the reply separately — which means the
5+
// caption reaches the agent as its own message. The agent then votes on the
6+
// poll AND answers the caption in prose, a redundant restatement of the vote.
7+
//
8+
// This tracker lets the monitor fold the caption into the poll: the poll message
9+
// already renders the options + vote cue, so a reply that arrives WITH the poll
10+
// is dropped instead of delivered standalone.
11+
//
12+
// The caption is sent as part of composing the poll, so its timestamp is
13+
// essentially the poll's. We only fold a reply whose own timestamp lands within
14+
// a short window of the poll; a deliberate later inline reply to the poll (e.g.
15+
// "I can't make it") falls outside the window and is delivered normally.
16+
17+
// The caption ships with the poll, so it lands within a couple seconds; a short
18+
// window keeps genuine later replies out. Generous enough to absorb clock/queue
19+
// skew, tight enough that a human's read-then-type reply falls outside.
20+
const DEFAULT_COMMENT_WINDOW_MS = 15_000;
21+
22+
function normalizeGuid(guid?: string | null): string {
23+
return guid?.trim() ?? "";
24+
}
25+
26+
function normalizeSender(sender?: string | null): string {
27+
return sender?.trim().toLowerCase() ?? "";
28+
}
29+
30+
type SeenPoll = { atMs: number; sender: string };
31+
32+
export function createPollCommentFolder(options?: { windowMs?: number }) {
33+
const windowMs = options?.windowMs ?? DEFAULT_COMMENT_WINDOW_MS;
34+
// poll guid -> the poll's send time + creator. Bounded: pruned on every write
35+
// against the newest poll time, so at most the polls seen within `windowMs`
36+
// are kept.
37+
const seenPolls = new Map<string, SeenPoll>();
38+
39+
function prune(referenceMs: number): void {
40+
for (const [key, seen] of seenPolls) {
41+
if (referenceMs - seen.atMs > windowMs) {
42+
seenPolls.delete(key);
43+
}
44+
}
45+
}
46+
47+
return {
48+
// Remember a native poll balloon (its guid + send time + creator) so a
49+
// caption reply that lands within the window from the same sender can be
50+
// folded. `atMs` is the poll's created_at; without a usable timestamp or
51+
// guid the poll is not tracked (fold stays disabled — messages deliver).
52+
rememberPoll(guid: string | null | undefined, atMs: number, sender?: string | null): void {
53+
const key = normalizeGuid(guid);
54+
if (!key || !Number.isFinite(atMs)) {
55+
return;
56+
}
57+
prune(atMs);
58+
seenPolls.set(key, { atMs, sender: normalizeSender(sender) });
59+
},
60+
// True only for the poll's caption: a reply whose `reply_to_guid` targets a
61+
// remembered poll, lands within the window after it, AND comes from the
62+
// poll's creator. A deliberate later reply, or any reply from someone else
63+
// (e.g. a group member), falls through and is delivered normally.
64+
isPollComment(
65+
replyToGuid: string | null | undefined,
66+
atMs: number,
67+
sender?: string | null,
68+
): boolean {
69+
const key = normalizeGuid(replyToGuid);
70+
if (!key || !Number.isFinite(atMs)) {
71+
return false;
72+
}
73+
const seen = seenPolls.get(key);
74+
if (!seen || atMs < seen.atMs || atMs - seen.atMs > windowMs) {
75+
return false;
76+
}
77+
const replySender = normalizeSender(sender);
78+
// Fail CLOSED on identity: fold only when the poll creator and the reply
79+
// sender are both known and identical. This fold runs before the normal
80+
// missing-sender/from-me/allowlist gate (monitor-provider handleMessageNowInner),
81+
// so folding on an unknown sender could drop a real in-window reply from a
82+
// different participant to the same poll guid. Unknown/mismatched sender
83+
// therefore falls through and is delivered (the poll_vote_echo guard still
84+
// catches a redundant spoken answer). Verified against chat.db: an inbound
85+
// poll and its caption both carry the sender handle, so the 1:1 caption
86+
// still folds as the same known sender.
87+
return seen.sender.length > 0 && replySender.length > 0 && seen.sender === replySender;
88+
},
89+
};
90+
}
91+
92+
export type PollCommentFolder = ReturnType<typeof createPollCommentFolder>;

extensions/imessage/src/monitor/poll-render.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ describe("renderIMessagePollBody", () => {
1414
expect(out).toContain("Favorite color?");
1515
expect(out).toContain("1) Red");
1616
expect(out).toContain("2) Blue");
17+
// Must cue the vote action so the agent votes instead of replying in prose;
18+
// the 📊 Poll prefix matches agents' vote-instruction trigger.
19+
expect(out).toContain("poll-vote");
20+
expect(out).toContain("\u{1F4CA} Poll");
1721
});
1822

1923
it("folds in vote tallies", () => {

extensions/imessage/src/monitor/poll-render.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,20 @@ export function renderIMessagePollBody(poll: IMessagePoll): string | null {
3333
tally.set(vote.option_id, (tally.get(vote.option_id) ?? 0) + 1);
3434
}
3535

36-
// Present the poll as a flat notification, not a question header with a
37-
// call-to-action. Rendering it as "Poll: <question>?" + "to vote, use…" made
38-
// the model treat it as a question to answer AND vote on (redundant echo).
39-
// A notification-style line lets it vote (options + indices retained) without
40-
// being nudged to also verbalize an answer.
36+
// Cue the vote action explicitly. The agent has the poll-vote tool, but given
37+
// a flat notification it tends to answer the poll with a prose text reply
38+
// instead of casting a vote. Naming the action + index makes voting the
39+
// obvious path. An earlier version dropped the call-to-action to stop the
40+
// model from also verbalizing its pick, but that suppressed voting entirely;
41+
// the poll_vote_echo guard now drops any redundant spoken answer, so the
42+
// call-to-action is safe. The `📊 Poll:` prefix also matches the trigger
43+
// phrasing agents key their vote instructions on.
4144
const optionList = options
4245
.map((option, index) => {
4346
const count = tally.get(option.id) ?? 0;
4447
return `${index + 1}) ${option.text}${count > 0 ? ` [${count}]` : ""}`;
4548
})
4649
.join(" ");
4750
const question = poll.question?.trim();
48-
return `[poll shared]${question ? ` ${question}` : ""} — options: ${optionList}`;
51+
return `\u{1F4CA} Poll${question ? `: ${question}` : ""} — options: ${optionList}. Cast your vote on this poll with the poll-vote action (pollOptionIndex = the option number); do not answer in a text reply.`;
4952
}

extensions/imessage/src/monitor/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export type IMessagePayload = {
4545
is_from_me?: boolean | null;
4646
text?: string | null;
4747
reply_to_id?: number | string | null;
48+
// imsg emits the replied-to message's GUID here (its inbound events carry
49+
// `reply_to_guid`, not a numeric `reply_to_id`); the poll-comment fold matches
50+
// a caption's `reply_to_guid` against the poll balloon's guid.
51+
reply_to_guid?: string | null;
4852
reply_to_text?: string | null;
4953
reply_to_sender?: string | null;
5054
created_at?: string | null;

0 commit comments

Comments
 (0)