Skip to content

Commit fed2452

Browse files
authored
Merge 4f323f0 into 53fe2e4
2 parents 53fe2e4 + 4f323f0 commit fed2452

4 files changed

Lines changed: 109 additions & 16 deletions

File tree

src/agents/tools/message-tool.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ describe("message tool gateway timeout", () => {
493493
describe("poll vote echo guard", () => {
494494
const currentChat = "iMessage;-;+15550001111";
495495

496-
function createPollVoteTool() {
496+
function createPollVoteTool(votedOption = "Blue") {
497497
setActivePluginRegistry(
498498
createTestRegistry([
499499
{
@@ -525,7 +525,7 @@ describe("poll vote echo guard", () => {
525525
payload: {},
526526
toolResult: {
527527
content: [{ type: "text", text: "vote cast" }],
528-
details: { pollVotedOption: "Blue" },
528+
details: { pollVotedOption: votedOption },
529529
},
530530
dryRun: false,
531531
} as MessageActionRunResult)
@@ -575,6 +575,37 @@ describe("poll vote echo guard", () => {
575575
expect(mocks.runMessageAction).toHaveBeenCalledTimes(1);
576576
});
577577

578+
it("suppresses an emoji-suffixed option echoed with a leading emoji", async () => {
579+
// Live regression: iMessage poll options carry a trailing emoji
580+
// ("Lobster 🦞 ") while the agent echoes a leading one ("🦞 Lobster.").
581+
// A leading-only emoji strip left "lobster 🦞" != "lobster" and leaked.
582+
const tool = createPollVoteTool("Lobster 🦞 ");
583+
await castBlueVote(tool);
584+
585+
const result = await tool.execute("send", {
586+
action: "send",
587+
channel: "imessage",
588+
message: "🦞 Lobster.",
589+
});
590+
591+
expect(result.details).toMatchObject({ status: "suppressed", reason: "poll_vote_echo" });
592+
expect(mocks.runMessageAction).toHaveBeenCalledTimes(1);
593+
});
594+
595+
it("does not suppress a different keycap option with the same words", async () => {
596+
const tool = createPollVoteTool("Option 1️⃣");
597+
await castBlueVote(tool);
598+
599+
const result = await tool.execute("send", {
600+
action: "send",
601+
channel: "imessage",
602+
message: "2️⃣ Option.",
603+
});
604+
605+
expect(result.details).not.toMatchObject({ status: "suppressed" });
606+
expect(mocks.runMessageAction).toHaveBeenCalledTimes(2);
607+
});
608+
578609
it("does not cross accounts, delivery targets, or conflicting target fields", async () => {
579610
const accountTool = createPollVoteTool();
580611
await castBlueVote(accountTool);

src/agents/tools/message-tool.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
resolveGatewayOptions,
8383
type GatewayCallOptions,
8484
} from "./gateway.js";
85+
import { isPollVoteEchoText } from "./poll-vote-echo.js";
8586

8687
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
8788
const MESSAGE_TOOL_THREAD_READ_HINT =
@@ -172,14 +173,6 @@ type VisibleTextSuppressionReason =
172173

173174
const POLL_VOTE_ECHO_TTL_MS = 30_000;
174175

175-
function normalizePollEchoText(text: string): string {
176-
return text
177-
.replace(/^[\p{Extended_Pictographic}]+\s+/u, "")
178-
.replace(/[.!?\s]+$/u, "")
179-
.trim()
180-
.toLowerCase();
181-
}
182-
183176
function resolvePollVoteEchoRoute(params: {
184177
action: ChannelMessageActionName;
185178
args: Record<string, unknown>;
@@ -1399,12 +1392,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
13991392
readStringParam(params, "text") ??
14001393
readStringParam(params, "message") ??
14011394
readStringParam(params, "content");
1402-
const normalizedOption = normalizePollEchoText(vote.option);
1403-
if (
1404-
outboundText &&
1405-
normalizedOption &&
1406-
normalizePollEchoText(outboundText) === normalizedOption
1407-
) {
1395+
if (outboundText && isPollVoteEchoText(vote.option, outboundText)) {
14081396
return jsonResult({
14091397
status: "suppressed",
14101398
reason: "poll_vote_echo" satisfies VisibleTextSuppressionReason,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isPollVoteEchoText } from "./poll-vote-echo.js";
3+
4+
describe("isPollVoteEchoText", () => {
5+
it.each([
6+
["Lobster 🦞 ", "🦞 Lobster."],
7+
["USA 🇺🇸 ", "🇺🇸 USA."],
8+
["Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿", "🏴󠁧󠁢󠁳󠁣󠁴󠁿 Scotland."],
9+
["Team 👍🏽", "👍🏽 Team."],
10+
["Family 👨‍👩‍👧", "👨‍👩‍👧 Family."],
11+
["Option 1️⃣", "1️⃣ Option."],
12+
["1️⃣", "1️⃣"],
13+
["Blue", "Blue!"],
14+
["Blue", "🦞 Blue."],
15+
["Lobster 🦞", "Lobster."],
16+
["🍎", "🍎"],
17+
])("matches the same label and emoji signature: %s", (option, outboundText) => {
18+
expect(isPollVoteEchoText(option, outboundText)).toBe(true);
19+
});
20+
21+
it.each([
22+
["Option 1️⃣", "2️⃣ Option."],
23+
["1️⃣", "2️⃣"],
24+
["1", "1️⃣"],
25+
["Lobster 🦞", "🦀 Lobster."],
26+
["C#", "C"],
27+
["C++", "C"],
28+
["Node.js", "Node js"],
29+
["Blue", "Red"],
30+
["", ""],
31+
])("does not collapse distinct labels or emoji: %s / %s", (option, outboundText) => {
32+
expect(isPollVoteEchoText(option, outboundText)).toBe(false);
33+
});
34+
});

src/agents/tools/poll-vote-echo.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
type NormalizedPollEchoText = {
2+
emojiSignature: string;
3+
words: string;
4+
};
5+
6+
// Keep the emoji identity while ignoring where it appears. Messages stores poll
7+
// options with a trailing emoji, while models commonly restate the same emoji
8+
// before the label. Retaining the signature avoids collapsing distinct options.
9+
const POLL_ECHO_EMOJI_SEQUENCE =
10+
/(?:[0-9#*]\u{FE0F}?\u{20E3}|(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|\p{Emoji_Modifier}|[\u{E0020}-\u{E007F}]|\u{FE0E}|\u{FE0F}|\u{200D})+)/gu;
11+
12+
function normalizePollEchoText(text: string): NormalizedPollEchoText {
13+
let emojiSignature = "";
14+
const words = text
15+
.replace(POLL_ECHO_EMOJI_SEQUENCE, (emoji) => {
16+
emojiSignature += emoji.replace(/[\u{FE0E}\u{FE0F}]/gu, "");
17+
return " ";
18+
})
19+
.replace(/\s+/gu, " ")
20+
.trim()
21+
.replace(/[.!?]+$/u, "")
22+
.trim()
23+
.toLowerCase();
24+
return { emojiSignature, words };
25+
}
26+
27+
export function isPollVoteEchoText(option: string, outboundText: string): boolean {
28+
const normalizedOption = normalizePollEchoText(option);
29+
const normalizedOutbound = normalizePollEchoText(outboundText);
30+
const optionHasContent = Boolean(normalizedOption.words || normalizedOption.emojiSignature);
31+
if (!optionHasContent || normalizedOption.words !== normalizedOutbound.words) {
32+
return false;
33+
}
34+
if (normalizedOption.emojiSignature && normalizedOutbound.emojiSignature) {
35+
return normalizedOption.emojiSignature === normalizedOutbound.emojiSignature;
36+
}
37+
// A model may add or omit a decorative emoji around a word label. Emoji-only
38+
// options still require an exact signature so unrelated symbols never match.
39+
return Boolean(normalizedOption.words);
40+
}

0 commit comments

Comments
 (0)