fix(imessage): render complete poll selections [AI-assisted] (#114714)

* fix(imessage): render poll selection snapshots

* fix(imessage): scope empty poll selections to sender

---------

Co-authored-by: clawSean <260045960+clawSean@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
clawSean
2026-07-28 01:28:08 -07:00
committed by GitHub
parent 3f209cf1b7
commit 4b32e6f0e8
3 changed files with 102 additions and 4 deletions

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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<string, string[]>();
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}"`;