From 4a5885df3a36ec67e8bea5056f08fd9e42d6b46c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 29 Mar 2026 13:00:01 +0530 Subject: [PATCH] fix(imessage): try all inbound echo ids --- .../src/monitor/inbound-processing.test.ts | 22 +++--- .../src/monitor/inbound-processing.ts | 68 ++++++++++++++++--- .../src/monitor/self-chat-dedupe.test.ts | 33 +++++++++ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts index 3e61490f389..2012353037c 100644 --- a/extensions/imessage/src/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -75,13 +75,10 @@ describe("resolveIMessageInboundDecision echo detection", () => { }); expect(decision).toEqual({ kind: "drop", reason: "echo" }); - expect(echoHas).toHaveBeenCalledWith( - "default:imessage:+15555550123", - expect.objectContaining({ - text: "Reasoning:\n_step_", - messageId: "42", - }), - ); + expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", { + messageId: "42", + }); + expect(echoHas).toHaveBeenCalledTimes(1); }); it("matches attachment-only echoes by bodyText placeholder", () => { @@ -100,12 +97,17 @@ describe("resolveIMessageInboundDecision echo detection", () => { }); expect(decision).toEqual({ kind: "drop", reason: "echo" }); - expect(echoHas).toHaveBeenCalledWith( + expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", { + messageId: "42", + }); + expect(echoHas).toHaveBeenNthCalledWith( + 2, "default:imessage:+15555550123", - expect.objectContaining({ + { text: "", messageId: "42", - }), + }, + undefined, ); }); diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index be230fa70b8..b1119333e46 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -64,6 +64,50 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext | return { body, id, sender }; } +function resolveInboundEchoMessageIds(message: IMessagePayload): string[] { + const values = [ + message.id != null ? String(message.id) : undefined, + normalizeReplyField(message.guid), + ]; + const ids: string[] = []; + for (const value of values) { + if (!value || ids.includes(value)) { + continue; + } + ids.push(value); + } + return ids; +} + +function hasIMessageEchoMatch(params: { + echoCache: { + has: ( + scope: string, + lookup: { text?: string; messageId?: string }, + skipIdShortCircuit?: boolean, + ) => boolean; + }; + scope: string; + text?: string; + messageIds: string[]; + skipIdShortCircuit?: boolean; +}): boolean { + for (const messageId of params.messageIds) { + if (params.echoCache.has(params.scope, { messageId })) { + return true; + } + } + const fallbackMessageId = params.messageIds[0]; + if (!params.text && !fallbackMessageId) { + return false; + } + return params.echoCache.has( + params.scope, + { text: params.text, messageId: fallbackMessageId }, + params.skipIdShortCircuit, + ); +} + export type IMessageInboundDispatchDecision = { kind: "dispatch"; isGroup: boolean; @@ -168,9 +212,9 @@ export function resolveIMessageInboundDecision(params: { // When true, the selfChatCache.has() check below must be skipped — we just // called remember() and would immediately match our own entry. let skipSelfChatHasCheck = false; - const inboundMessageId = - normalizeReplyField(params.message.guid) ?? - (params.message.id != null ? String(params.message.id) : undefined); + const inboundMessageIds = resolveInboundEchoMessageIds(params.message); + const inboundMessageId = inboundMessageIds[0]; + const hasInboundGuid = Boolean(normalizeReplyField(params.message.guid)); if (params.message.is_from_me) { // Always cache in selfChatCache so the upcoming is_from_me=false reflection @@ -190,11 +234,13 @@ export function resolveIMessageInboundDecision(params: { if ( params.echoCache && (bodyText || inboundMessageId) && - params.echoCache.has( - echoScope, - { text: bodyText || undefined, messageId: inboundMessageId }, - !normalizeReplyField(params.message.guid), - ) + hasIMessageEchoMatch({ + echoCache: params.echoCache, + scope: echoScope, + text: bodyText || undefined, + messageIds: inboundMessageIds, + skipIdShortCircuit: !hasInboundGuid, + }) ) { return { kind: "drop", reason: "agent echo in self-chat" }; } @@ -305,9 +351,11 @@ export function resolveIMessageInboundDecision(params: { sender, }); if ( - params.echoCache.has(echoScope, { + hasIMessageEchoMatch({ + echoCache: params.echoCache, + scope: echoScope, text: bodyText || undefined, - messageId: inboundMessageId, + messageIds: inboundMessageIds, }) ) { params.logVerbose?.( diff --git a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts index d6f6e900371..7668026818a 100644 --- a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts +++ b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts @@ -439,6 +439,39 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" }); }); + it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); + + const echoCache = createSentMessageCache(); + const selfChatCache = createSelfChatCache(); + + const scope = "default:imessage:+15551234567"; + echoCache.remember(scope, { text: "Numeric id echo", messageId: "123709" }); + + vi.advanceTimersByTime(1000); + + const decision = resolveIMessageInboundDecision( + createParams({ + message: { + id: 123709, + guid: "p:0/GUID-different-shape", + sender: "+15551234567", + chat_identifier: "+15551234567", + text: "Numeric id echo", + is_from_me: true, + is_group: false, + }, + messageText: "Numeric id echo", + bodyText: "Numeric id echo", + echoCache, + selfChatCache, + }), + ); + + expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" }); + }); + it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));