From b0b42b4e14ffa8fcccfe95d523a559e30a913b2b Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 20 Jan 2026 01:34:51 -0800 Subject: [PATCH] feat: improve BlueBubbles message processing by adding reply context formatting and enhancing message ID extraction from responses --- extensions/bluebubbles/src/monitor.test.ts | 2 ++ extensions/bluebubbles/src/monitor.ts | 19 ++++++++++- extensions/bluebubbles/src/send.test.ts | 32 +++++++++++++++++++ extensions/bluebubbles/src/send.ts | 8 ++++- src/agents/pi-embedded-runner/run/payloads.ts | 14 ++++---- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 96592edbe..03fdc4efc 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1078,6 +1078,8 @@ describe("BlueBubbles webhook monitor", () => { expect(callArgs.ctx.ReplyToId).toBe("msg-0"); expect(callArgs.ctx.ReplyToBody).toBe("original message"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); + expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]"); + expect(callArgs.ctx.Body).toContain("original message"); }); }); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 9936950e9..05b28139f 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -216,6 +216,21 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { return ""; } +function formatReplyContext(message: { + replyToId?: string; + replyToBody?: string; + replyToSender?: string; +}): string | null { + if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null; + const sender = message.replyToSender?.trim() || "unknown sender"; + const idPart = message.replyToId ? ` id:${message.replyToId}` : ""; + const body = message.replyToBody?.trim(); + if (!body) { + return `[Replying to ${sender}${idPart}]\n[/Replying]`; + } + return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`; +} + function readNumberLike(record: Record | null, key: string): number | undefined { if (!record) return undefined; const value = record[key]; @@ -1178,6 +1193,8 @@ async function processMessage( } } const rawBody = text.trim() || placeholder; + const replyContext = formatReplyContext(message); + const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody; const fromLabel = isGroup ? `group:${peerId}` : message.senderName || `user:${message.senderId}`; @@ -1202,7 +1219,7 @@ async function processMessage( timestamp: message.timestamp, previousTimestamp, envelope: envelopeOptions, - body: rawBody, + body: baseBody, }); let chatGuidForActions = chatGuid; if (!chatGuidForActions && baseUrl && password) { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 94db776ca..f39abdd5e 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -589,6 +589,38 @@ describe("send", () => { expect(result.messageId).toBe("numeric-id-456"); }); + it("extracts messageGuid from response payload", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { messageGuid: "msg-guid-789" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-guid-789"); + }); + it("resolves credentials from config", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 2ef30cb7a..868184c42 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -86,12 +86,18 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget { function extractMessageId(payload: unknown): string { if (!payload || typeof payload !== "object") return "unknown"; const record = payload as Record; - const data = record.data && typeof record.data === "object" ? (record.data as Record) : null; + const data = + record.data && typeof record.data === "object" ? (record.data as Record) : null; const candidates = [ record.messageId, + record.messageGuid, + record.message_guid, record.guid, record.id, data?.messageId, + data?.messageGuid, + data?.message_guid, + data?.message_id, data?.guid, data?.id, ]; diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 7f602bbe2..b59315683 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -156,9 +156,10 @@ export function buildEmbeddedRunPayloads(params: { }); } - if (replyItems.length === 0 && params.lastToolError) { - // Check if this is a recoverable/internal tool error that shouldn't be shown to users. - // These include parameter validation errors that the model should have retried. + if (params.lastToolError) { + const hasUserFacingReply = replyItems.length > 0; + // Check if this is a recoverable/internal tool error that shouldn't be shown to users + // when there's already a user-facing reply (the model should have retried). const errorLower = (params.lastToolError.error ?? "").toLowerCase(); const isRecoverableError = errorLower.includes("required") || @@ -169,8 +170,7 @@ export function buildEmbeddedRunPayloads(params: { errorLower.includes("needs") || errorLower.includes("requires"); - // Only show non-recoverable errors to users - if (!isRecoverableError) { + if (!hasUserFacingReply || !isRecoverableError) { const toolSummary = formatToolAggregate( params.lastToolError.toolName, params.lastToolError.meta ? [params.lastToolError.meta] : undefined, @@ -182,8 +182,8 @@ export function buildEmbeddedRunPayloads(params: { isError: true, }); } - // Note: Recoverable errors are already in the model's context as tool_result is_error, - // so the model can see them and should retry. We just don't send them to the user. + // Note: Recoverable errors are already in the model's context as tool_result is_error. + // We only suppress them when a user-facing reply already exists. } const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);