Skip to content

Commit 9d69f06

Browse files
fix(imessage): normalize attributed echo prefixes
1 parent 5fbc95e commit 9d69f06

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

extensions/imessage/src/monitor/echo-cache.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,72 @@ export type SentMessageCache = {
2323
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
2424
const SENT_MESSAGE_ID_TTL_MS = 60_000;
2525

26+
function isLowControlOrReplacementChar(char: string): boolean {
27+
if (char === "\uFFFD") {
28+
return true;
29+
}
30+
const code = char.charCodeAt(0);
31+
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
32+
}
33+
34+
function isHighAttributedLeadChar(char: string): boolean {
35+
if (char === "\uFFFD") {
36+
return true;
37+
}
38+
const code = char.charCodeAt(0);
39+
return code >= 0x80 && code <= 0x9f;
40+
}
41+
42+
function isAsciiPrintableChar(char: string): boolean {
43+
const code = char.charCodeAt(0);
44+
return code >= 0x20 && code <= 0x7e;
45+
}
46+
47+
function stripLeadingIMessageAttributedTextPrefix(text: string): string {
48+
let offset = 0;
49+
50+
while (offset < text.length) {
51+
const first = text[offset];
52+
if (!first) {
53+
break;
54+
}
55+
const second = text[offset + 1];
56+
const third = text[offset + 2];
57+
if (
58+
isHighAttributedLeadChar(first) &&
59+
third &&
60+
isLowControlOrReplacementChar(third) &&
61+
(!second || isAsciiPrintableChar(second))
62+
) {
63+
offset += second ? 3 : 2;
64+
continue;
65+
}
66+
67+
let runEnd = offset;
68+
while (runEnd < text.length && runEnd - offset < 4) {
69+
const char = text[runEnd];
70+
if (!char || !isLowControlOrReplacementChar(char)) {
71+
break;
72+
}
73+
runEnd += 1;
74+
}
75+
if (runEnd > offset) {
76+
offset = runEnd;
77+
continue;
78+
}
79+
80+
break;
81+
}
82+
83+
const stripped = offset > 0 ? text.slice(offset) : text;
84+
return stripped || text;
85+
}
86+
2687
function normalizeEchoTextKey(text: string | undefined): string | null {
2788
if (!text) {
2889
return null;
2990
}
30-
const normalized = text.replace(/\r\n?/g, "\n").trim();
91+
const normalized = stripLeadingIMessageAttributedTextPrefix(text).replace(/\r\n?/g, "\n").trim();
3192
return normalized ? normalized : null;
3293
}
3394

extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,23 @@ describe("iMessage sent-message echo cache", () => {
4141
expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false);
4242
expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true);
4343
});
44+
45+
it("strips attributed-text prefix bytes before text fallback matching", () => {
46+
vi.useFakeTimers();
47+
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
48+
const cache = createSentMessageCache();
49+
50+
cache.remember("acct:imessage:+1555", {
51+
text: "Confirmed full access",
52+
messageId: "p:0/GUID-outbound",
53+
});
54+
55+
expect(
56+
cache.has(
57+
"acct:imessage:+1555",
58+
{ text: "\u0093b\u0002Confirmed full access", messageId: "123799" },
59+
true,
60+
),
61+
).toBe(true);
62+
});
4463
});

extensions/imessage/src/monitor/self-chat-dedupe.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,41 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
748748
// With skipIdShortCircuit=false (default), ID mismatch causes early return false.
749749
expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, false)).toBe(false);
750750
});
751+
752+
it("drops self-chat echoes when reflected text carries attributed prefix bytes", () => {
753+
vi.useFakeTimers();
754+
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
755+
756+
const echoCache = createSentMessageCache();
757+
const selfChatCache = createSelfChatCache();
758+
const scope = "default:imessage:+15551234567";
759+
echoCache.remember(scope, {
760+
text: "Cached reply",
761+
messageId: "p:0/GUID-echo",
762+
});
763+
764+
vi.advanceTimersByTime(1000);
765+
766+
const decision = resolveIMessageInboundDecision(
767+
createParams({
768+
message: {
769+
id: 123799,
770+
sender: "+15551234567",
771+
chat_identifier: "+15551234567",
772+
destination_caller_id: "+15551234567",
773+
text: "\u0093b\u0002Cached reply",
774+
is_from_me: true,
775+
is_group: false,
776+
},
777+
messageText: "\u0093b\u0002Cached reply",
778+
bodyText: "\u0093b\u0002Cached reply",
779+
echoCache,
780+
selfChatCache,
781+
}),
782+
);
783+
784+
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
785+
});
751786
});
752787

753788
describe("echo cache — text fallback for null-id inbound messages", () => {

0 commit comments

Comments
 (0)