Skip to content

iMessage echo loop: NSAttributedString garbage bytes cause echo cache miss for is_from_me messages #61821

Description

@jarvisaibowen

Summary

Outgoing iMessages from Jarvis get reflected back as inbound messages, creating a reply loop. Root cause is a text normalization mismatch in the sentMessageCache echo cache.

Environment

  • OpenClaw: 2026.3.31 (213a704)
  • imsg: 0.5.0
  • macOS: Darwin 25.3.0 (arm64)
  • Chat type: iMessage DM

Root Cause

Step 1 — isSelfChat detection fires incorrectly

imsg RPC notifications report sent messages (is_from_me: true) with:

{
  "sender": "+13179650799",
  "chat_identifier": "+13179650799",
  "is_from_me": true
}

Because sender === chat_identifier, the isSelfChat check in resolveIMessageInboundDecision evaluates to true. This bypasses the simple is_from_me → drop path and instead defers to the echo cache.

Step 2 — Echo cache lookup fails due to text corruption

imsg reads iMessage messages from the attributedBody SQLite column (NSAttributedString binary format). For sent messages, it prepends garbled bytes to the parsed text:

Stored in echo cache:  "Good news — the portal is up..."
Reported by imsg RPC:  "\xef\xbf\xbd@\x01Good news — the portal is up..."

The leading \xef\xbf\xbd (U+FFFD replacement char) followed by 1–2 control bytes is an NSAttributedString parsing artefact. The text keys do not match, so sentMessageCache.has() returns false.

Step 3 — Message dispatched as inbound → loop

With a cache miss on a isSelfChat message, the code falls through to accessDecision. The sender (+13179650799) is in allowFrom, so the message is dispatched as a real inbound message. Jarvis replies. Loop begins.

Reproduction

  1. Configure iMessage channel with dmPolicy: allowlist, allowFrom: [<your_number>]
  2. Send multiple rapid replies from Jarvis to a DM conversation
  3. Each outgoing message fires an RPC notification with garbled attributedBody text
  4. Echo cache misses → messages dispatched as inbound → loop

Note: Single-message replies often work fine — the loop is most likely when multiple replies are sent in rapid succession (race between sentMessageCache.remember() and the RPC notification arrival).

Observed Pattern

Messages that loop start with bytes ef bf bd (U+FFFD) in the attributedBody-parsed text. Messages from the text column (when populated) do not exhibit this issue.

Suggested Fix

In resolveIMessageInboundDecision, normalize the inbound message text before echo cache comparison — specifically, strip leading \uFFFD and control characters (\x00\x1F) from the text before building the cache key:

function normalizeInboundBodyText(text: string): string {
  // Strip leading NSAttributedString parse artefacts (U+FFFD + control bytes)
  return text.replace(/^[\uFFFD\x00-\x1F]+/, "");
}

Alternatively, when is_from_me is true and the message is not a genuine self-chat (i.e. the sender is not the agent's own iCloud account), always drop without checking the echo cache. This could be gated on a new config key like channels.imessage.selfHandle to specify the agent's own iCloud address.

Workaround

Set channels.imessage.blockStreaming: true to coalesce rapid burst replies into a single message, reducing the race window. Does not fully eliminate the issue.

Additional Context

The destination_caller_id field in the RPC notification correctly identifies the agent's own iCloud address (e.g. [email protected]). This field could be used to reliably detect is_from_me messages that are NOT self-chats and hard-drop them without touching the echo cache.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions