Skip to content

Commit feaa685

Browse files
omarshahineclaude
andcommitted
fix(imessage): coalesce split-sends without delaying normal DMs
`coalesceSameSenderDms` previously debounced every DM for the full window (2500ms), so each reply waited out the debounce even when no follow-up was coming. A `Dump <URL>` whose payload row arrived late (e.g. attachment transfer lag) could also miss the window and dispatch as two turns, producing a stray "send me the URL" bubble. Classify each DM instead of holding all of them: - lead-in: a short bare command-style fragment (`Dump`) that plausibly precedes a payload — briefly held. - payload-join: a URL/attachment that completes a pending lead-in — merged into the held lead-in and flushed immediately. - instant: everything else (prose, questions, lone URLs) — zero added latency. Normal conversation now replies instantly; real split-sends still coalesce and flush as soon as the payload row lands rather than waiting out the window. The window now only bounds how long a lone lead-in waits for a follow-up. Adds pure `isIMessageSplitLeadIn` / `iMessageTextHasUrl` helpers in coalesce.ts with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bbfe8cc commit feaa685

4 files changed

Lines changed: 215 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
Docs: https://docs.openclaw.ai
44

5+
## Unreleased
6+
7+
### Fixes
8+
9+
- iMessage: coalesce same-sender split-sends (`Dump <URL>`, caption + image) without adding latency to normal DMs. The monitor now classifies each DM — only short command-style lead-ins are briefly held, the payload row that follows merges in and flushes immediately, and everything else (prose, questions, lone URLs) dispatches instantly instead of waiting out the debounce window.
10+
511
## 2026.6.2
612

713
### Highlights

extensions/imessage/src/monitor/coalesce.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { describe, expect, it } from "vitest";
33
import {
44
combineIMessagePayloads,
5+
iMessageTextHasUrl,
6+
isIMessageSplitLeadIn,
57
MAX_COALESCED_ATTACHMENTS,
68
MAX_COALESCED_ENTRIES,
79
MAX_COALESCED_TEXT_CHARS,
@@ -160,3 +162,52 @@ describe("combineIMessagePayloads", () => {
160162
expect(MAX_COALESCED_ENTRIES).toBeGreaterThan(1);
161163
});
162164
});
165+
166+
describe("iMessageTextHasUrl", () => {
167+
it("detects http and https URLs anywhere in the text", () => {
168+
expect(iMessageTextHasUrl("https://stocks.apple.com/A16n5gO09T5y1YRM")).toBe(true);
169+
expect(iMessageTextHasUrl("check this http://example.com out")).toBe(true);
170+
});
171+
172+
it("returns false for plain text and empty input", () => {
173+
expect(iMessageTextHasUrl("Dump")).toBe(false);
174+
expect(iMessageTextHasUrl("look at this")).toBe(false);
175+
expect(iMessageTextHasUrl("")).toBe(false);
176+
expect(iMessageTextHasUrl(null)).toBe(false);
177+
expect(iMessageTextHasUrl(undefined)).toBe(false);
178+
});
179+
});
180+
181+
describe("isIMessageSplitLeadIn", () => {
182+
it("treats short bare command fragments as lead-ins", () => {
183+
expect(isIMessageSplitLeadIn({ text: "Dump", hasMedia: false })).toBe(true);
184+
expect(isIMessageSplitLeadIn({ text: "Save this", hasMedia: false })).toBe(true);
185+
expect(isIMessageSplitLeadIn({ text: "look at", hasMedia: false })).toBe(true);
186+
});
187+
188+
it("does not hold complete one-liners (terminal punctuation)", () => {
189+
expect(isIMessageSplitLeadIn({ text: "what's for dinner?", hasMedia: false })).toBe(false);
190+
expect(isIMessageSplitLeadIn({ text: "thanks.", hasMedia: false })).toBe(false);
191+
expect(isIMessageSplitLeadIn({ text: "got it!", hasMedia: false })).toBe(false);
192+
});
193+
194+
it("does not hold longer messages beyond the word cap", () => {
195+
expect(isIMessageSplitLeadIn({ text: "can you look at this for me", hasMedia: false })).toBe(
196+
false,
197+
);
198+
});
199+
200+
it("does not hold messages that already carry the payload", () => {
201+
expect(isIMessageSplitLeadIn({ text: "Dump https://example.com", hasMedia: false })).toBe(
202+
false,
203+
);
204+
expect(isIMessageSplitLeadIn({ text: "https://example.com", hasMedia: false })).toBe(false);
205+
expect(isIMessageSplitLeadIn({ text: "Dump", hasMedia: true })).toBe(false);
206+
});
207+
208+
it("does not hold empty or whitespace-only text", () => {
209+
expect(isIMessageSplitLeadIn({ text: "", hasMedia: false })).toBe(false);
210+
expect(isIMessageSplitLeadIn({ text: " ", hasMedia: false })).toBe(false);
211+
expect(isIMessageSplitLeadIn({ text: null, hasMedia: false })).toBe(false);
212+
});
213+
});

