diff --git a/CHANGELOG.md b/CHANGELOG.md index 464c81a3852..d2bbfa575c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt. - ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren. - Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1. +- iMessage/self-chat: remember ambiguous `sender === chat_identifier` outbound rows with missing `destination_caller_id` in self-chat dedupe state so the later reflected inbound copy still drops instead of re-entering inbound handling when the echo cache misses. Thanks @neeravmakwana. ## 2026.4.9 diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 61794f2d6b3..5d927b6a15c 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -217,12 +217,20 @@ export function resolveIMessageInboundDecision(params: { chatIdentifierNormalized != null && senderNormalized === chatIdentifierNormalized && matchesSelfChatDestination; + const isAmbiguousSelfThread = + !isGroup && + chatIdentifierNormalized != null && + senderNormalized === chatIdentifierNormalized && + destinationCallerIdNormalized == null; let skipSelfChatHasCheck = false; const inboundMessageIds = resolveInboundEchoMessageIds(params.message); const inboundMessageId = inboundMessageIds[0]; const hasInboundGuid = Boolean(normalizeReplyField(params.message.guid)); if (params.message.is_from_me) { + if (isAmbiguousSelfThread) { + params.selfChatCache?.remember(selfChatLookup); + } if (isSelfChat) { params.selfChatCache?.remember(selfChatLookup); const echoScope = buildIMessageEchoScope({ diff --git a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts index 0c5d1111c39..8311c759419 100644 --- a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts +++ b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts @@ -630,7 +630,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); - it("does not drop reflected inbound when destination_caller_id is absent (#63980)", () => { + it("drops reflected inbound when destination_caller_id is absent (#63980)", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -674,7 +674,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { }), ); - expect(reflection.kind).toBe("dispatch"); + expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" }); }); it("normal DM is_from_me=true is still dropped (regression test)", () => {