openclaw - 💡(How to fix) Fix iMessage self-chat echo suppression fails: message-ID short-circuit prevents text-based fallback match [1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#59860Fetched 2026-04-08 02:39:44
View on GitHub
Comments
0
Participants
1
Timeline
0
Reactions
0
Participants

After updating to the latest OpenClaw release, outbound agent replies in iMessage self-chat (Notes to Self / single-person conversation) are echoed back and re-ingested as new inbound messages. This triggers duplicate agent turns and creates an echo loop.

Root Cause

The echo suppression logic in SentMessageCache.has() short-circuits on message ID mismatch before reaching the text-based fuzzy match fallback.

In monitor-provider-*.js, the has() method:

  1. Looks up the inbound message's ID/GUID in messageIdCache
  2. If not found and skipIdShortCircuit is false, returns false immediately
  3. Never reaches the textCache fuzzy substring comparison

The problem: iMessage echoes arrive with a new GUID (assigned by the Messages framework), different from the GUID of the original outbound message. So the ID lookup always misses. And since skipIdShortCircuit is derived from !hasInboundGuid — and all iMessage messages have GUIDs — it's always false, triggering the early return.

// Simplified reproduction of the bug path:
has(scope, lookup, skipIdShortCircuit = false) {
    const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
    if (messageIdKey) {
        const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
        if (idTimestamp && ...) return true;
        // ⚠️ BUG: This returns false before trying text match
        if (!skipIdShortCircuit && !(textTimestamp conditions...)) return false;
    }
    // Text fuzzy match below is never reached for iMessage echoes
    if (textKey) { ... }
}

Fix Action

Workaround

Removing the early return false in SentMessageCache.has() when the message ID doesn't match, allowing fallthrough to the text-based fuzzy comparison. Specifically, replacing:

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
    const textTimestamp = textKey ? this.textCache.get(`${scope}:${textKey}`) : void 0;
    const textBackedByIdTimestamp = textKey ? this.textBackedByIdCache.get(`${scope}:${textKey}`) : void 0;
    if (!skipIdShortCircuit && !(typeof textTimestamp === "number" && (!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp))) return false;
}

With:

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
}

This allows the method to always fall through to the text-based matching, which correctly identifies echoes via fuzzy substring comparison.

Code Example

// Simplified reproduction of the bug path:
has(scope, lookup, skipIdShortCircuit = false) {
    const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
    if (messageIdKey) {
        const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
        if (idTimestamp && ...) return true;
        // ⚠️ BUG: This returns false before trying text match
        if (!skipIdShortCircuit && !(textTimestamp conditions...)) return false;
    }
    // Text fuzzy match below is never reached for iMessage echoes
    if (textKey) { ... }
}

---

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
    const textTimestamp = textKey ? this.textCache.get(`${scope}:${textKey}`) : void 0;
    const textBackedByIdTimestamp = textKey ? this.textBackedByIdCache.get(`${scope}:${textKey}`) : void 0;
    if (!skipIdShortCircuit && !(typeof textTimestamp === "number" && (!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp))) return false;
}

---

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
}
RAW_BUFFERClick to expand / collapse

Bug: iMessage self-chat echo suppression fails due to message-ID short-circuit in SentMessageCache.has()

Summary

After updating to the latest OpenClaw release, outbound agent replies in iMessage self-chat (Notes to Self / single-person conversation) are echoed back and re-ingested as new inbound messages. This triggers duplicate agent turns and creates an echo loop.

Root Cause

The echo suppression logic in SentMessageCache.has() short-circuits on message ID mismatch before reaching the text-based fuzzy match fallback.

In monitor-provider-*.js, the has() method:

  1. Looks up the inbound message's ID/GUID in messageIdCache
  2. If not found and skipIdShortCircuit is false, returns false immediately
  3. Never reaches the textCache fuzzy substring comparison

The problem: iMessage echoes arrive with a new GUID (assigned by the Messages framework), different from the GUID of the original outbound message. So the ID lookup always misses. And since skipIdShortCircuit is derived from !hasInboundGuid — and all iMessage messages have GUIDs — it's always false, triggering the early return.

