Skip to content

Commit 14506ae

Browse files
authored
fix(bluebubbles): add opt-in coalesceSameSenderDms for split-send DMs (#69258)
Merged via squash. Prepared head SHA: 8f1bd3c Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
1 parent f1805ab commit 14506ae

16 files changed

Lines changed: 921 additions & 22 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
7575
- Codex/app-server: release the session lane when a downstream consumer throws while draining the `turn/completed` notification, so follow-up messages after a Codex plugin reply stop queueing behind a stale lane lock. Fixes #67996. (#69072) Thanks @ayeshakhalid192007-dev.
7676
- Codex/app-server: default approval handling to `on-request` so Codex harness sessions do not start with overly permissive tool approvals. (#68721) Thanks @Lucenx9.
7777
- Cron/delivery: keep isolated cron chat delivery tools available, resolve `channel: "last"` targets from the gateway, show delivery previews in `cron list/show`, and avoid duplicate fallback sends after direct message-tool delivery. (#69587) Thanks @obviyus.
78+
- BlueBubbles: add opt-in `channels.bluebubbles.coalesceSameSenderDms` so a single composed message with text + pasted URL (which Apple splits into two webhooks ~0.8-2.0 s apart) arrives as one agent turn instead of two. When enabled, DM messages that are not linked via `associatedMessageGuid` hash to `dm:<chat>:<sender>` so the inbound debounce window merges them into a single merged turn — including URL-preview balloon events, DM control-command sends (which normally bypass debouncing), and rapid same-sender follow-ups. The default inbound debounce window widens from 500 ms to 2500 ms when the flag is set without an explicit `messages.inbound.byChannel.bluebubbles`, covering the observed Apple split-send cadence. Every source `messageId` folded into the merged view is committed to the inbound dedupe store after processing, so a later MessagePoller replay of any individual source event is recognized as a duplicate. Merged output is bounded (≤4000 chars text with an explicit `…[truncated]` marker, ≤20 attachments, first-plus-latest sampling beyond 10 source entries) so a rapid-fire flood inside the window cannot amplify the downstream prompt. Group chats and existing text+balloon follow-ups continue to key per-message. See [Coalescing split-send DMs](https://docs.openclaw.ai/channels/bluebubbles#coalescing-split-send-dms-command--url-in-one-composition) for scenarios, tuning, and troubleshooting. (#69258) Thanks @omarshahine.
7879
- Cron/Telegram: key isolated direct-delivery dedupe to each cron execution instead of the reused session id, so recurring Telegram announce runs no longer report delivered while silently skipping later sends. (#69000) Thanks @obviyus.
7980
- Models/Kimi: default bundled Kimi thinking to off and normalize Anthropic-compatible `thinking` payloads so stale session `/think` state no longer silently re-enables reasoning on Kimi runs. (#68907) Thanks @frankekn.
8081
- Control UI/cron: keep the runtime-only `last` delivery sentinel from being materialized into persisted cron delivery and failure-alert channel configs when jobs are created or edited. (#68829) Thanks @tianhaocui.
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
e3a16ceb9e933c5b707b717c18a1d9d50f98e687a98e6c35f4f3a290f7036a62 config-baseline.json
2-
ae1ab87635e7bf613c84fee04425af901ceeb67fb5dbcf1c74095aa00a59ee88 config-baseline.core.json
3-
e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json
4-
8fb3a1cf5fe56ab8fc2cb46341c3403aed32b0d1f0aaeac0e96cd3599db4f06e config-baseline.plugin.json
1+
cc473bcd00e63c3d3f351e4de1ceb390aae88dddce8616929e98a9d94412b1b9 config-baseline.json
2+
7956c319e82d288d496a51cb2ff4485ab72ef4900cb089f99e1df8b9ef3bfb73 config-baseline.core.json
3+
cd467228990cdbdebde2fa87d8b1384b94c149e791f2e67250bf17b13162d4a1 config-baseline.channel.json
4+
17a73724e5082b3aa846c220d38115916fb6003887439e6794510a99fc73f7de config-baseline.plugin.json

docs/channels/bluebubbles.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,103 @@ Use full IDs for durable automations and storage:
393393

394394
See [Configuration](/gateway/configuration) for template variables.
395395

396+
## Coalescing split-send DMs (command + URL in one composition)
397+
398+
When a user types a command and a URL together in iMessage — e.g. `Dump https://example.com/article` — Apple splits the send into **two separate webhook deliveries**:
399+
400+
1. A text message (`"Dump"`).
401+
2. A URL-preview balloon (`"https://..."`) with OG-preview images as attachments.
402+
403+
The two webhooks arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost.
404+
405+
`channels.bluebubbles.coalesceSameSenderDms` opts a DM into merging consecutive same-sender webhooks into a single agent turn. Group chats continue to key per-message so multi-user turn structure is preserved.
406+
407+
### When to enable
408+
409+
Enable when:
410+
411+
- You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.).
412+
- Your users paste URLs, images, or long content alongside commands.
413+
- You can accept the added DM turn latency (see below).
414+
415+
Leave disabled when:
416+
417+
- You need minimum command latency for single-word DM triggers.
418+
- All your flows are one-shot commands without payload follow-ups.
419+
420+
### Enabling
421+
422+
```json5
423+
{
424+
channels: {
425+
bluebubbles: {
426+
coalesceSameSenderDms: true, // opt in (default: false)
427+
},
428+
},
429+
}
430+
```
431+
432+
With the flag on and no explicit `messages.inbound.byChannel.bluebubbles`, the debounce window widens to **2500 ms** (the default for non-coalescing is 500 ms). The wider window is required — Apple's split-send cadence of 0.8-2.0 s does not fit in the tighter default.
433+
434+
To tune the window yourself:
435+
436+
```json5
437+
{
438+
messages: {
439+
inbound: {
440+
byChannel: {
441+
// 2500 ms works for most setups; raise to 4000 ms if your Mac is slow
442+
// or under memory pressure (observed gap can stretch past 2 s then).
443+
bluebubbles: 2500,
444+
},
445+
},
446+
},
447+
}
448+
```
449+
450+
### Trade-offs
451+
452+
- **Added latency for DM control commands.** With the flag on, DM control-command messages (like `Dump`, `Save`, etc.) now wait up to the debounce window before dispatching, in case a payload webhook is coming. Group-chat commands keep instant dispatch.
453+
- **Merged output is bounded** — merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source `messageId` still reaches inbound-dedupe so a later MessagePoller replay of any individual event is recognized as a duplicate.
454+
- **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected.
455+
456+
### Scenarios and what the agent sees
457+
458+
| User composes | Apple delivers | Flag off (default) | Flag on + 2500 ms window |
459+
| ------------------------------------------------------------------ | ------------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
460+
| `Dump https://example.com` (one send) | 2 webhooks ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` |
461+
| `Save this 📎image.jpg caption` (attachment + text) | 2 webhooks | Two turns | One turn: text + image |
462+
| `/status` (standalone command) | 1 webhook | Instant dispatch | **Wait up to window, then dispatch** |
463+
| URL pasted alone | 1 webhook | Instant dispatch | Instant dispatch (only one entry in bucket) |
464+
| Text + URL sent as two deliberate separate messages, minutes apart | 2 webhooks outside window | Two turns | Two turns (window expires between them) |
465+
| Rapid flood (>10 small DMs inside window) | N webhooks | N turns | One turn, bounded output (first + latest, text/attachment caps applied) |
466+
467+
### Split-send coalescing troubleshooting
468+
469+
If the flag is on and split-sends still arrive as two turns, check each layer:
470+
471+
1. **Config actually loaded.**
472+
473+
```
474+
grep coalesceSameSenderDms ~/.openclaw/openclaw.json
475+
```
476+
477+
Then `openclaw gateway restart` — the flag is read at debouncer-registry creation.
478+
479+
2. **Debounce window wide enough for your setup.** Look at the BlueBubbles server log under `~/Library/Logs/bluebubbles-server/main.log`:
480+
481+
```
482+
grep -E "Dispatching event to webhook" main.log | tail -20
483+
```
484+
485+
Measure the gap between the `"Dump"`-style text dispatch and the `"https://..."; Attachments:` dispatch that follows. Raise `messages.inbound.byChannel.bluebubbles` to comfortably cover that gap.
486+
487+
3. **Session JSONL timestamps ≠ webhook arrival.** Session event timestamps (`~/.openclaw/agents/<id>/sessions/*.jsonl`) reflect when the gateway hands a message to the agent, **not** when the webhook arrived. A queued-second message tagged `[Queued messages while agent was busy]` means the first turn was still running when the second webhook arrived — the coalesce bucket had already flushed. Tune the window against the BB server log, not the session log.
488+
489+
4. **Memory pressure slowing reply dispatch.** On smaller machines (8 GB), agent turns can take long enough that the coalesce bucket flushes before the reply completes, and the URL lands as a queued second turn. Check `memory_pressure` and `ps -o rss -p $(pgrep openclaw-gateway)`; if the gateway is over ~500 MB RSS and the compressor is active, close other heavy processes or bump to a larger host.
490+
491+
5. **Reply-quote sends are a different path.** If the user tapped `Dump` as a **reply** to an existing URL-balloon (iMessage shows a "1 Reply" badge on the Dump bubble), the URL lives in `replyToBody`, not in a second webhook. Coalescing does not apply — that's a skill/prompt concern, not a debouncer concern.
492+
396493
## Block streaming
397494

398495
Control whether responses are sent as a single message or streamed in blocks:
@@ -436,6 +533,7 @@ Provider options:
436533
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
437534
- `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8).
438535
- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts.<accountId>.mediaLocalRoots`.
536+
- `channels.bluebubbles.coalesceSameSenderDms`: Merge consecutive same-sender DM webhooks into one agent turn so Apple's text+URL split-send arrives as a single message (default: `false`). See [Coalescing split-send DMs](#coalescing-split-send-dms-command--url-in-one-composition) for scenarios, window tuning, and trade-offs. Widens the default inbound debounce window from 500 ms to 2500 ms when enabled without an explicit `messages.inbound.byChannel.bluebubbles`.
439537
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
440538
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
441539
- `channels.bluebubbles.actions`: Enable/disable specific actions.
@@ -471,6 +569,7 @@ Prefer `chat_guid` for stable routing:
471569
- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes.
472570
- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync.
473571
- OpenClaw auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
572+
- `coalesceSameSenderDms` enabled but split-sends (e.g. `Dump` + URL) still arrive as two turns: see the [split-send coalescing troubleshooting](#split-send-coalescing-troubleshooting) checklist — common causes are too-tight debounce window, session-log timestamps misread as webhook arrival, or a reply-quote send (which uses `replyToBody`, not a second webhook).
474573
- For status/health info: `openclaw status --all` or `openclaw status --deep`.
475574

476575
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/tools/plugin) guide.

docs/concepts/messages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Config (global default + per-channel overrides):
6262
Notes:
6363

6464
- Debounce applies to **text-only** messages; media/attachments flush immediately.
65-
- Control commands bypass debouncing so they remain standalone.
65+
- Control commands bypass debouncing so they remain standalone**except** when a channel explicitly opts in to same-sender DM coalescing (e.g. [BlueBubbles `coalesceSameSenderDms`](/channels/bluebubbles#coalescing-split-send-dms-command--url-in-one-composition)), where DM commands wait inside the debounce window so a split-send payload can join the same agent turn.
6666

6767
## Sessions and devices
6868

extensions/bluebubbles/src/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ const bluebubblesAccountSchema = z
9494
catchup: bluebubblesCatchupSchema,
9595
blockStreaming: z.boolean().optional(),
9696
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
97+
coalesceSameSenderDms: z.boolean().optional(),
9798
})
9899
.superRefine((value, ctx) => {
99100
const serverUrl = value.serverUrl?.trim() ?? "";

extensions/bluebubbles/src/inbound-dedupe.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
22
import {
33
_resetBlueBubblesInboundDedupForTest,
44
claimBlueBubblesInboundMessage,
5+
commitBlueBubblesCoalescedMessageIds,
56
resolveBlueBubblesInboundDedupeKey,
67
} from "./inbound-dedupe.js";
78

@@ -58,6 +59,47 @@ describe("claimBlueBubblesInboundMessage", () => {
5859
});
5960
});
6061

62+
describe("commitBlueBubblesCoalescedMessageIds", () => {
63+
beforeEach(() => {
64+
_resetBlueBubblesInboundDedupForTest();
65+
});
66+
67+
it("marks every coalesced source messageId as seen so a later replay dedupes", async () => {
68+
// Primary was processed via claim+finalize by the debouncer flush.
69+
expect(await claimAndFinalize("primary", "acc")).toBe("claimed");
70+
// Secondaries reach dedupe through the bulk-commit path.
71+
await commitBlueBubblesCoalescedMessageIds({
72+
messageIds: ["secondary-1", "secondary-2"],
73+
accountId: "acc",
74+
});
75+
// A MessagePoller replay of any individual source event is now a duplicate
76+
// rather than a fresh agent turn — the core bug this helper exists to fix.
77+
expect(await claimAndFinalize("primary", "acc")).toBe("duplicate");
78+
expect(await claimAndFinalize("secondary-1", "acc")).toBe("duplicate");
79+
expect(await claimAndFinalize("secondary-2", "acc")).toBe("duplicate");
80+
});
81+
82+
it("scopes coalesced commits per account", async () => {
83+
await commitBlueBubblesCoalescedMessageIds({
84+
messageIds: ["g1"],
85+
accountId: "a",
86+
});
87+
// Same messageId under a different account is still claimable.
88+
expect(await claimAndFinalize("g1", "a")).toBe("duplicate");
89+
expect(await claimAndFinalize("g1", "b")).toBe("claimed");
90+
});
91+
92+
it("skips empty or overlong guids without throwing", async () => {
93+
await commitBlueBubblesCoalescedMessageIds({
94+
messageIds: ["", " ", "x".repeat(10_000), "valid"],
95+
accountId: "acc",
96+
});
97+
expect(await claimAndFinalize("valid", "acc")).toBe("duplicate");
98+
// Overlong guid was skipped by sanitization, not committed.
99+
expect(await claimAndFinalize("x".repeat(10_000), "acc")).toBe("skip");
100+
});
101+
});
102+
61103
describe("resolveBlueBubblesInboundDedupeKey", () => {
62104
it("returns messageId for new-message events", () => {
63105
expect(resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" })).toBe("msg-1");

extensions/bluebubbles/src/inbound-dedupe.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,35 @@ export async function claimBlueBubblesInboundMessage(params: {
210210
};
211211
}
212212

213+
/**
214+
* Mark a set of source messageIds as already processed, without going through
215+
* the `claim()` protocol. Intended for the coalesced-batch case: when the
216+
* debouncer merges N webhook events into one agent turn, only the primary
217+
* messageId reaches `claimBlueBubblesInboundMessage`. The remaining source
218+
* messageIds must still be remembered so a later MessagePoller replay of any
219+
* single source event is recognized as a duplicate rather than re-processed.
220+
*
221+
* Best-effort — disk errors on secondary commits are surfaced via
222+
* `onDiskError` but never thrown, so a single persistence hiccup cannot block
223+
* the caller's main finalize path.
224+
*/
225+
export async function commitBlueBubblesCoalescedMessageIds(params: {
226+
messageIds: readonly string[];
227+
accountId: string;
228+
onDiskError?: (error: unknown) => void;
229+
}): Promise<void> {
230+
for (const raw of params.messageIds) {
231+
const normalized = sanitizeGuid(raw);
232+
if (!normalized) {
233+
continue;
234+
}
235+
await impl.commit(normalized, {
236+
namespace: params.accountId,
237+
onDiskError: params.onDiskError,
238+
});
239+
}
240+
}
241+
213242
/**
214243
* Ensure the legacy→hashed dedupe file migration runs and the on-disk
215244
* store is warmed into memory for the given account. Call before any

0 commit comments

Comments
 (0)