diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index ca333f12e30b..8666b01ec0b8 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -728,7 +728,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P function resolveIMessageInboundBodyText(message: IMessagePayload) { // Native poll balloons carry only a 0xFFFD placeholder in `text`; render the // decoded poll (question/options/votes) so the agent sees the actual poll. - const pollBody = message.poll ? renderIMessagePollBody(message.poll) : null; + const pollBody = message.poll ? renderIMessagePollBody(message.poll, message.sender) : null; const messageText = (pollBody ?? message.text ?? "").trim(); const attachments = includeAttachments ? (message.attachments ?? []) : []; const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots; diff --git a/extensions/imessage/src/monitor/poll-render.test-support.ts b/extensions/imessage/src/monitor/poll-render.test-support.ts index f06710637774..c6944702fa9c 100644 --- a/extensions/imessage/src/monitor/poll-render.test-support.ts +++ b/extensions/imessage/src/monitor/poll-render.test-support.ts @@ -49,6 +49,70 @@ describe("renderIMessagePollBody", () => { expect(out).toContain("Blue"); }); + it("renders the full selection snapshot instead of a stale singular vote", () => { + const out = renderIMessagePollBody({ + kind: "vote", + vote: { + participant: "+12065550123", + option_id: "a", + option_text: "Lobster", + event_type: "selected", + }, + votes: [ + { + participant: "+12065550123", + option_id: "a", + option_text: "Lobster", + event_type: "selected", + }, + { + participant: "+12065550123", + option_id: "b", + option_text: "Also lobster", + event_type: "selected", + }, + ], + }); + + expect(out).toContain('currently selected "Lobster", "Also lobster"'); + expect(out).not.toContain('voted for "Lobster"'); + }); + + it("does not report a remaining selection as a new vote", () => { + const out = renderIMessagePollBody({ + kind: "vote", + vote: { + participant: "+12065550123", + option_id: "b", + option_text: "Beta", + event_type: "selected", + }, + votes: [ + { + participant: "+12065550123", + option_id: "b", + option_text: "Beta", + event_type: "selected", + }, + ], + }); + + expect(out).toContain('currently selected "Beta"'); + expect(out).not.toContain('voted for "Beta"'); + }); + + it("scopes an empty selection snapshot to the event sender", () => { + const out = renderIMessagePollBody( + { + kind: "vote", + votes: [], + }, + "+12065550123", + ); + + expect(out).toBe("\u{1F4CA} Poll selections: +12065550123 currently selected no options"); + }); + it("returns null for a poll with no options and no vote", () => { expect(renderIMessagePollBody({ kind: "created", options: [] })).toBeNull(); }); diff --git a/extensions/imessage/src/monitor/poll-render.ts b/extensions/imessage/src/monitor/poll-render.ts index da4e869d33fc..b0d63e3a08a0 100644 --- a/extensions/imessage/src/monitor/poll-render.ts +++ b/extensions/imessage/src/monitor/poll-render.ts @@ -6,16 +6,50 @@ // numbered options so it can vote by 1-based index via the poll-vote action. import type { IMessagePoll } from "./types.js"; -export function renderIMessagePollBody(poll: IMessagePoll): string | null { +export function renderIMessagePollBody(poll: IMessagePoll, sender?: string | null): string | null { const options = poll.options ?? []; - // Vote update: surface who voted for what so the agent can follow tallies. + // Vote update: prefer imsg's full selection snapshot. Its singular `vote` + // field is only the first decoded entry, not necessarily the option that + // changed. Older imsg builds without `votes` still use the singular fallback. if (poll.kind === "vote" || (poll.vote && options.length === 0)) { + if (poll.votes) { + // An empty snapshot carries no vote-level participant, but it still only + // describes the event sender's selections—not the state of the full poll. + const snapshotParticipant = + poll.votes.find((vote) => vote.participant?.trim())?.participant?.trim() || + sender?.trim() || + "someone"; + const selectionsByParticipant = new Map(); + for (const vote of poll.votes) { + if (vote.event_type === "removed") { + continue; + } + const who = vote.participant?.trim() || snapshotParticipant; + const what = vote.option_text?.trim() || vote.option_id || "an option"; + const selections = selectionsByParticipant.get(who) ?? []; + if (!selections.includes(what)) { + selections.push(what); + } + selectionsByParticipant.set(who, selections); + } + + if (selectionsByParticipant.size === 0) { + return `\u{1F4CA} Poll selections: ${snapshotParticipant} currently selected no options`; + } + + const snapshots = Array.from(selectionsByParticipant, ([who, selections]) => { + const quotedSelections = selections.map((selection) => `"${selection}"`).join(", "); + return `${who} currently selected ${quotedSelections}`; + }); + return `\u{1F4CA} Poll selections: ${snapshots.join("; ")}`; + } + const vote = poll.vote; if (!vote) { return "\u{1F4CA} Poll vote received"; } - const who = vote.participant?.trim() || "someone"; + const who = vote.participant?.trim() || sender?.trim() || "someone"; const what = vote.option_text?.trim() || vote.option_id || "an option"; const verb = vote.event_type === "removed" ? "removed their vote for" : "voted for"; return `\u{1F4CA} Poll vote: ${who} ${verb} "${what}"`;