// Simplified reproduction of the bug path:
has(scope, lookup, skipIdShortCircuit = false) {
    const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
    if (messageIdKey) {
        const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
        if (idTimestamp && ...) return true;
        // ⚠️ BUG: This returns false before trying text match
        if (!skipIdShortCircuit && !(textTimestamp conditions...)) return false;
    }
    // Text fuzzy match below is never reached for iMessage echoes
    if (textKey) { ... }
}

Environment

  • OpenClaw version: Latest as of 2026-04-02
  • Platform: macOS, Apple Silicon (Mac Studio)
  • iMessage setup: Self-chat (agent sends to the same phone number it monitors)
  • Regression: This worked in prior versions. Started failing after update, but not immediately (likely needed specific message patterns/state to trigger).

Symptoms

  1. Echo text replies — Agent's own replies bounce back with garbled prefix characters (\u001b\u0001, \ufffc, etc.) from NSAttributedString serialization
  2. Empty messages — Messages with only attributed body data (no plain text) pass through as empty inbound messages
  3. Cascading echo loops — Each echo triggers a new agent turn, which sends a new reply, which echoes again

Workaround

Removing the early return false in SentMessageCache.has() when the message ID doesn't match, allowing fallthrough to the text-based fuzzy comparison. Specifically, replacing:

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
    const textTimestamp = textKey ? this.textCache.get(`${scope}:${textKey}`) : void 0;
    const textBackedByIdTimestamp = textKey ? this.textBackedByIdCache.get(`${scope}:${textKey}`) : void 0;
    if (!skipIdShortCircuit && !(typeof textTimestamp === "number" && (!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp))) return false;
}

With:

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
}

This allows the method to always fall through to the text-based matching, which correctly identifies echoes via fuzzy substring comparison.

Additional Context

  • The buildIMessageEchoScope (inbound) and deliverReplies scope (outbound) formats do match (${accountId}:imessage:${sender} vs ${accountId}:${target} where target includes imessage: prefix), so the scope is not the issue.
  • The fuzzy text match (textKey.includes(cachedText) || cachedText.includes(textKey)) works correctly once reached — chunked outbound messages are properly matched against concatenated echo text.
  • Echo text normalization (stripping control characters, \ufffc, etc.) also works correctly.
  • The skipIdShortCircuit flag is derived from !hasInboundGuid, but all iMessage messages have GUIDs, making this flag effectively always false for iMessage.

Suggested Fix

Either:

  1. Remove the ID-based short-circuit entirely (our workaround)
  2. Set skipIdShortCircuit = true for self-chat echo detection specifically
  3. Always fall through to text matching when in self-chat context (isSelfChat && is_from_me)

extent analysis

TL;DR

Removing the early return false in SentMessageCache.has() allows the method to fall through to the text-based matching, correctly identifying echoes via fuzzy substring comparison.

Guidance

  • Identify the has() method in SentMessageCache and remove the early return false when the message ID doesn't match, allowing fallthrough to the text-based fuzzy comparison.
  • Consider setting skipIdShortCircuit = true for self-chat echo detection specifically, as an alternative solution.
  • Verify that the fuzzy text match works correctly once reached, by testing with chunked outbound messages and concatenated echo text.
  • Ensure that echo text normalization (stripping control characters, \ufffc, etc.) works correctly, to prevent any issues with the fuzzy text match.

Example

if (messageIdKey) {
    const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
    if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) return true;
}

This code snippet shows the modified has() method, which removes the early return false and allows the method to fall through to the text-based matching.

Notes

The suggested fix assumes that the textKey.includes(cachedText) || cachedText.includes(textKey) fuzzy text match works correctly once reached. Additionally, the fix may need to be adapted based on the specific requirements of the self-chat echo detection.

Recommendation

Apply the workaround by removing the early return false in SentMessageCache.has(), as it allows the method to correctly identify echoes via fuzzy substring comparison. This solution is preferred because it is a simple and effective fix that has been verified to work.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix iMessage self-chat echo suppression fails: message-ID short-circuit prevents text-based fallback match [1 participants]