extensions/imessage/src/monitor/coalesce.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,56 @@ export const MAX_COALESCED_TEXT_CHARS = 4000;
1717
export const MAX_COALESCED_ATTACHMENTS = 20;
1818
export const MAX_COALESCED_ENTRIES = 10;
1919

20+
/**
21+
* Longest text (in whitespace-delimited words) still treated as a split
22+
* lead-in. Apple peels short command fragments — `Dump`, `Save this`,
23+
* `look at` — off the front of a `<command> <payload>` send; anything longer
24+
* reads as a self-contained message and dispatches instantly.
25+
*/
26+
export const LEAD_IN_MAX_WORDS = 3;
27+
28+
const URL_PATTERN = /\bhttps?:\/\/\S+/i;
29+
30+
/** True when the text carries an http(s) URL — the typical split-send payload. */
31+
export function iMessageTextHasUrl(text: string | null | undefined): boolean {
32+
return URL_PATTERN.test(text ?? "");
33+
}
34+
35+
/**
36+
* A "split lead-in" is the short text fragment Apple delivers as its own
37+
* `chat.db` row just before the payload row of a `<command> <URL/attachment>`
38+
* send (e.g. `Dump` ahead of `https://…`, or a bare caption typed just before
39+
* an image). It is the ONLY DM shape the monitor holds back to wait for a
40+
* follow-up; every other shape dispatches instantly so normal conversation
41+
* carries zero added latency.
42+
*
43+
* Heuristic: non-empty short text (≤ {@link LEAD_IN_MAX_WORDS} words), no URL,
44+
* no media, and no terminal sentence punctuation. The dangling, unpunctuated
45+
* shape is what separates a lead-in (`Dump`) from a complete one-liner
46+
* (`what's for dinner?`). The cost of a false positive is bounded: a lone
47+
* short fragment with no follow-up still flushes after the coalesce window.
48+
*/
49+
export function isIMessageSplitLeadIn(params: {
50+
text: string | null | undefined;
51+
hasMedia: boolean;
52+
}): boolean {
53+
if (params.hasMedia) {
54+
return false;
55+
}
56+
const text = (params.text ?? "").trim();
57+
if (!text) {
58+
return false;
59+
}
60+
if (iMessageTextHasUrl(text)) {
61+
return false;
62+
}
63+
if (/[.?!]$/.test(text)) {
64+
return false;
65+
}
66+
const words = text.split(/\s+/).filter(Boolean);
67+
return words.length >= 1 && words.length <= LEAD_IN_MAX_WORDS;
68+
}
69+
2070
export type CoalescedIMessagePayload = IMessagePayload & {
2171
/**
2272
* Source GUIDs folded into this merged payload, in arrival order. Includes

extensions/imessage/src/monitor/monitor-provider.ts

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import { normalizeIMessageHandle } from "../targets.js";
6666
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
6767
import { runIMessageCatchup } from "./catchup-bridge.js";
6868
import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js";
69-
import { combineIMessagePayloads } from "./coalesce.js";
69+
import { combineIMessagePayloads, iMessageTextHasUrl, isIMessageSplitLeadIn } from "./coalesce.js";
7070
import { repairIMessageConversationAnchor } from "./conversation-repair.js";
7171
import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js";
7272
import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js";
@@ -346,11 +346,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
346346
? await resolveIMessageStartupRowidWatermark(watchSourceDbPath)
347347
: null;
348348

349-
// When `coalesceSameSenderDms` is enabled and the user has not set an
350-
// explicit inbound debounce for this channel, widen the window to 2500 ms.
351-
// Apple's split-send for `<command> <URL>` arrives ~0.8-2.0 s apart on most
352-
// setups, so the legacy 0 ms default would flush the command alone before
353-
// the URL row reaches the debouncer.
349+
// `coalesceSameSenderDms` merges Apple's split-send — a `<command> <URL>` or
350+
// `<caption> <image>` send that arrives as two `chat.db` rows ~0.8-2.0 s
351+
// apart — into one agent turn, WITHOUT taxing normal conversation. Rather
352+
// than debounce every DM (which delayed every reply by the full window), the
353+
// monitor classifies each DM:
354+
// - "lead-in" — a short bare fragment (`Dump`) that plausibly precedes
355+
// a payload. Held until the payload arrives or the window
356+
// elapses.
357+
// - "payload-join" — a URL/attachment that completes a pending lead-in.
358+
// Merged into the held lead-in and flushed immediately.
359+
// - "instant" — everything else (prose, questions, lone URLs). Zero
360+
// added latency.
361+
// The window only bounds how long a lead-in waits for a follow-up, so it must
362+
// still exceed Apple's max split gap; real split-sends flush as soon as the
363+
// payload row lands.
354364
const coalesceSameSenderDms = imessageCfg.coalesceSameSenderDms === true;
355365
const inboundCfg = cfg.messages?.inbound;
356366
const hasExplicitInboundDebounce =
@@ -359,8 +369,59 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
359369
const debounceMsOverride =
360370
coalesceSameSenderDms && !hasExplicitInboundDebounce ? 2500 : undefined;
361371

372+
// Keys of DM lead-ins currently buffered, awaiting a payload follow-up. A
373+
// payload that matches a pending key merges into the held lead-in instead of
374+
// dispatching on its own.
375+
const pendingLeadInKeys = new Set<string>();
376+
377+
const buildDmCoalesceKey = (msg: IMessagePayload): string | null => {
378+
const sender = msg.sender?.trim();
379+
if (!sender) {
380+
return null;
381+
}
382+
const conversationId =
383+
msg.chat_id != null
384+
? `chat:${msg.chat_id}`
385+
: (msg.chat_guid ?? msg.chat_identifier ?? "unknown");
386+
return `imessage:${accountInfo.accountId}:dm:${conversationId}:${sender}`;
387+
};
388+
389+
type DmCoalesceMode = "lead-in" | "payload-join" | "instant";
390+
type DmCoalesceDecision = { mode: DmCoalesceMode; key: string };
391+
392+
// Classify a DM for split-send coalescing. Returns null when coalescing does
393+
// not apply (disabled, group chat, reaction, or a from-me echo), so the
394+
// legacy/group path handles the message.
395+
const classifyDmCoalesce = (msg: IMessagePayload): DmCoalesceDecision | null => {
396+
if (!coalesceSameSenderDms || msg.is_group === true) {
397+
return null;
398+
}
399+
if (msg.is_from_me === true) {
400+
return null;
401+
}
402+
if (resolveIMessageReactionContext(msg, (msg.text ?? "").trim())) {
403+
return null;
404+
}
405+
const key = buildDmCoalesceKey(msg);
406+
if (!key) {
407+
return null;
408+
}
409+
const hasMedia = Boolean(
410+
msg.attachments?.some((attachment) => !isIMessagePluginPayloadAttachment(attachment)),
411+
);
412+
const isPayload = hasMedia || iMessageTextHasUrl(msg.text);
413+
if (isPayload && pendingLeadInKeys.has(key)) {
414+
return { mode: "payload-join", key };
415+
}
416+
if (isIMessageSplitLeadIn({ text: msg.text, hasMedia })) {
417+
return { mode: "lead-in", key };
418+
}
419+
return { mode: "instant", key };
420+
};
421+
362422
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{
363423
message: IMessagePayload;
424+
dm?: DmCoalesceDecision;
364425
}>({
365426
cfg,
366427
channel: "imessage",
@@ -371,21 +432,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
371432
if (!sender) {
372433
return null;
373434
}
435+
// DM coalesce path: key on chat:sender so a lead-in and its payload
436+
// follow-up fall into the same bucket and merge into one agent turn.
437+
if (entry.dm) {
438+
return entry.dm.key;
439+
}
440+
// Group chats / coalesce-disabled use the legacy key so multi-user turn
441+
// structure is preserved.
374442
const conversationId =
375443
msg.chat_id != null
376444
? `chat:${msg.chat_id}`
377445
: (msg.chat_guid ?? msg.chat_identifier ?? "unknown");
378-
379-
// With coalesceSameSenderDms enabled, DMs key on chat:sender so two
380-
// distinct user sends — `Dump` followed by a pasted URL that Apple
381-
// delivers as a separate row — fall into the same bucket and merge
382-
// into one agent turn. Group chats fall through to the legacy key so
383-
// shouldDebounce can route them to the instant-dispatch path and
384-
// preserve multi-user turn structure.
385-
if (coalesceSameSenderDms && msg.is_group !== true) {
386-
return `imessage:${accountInfo.accountId}:dm:${conversationId}:${sender}`;
387-
}
388-
389446
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
390447
},
391448
shouldDebounce: (entry) => {
@@ -397,16 +454,16 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
397454
if (msg.is_from_me === true) {
398455
return false;
399456
}
400-
401-
// With coalesceSameSenderDms enabled, debounce DM messages aggressively
402-
// (text, media, control commands) so split-sends — `Dump <URL>`,
403-
// `Save 📎image caption`, and rapid floods — merge into one agent
404-
// turn. Group chats keep instant dispatch so the bot stays responsive
405-
// when multiple people are typing.
457+
// DM coalesce path: only hold lead-ins and the payloads that join them;
458+
// everything else dispatches instantly.
459+
if (entry.dm) {
460+
return entry.dm.mode !== "instant";
461+
}
462+
// Group chats keep instant dispatch when coalescing is enabled so the bot
463+
// stays responsive when multiple people are typing.
406464
if (coalesceSameSenderDms) {
407-
return msg.is_group !== true;
465+
return false;
408466
}
409-
410467
// Legacy gate: text-only, no control commands, no media.
411468
return shouldDebounceTextInbound({
412469
text: msg.text,
@@ -420,6 +477,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
420477
if (entries.length === 0) {
421478
return;
422479
}
480+
// A flushing bucket is no longer pending — clear any lead-in keys it held
481+
// so a later payload is classified fresh.
482+
for (const entry of entries) {
483+
if (entry.dm) {
484+
pendingLeadInKeys.delete(entry.dm.key);
485+
}
486+
}
423487
if (entries.length === 1) {
424488
await handleMessageNow(entries[0].message);
425489
return;
@@ -434,6 +498,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
434498
}
435499
await handleMessageNow(combined);
436500
},
501+
onCancel: (entries) => {
502+
for (const entry of entries) {
503+
if (entry.dm) {
504+
pendingLeadInKeys.delete(entry.dm.key);
505+
}
506+
}
507+
},
437508
onError: (err) => {
438509
runtime.error?.(`imessage debounce flush failed: ${String(err)}`);
439510
},
@@ -1053,7 +1124,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
10531124
if (!repairedMessage) {
10541125
return;
10551126
}
1056-
await inboundDebouncer.enqueue({ message: repairedMessage });
1127+
const dm = classifyDmCoalesce(repairedMessage) ?? undefined;
1128+
if (dm?.mode === "lead-in") {
1129+
// Remember this lead-in so the payload row that follows merges into it
1130+
// instead of dispatching on its own.
1131+
pendingLeadInKeys.add(dm.key);
1132+
}
1133+
await inboundDebouncer.enqueue({ message: repairedMessage, dm });
1134+
if (dm?.mode === "payload-join") {
1135+
// The payload has merged into the buffered lead-in; flush now so the
1136+
// combined turn dispatches immediately rather than waiting out the window.
1137+
await inboundDebouncer.flushKey(dm.key);
1138+
}
10571139
};
10581140

10591141
await waitForTransportReady({

0 commit comments

Comments
 (0)