diff --git a/extensions/imessage/src/monitor.plugin-payload.test.ts b/extensions/imessage/src/monitor.plugin-payload.test.ts index 07bc4ae716b0..a3bf0259c126 100644 --- a/extensions/imessage/src/monitor.plugin-payload.test.ts +++ b/extensions/imessage/src/monitor.plugin-payload.test.ts @@ -1,8 +1,22 @@ // Imessage tests cover monitor.plugin payload plugin behavior. +import path from "node:path"; +import * as channelInbound from "openclaw/plugin-sdk/channel-inbound"; +import { + addTestHook, + createEmptyPluginRegistry, + createTestInboundDebounceFlush, + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; +import type { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { createIMessageRpcClient } from "./client.js"; import { monitorIMessageProvider } from "./monitor.js"; +import { setCachedIMessagePrivateApiStatus } from "./private-api-status.js"; +import { getIMessageRuntime } from "./runtime.js"; import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js"; const waitForTransportReadyMock = vi.hoisted(() => @@ -10,6 +24,7 @@ const waitForTransportReadyMock = vi.hoisted(() => ); const createIMessageRpcClientMock = vi.hoisted(() => vi.fn()); const shouldDebounceTextInboundMock = vi.hoisted(() => vi.fn(() => false)); +const directDeliveryProof = vi.hoisted(() => ({ flush: false })); vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({ waitForTransportReady: waitForTransportReadyMock, @@ -20,10 +35,19 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { return { ...actual, createChannelInboundDebouncer: vi.fn( - (opts: { shouldDebounce: (entry: unknown) => boolean }) => ({ + (opts: { + shouldDebounce: (entry: unknown) => boolean; + onFlush: ( + entries: unknown[], + createFlush: typeof createTestInboundDebounceFlush, + ) => { completion: Promise }; + }) => ({ debouncer: { enqueue: async (entry: unknown) => { opts.shouldDebounce(entry); + if (directDeliveryProof.flush) { + await opts.onFlush([entry], createTestInboundDebounceFlush).completion; + } }, }, }), @@ -46,6 +70,12 @@ describe("iMessage plugin payload attachments", () => { waitForTransportReadyMock.mockReset().mockResolvedValue(undefined); createIMessageRpcClientMock.mockReset(); shouldDebounceTextInboundMock.mockReset().mockReturnValue(false); + directDeliveryProof.flush = false; + }); + + afterEach(() => { + resetGlobalHookRunner(); + vi.restoreAllMocks(); }); it("does not count Apple rich-link plugin payloads as user media", async () => { @@ -103,4 +133,161 @@ describe("iMessage plugin payload attachments", () => { }), ); }); + + it.each([ + { kind: "tool", text: "provider-visible tool result", visible: true }, + { kind: "block", text: "provider-visible streamed block", visible: true }, + { kind: "tool", text: "private reasoning", visible: false }, + ] as const)( + "settles direct $kind delivery through actual provider, hooks, and SQLite ($visible)", + async ({ kind, text, visible }) => { + directDeliveryProof.flush = true; + const messageSent = vi.fn(); + const registry = createEmptyPluginRegistry(); + addTestHook({ + registry, + pluginId: "imessage-monitor-proof", + hookName: "message_sent", + handler: messageSent, + }); + initializeGlobalHookRunner(registry); + setCachedIMessagePrivateApiStatus("imsg", { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: ["watch.subscribe", "send"], + }); + + const dispatch = vi.fn(async (params) => { + const settled = await params.dispatcherOptions.deliver({ text }, { kind }); + expect(settled).toMatchObject( + visible + ? { + visibleReplySent: true, + messageIds: [`native-${kind}-guid`], + receipt: { platformMessageIds: [`native-${kind}-guid`] }, + content: text, + } + : { visibleReplySent: false, suppression: { reason: "no_visible_result" } }, + ); + return { + queuedFinal: false, + counts: { tool: kind === "tool" ? 1 : 0, block: kind === "block" ? 1 : 0, final: 0 }, + }; + }); + const runActual = channelInbound.runChannelInboundEvent; + vi.spyOn(channelInbound, "runChannelInboundEvent").mockImplementation(async (params) => + runActual({ + ...params, + adapter: { + ...params.adapter, + resolveTurn: async (input, eventClass, preflight) => { + const turn = await params.adapter.resolveTurn(input, eventClass, preflight); + if (!("route" in turn) || !("delivery" in turn)) { + throw new Error("expected assembled iMessage delivery turn"); + } + const { route, ...resolvedTurn } = turn; + return { + ...resolvedTurn, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath: resolveStorePath(turn.cfg.session?.store, { agentId: route.agentId }), + recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: dispatch, + }; + }, + }, + }), + ); + + const nativeClient = { + request: vi.fn(async (method: string) => { + if (method !== "send") { + throw new Error(`unexpected native iMessage method ${method}`); + } + return { guid: `native-${kind}-guid`, status: "sent" }; + }), + stop: vi.fn(async () => {}), + }; + let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; + const watchClient = { + request: vi.fn(async () => ({ subscription: 1 })), + waitForClose: vi.fn(async () => { + onNotification?.({ + method: "message", + params: { + message: { + id: 91, + guid: `monitor-${kind}-${visible}-guid`, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "exercise actual direct delivery", + is_group: false, + created_at: new Date().toISOString(), + }, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }), + stop: vi.fn(async () => {}), + }; + createIMessageRpcClientMock.mockImplementation(async (params) => { + if (params?.onNotification) { + onNotification = params.onNotification; + return watchClient as never; + } + return nativeClient as never; + }); + + await monitorIMessageProvider({ + config: { + channels: { + imessage: { + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + sendReadReceipts: false, + }, + }, + messages: { inbound: { debounceMs: 0 } }, + session: { mainKey: "main" }, + } as never, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + }); + + if (!visible) { + expect(nativeClient.request).not.toHaveBeenCalled(); + expect(messageSent).not.toHaveBeenCalled(); + return; + } + await vi.waitFor(() => { + expect(messageSent).toHaveBeenCalledOnce(); + }); + expect(messageSent).toHaveBeenCalledWith( + expect.objectContaining({ content: text, success: true, messageId: `native-${kind}-guid` }), + expect.objectContaining({ channelId: "imessage" }), + ); + + const { DatabaseSync } = await import("node:sqlite"); + const database = new DatabaseSync( + path.join(getIMessageRuntime().state.resolveStateDir(), "state", "openclaw.sqlite"), + { readOnly: true }, + ); + try { + const persisted = database + .prepare( + "SELECT value_json FROM plugin_state_entries WHERE plugin_id = ? AND namespace = ?", + ) + .all("imessage", "imessage.sent-echoes"); + expect(persisted).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value_json: expect.stringContaining(`native-${kind}-guid`) }), + ]), + ); + } finally { + database.close(); + } + }, + ); }); diff --git a/extensions/imessage/src/monitor/deliver.test.ts b/extensions/imessage/src/monitor/deliver.test.ts index ea993387af63..f4da3f8d4dd8 100644 --- a/extensions/imessage/src/monitor/deliver.test.ts +++ b/extensions/imessage/src/monitor/deliver.test.ts @@ -1,4 +1,9 @@ // Imessage tests cover deliver plugin behavior. +import { + createChannelPartialDeliveryError, + isChannelPartialDeliveryError, +} from "openclaw/plugin-sdk/channel-inbound"; +import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -6,6 +11,10 @@ const sendMessageIMessageMock = vi.hoisted(() => vi.fn().mockImplementation(async (_to: string, message: string) => ({ messageId: "imsg-1", sentText: message, + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "imessage", messageId: "imsg-1" }], + kind: "text", + }), })), ); const chunkTextWithModeMock = vi.hoisted(() => vi.fn((text: string) => [text])); @@ -13,6 +22,13 @@ const resolveChunkModeMock = vi.hoisted(() => vi.fn(() => "length")); const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text)); const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "code")); +function createTestIMessageReceipt(messageId: string, kind: "text" | "media" = "text") { + return createMessageReceiptFromOutboundResults({ + results: [{ channel: "imessage", messageId }], + kind, + }); +} + vi.mock("../send.js", () => ({ sendMessageIMessage: (to: string, message: string, opts?: unknown) => sendMessageIMessageMock(to, message, opts), @@ -25,15 +41,15 @@ vi.mock("./deliver.runtime.js", () => ({ convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text), })); -let deliverReplies: typeof import("./deliver.js").deliverReplies; +let deliverIMessageReply: typeof import("./deliver.js").deliverIMessageReply; let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageEchoCachingSend; -describe("deliverReplies", () => { +describe("deliverIMessageReply", () => { const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } }; const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv; beforeAll(async () => { - ({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js")); + ({ createIMessageEchoCachingSend, deliverIMessageReply } = await import("./deliver.js")); }); beforeEach(() => { @@ -50,9 +66,9 @@ describe("deliverReplies", () => { it("sends monitor text chunks without reusing the watch rpc client", async () => { chunkTextWithModeMock.mockImplementation((text: string) => text.split("|")); - await deliverReplies({ + await deliverIMessageReply({ cfg: IMESSAGE_TEST_CFG, - replies: [{ text: "first|second", replyToId: "reply-1" }], + payload: { text: "first|second", replyToId: "reply-1" }, target: "chat_id:10", accountId: "default", runtime, @@ -86,15 +102,13 @@ describe("deliverReplies", () => { }); it("propagates payload replyToId through media sends", async () => { - await deliverReplies({ + await deliverIMessageReply({ cfg: IMESSAGE_TEST_CFG, - replies: [ - { - text: "caption", - mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"], - replyToId: "reply-2", - }, - ], + payload: { + text: "caption", + mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"], + replyToId: "reply-2", + }, target: "chat_id:20", accountId: "acct-2", runtime, @@ -130,9 +144,9 @@ describe("deliverReplies", () => { }); it("forwards voice-note payloads to the canonical iMessage media sender", async () => { - await deliverReplies({ + await deliverIMessageReply({ cfg: IMESSAGE_TEST_CFG, - replies: [{ mediaUrl: "https://example.com/voice.caf", audioAsVoice: true }], + payload: { mediaUrl: "https://example.com/voice.caf", audioAsVoice: true }, target: "chat_id:20", accountId: "acct-2", runtime, @@ -160,6 +174,7 @@ describe("deliverReplies", () => { sendMessageIMessageMock.mockResolvedValueOnce({ messageId: "imsg-durable-1", sentText: "durable hello", + receipt: createTestIMessageReceipt("imsg-durable-1"), }); await send("chat_id:50", "durable hello", { @@ -192,6 +207,7 @@ describe("deliverReplies", () => { sendMessageIMessageMock.mockResolvedValueOnce({ messageId: "imsg-durable-2", sentText: "Visible reply", + receipt: createTestIMessageReceipt("imsg-durable-2"), }); await send("chat_id:60", "hidden\nVisible reply\nassistant:", { @@ -221,12 +237,20 @@ describe("deliverReplies", () => { const remember = vi.fn(); chunkTextWithModeMock.mockImplementation((text: string) => text.split("|")); sendMessageIMessageMock - .mockResolvedValueOnce({ messageId: "imsg-1", sentText: "first" }) - .mockResolvedValueOnce({ messageId: "imsg-2", sentText: "second" }); + .mockResolvedValueOnce({ + messageId: "imsg-1", + sentText: "first", + receipt: createTestIMessageReceipt("imsg-1"), + }) + .mockResolvedValueOnce({ + messageId: "imsg-2", + sentText: "second", + receipt: createTestIMessageReceipt("imsg-2"), + }); - await deliverReplies({ + await deliverIMessageReply({ cfg: IMESSAGE_TEST_CFG, - replies: [{ text: "first|second" }], + payload: { text: "first|second" }, target: "chat_id:30", accountId: "acct-3", runtime, @@ -251,11 +275,12 @@ describe("deliverReplies", () => { messageId: "imsg-media-1", sentText: "", echoMedia: { contentType: "image/jpeg", kind: "image" }, + receipt: createTestIMessageReceipt("imsg-media-1", "media"), }); - await deliverReplies({ + await deliverIMessageReply({ cfg: IMESSAGE_TEST_CFG, - replies: [{ mediaUrls: ["https://example.com/a.jpg"] }], + payload: { mediaUrls: ["https://example.com/a.jpg"] }, target: "chat_id:40", accountId: "acct-4", runtime, @@ -269,4 +294,113 @@ describe("deliverReplies", () => { messageId: "imsg-media-1", }); }); + + it("returns every accepted native chunk without inventing failed thread metadata", async () => { + chunkTextWithModeMock.mockImplementation((text: string) => text.split("|")); + sendMessageIMessageMock + .mockResolvedValueOnce({ + messageId: "accepted-first", + sentText: "first", + receipt: createTestIMessageReceipt("accepted-first"), + }) + .mockResolvedValueOnce({ + messageId: "accepted-second", + sentText: "second", + receipt: createTestIMessageReceipt("accepted-second"), + }); + + const delivered = await deliverIMessageReply({ + cfg: IMESSAGE_TEST_CFG, + payload: { text: "first|second", replyToId: "unsupported-thread" }, + target: "chat_id:70", + accountId: "default", + runtime, + maxBytes: 4096, + textLimit: 4000, + }); + + expect(delivered).toMatchObject({ + visibleReplySent: true, + messageIds: ["accepted-first", "accepted-second"], + content: "first\nsecond", + receipt: { platformMessageIds: ["accepted-first", "accepted-second"] }, + }); + expect(delivered?.receipt).not.toHaveProperty("replyToId"); + }); + + it("preserves earlier media receipts when a later native caption fails", async () => { + const firstReceipt = createMessageReceiptFromOutboundResults({ + results: [ + { channel: "imessage", messageId: "first-attachment" }, + { channel: "imessage", messageId: "first-caption" }, + ], + kind: "media", + }); + const lastReceipt = createTestIMessageReceipt("second-attachment", "media"); + sendMessageIMessageMock + .mockResolvedValueOnce({ + messageId: "first-attachment", + sentText: "visible caption", + receipt: firstReceipt, + }) + .mockRejectedValueOnce( + createChannelPartialDeliveryError(new Error("caption rejected"), { + messageIds: ["second-attachment"], + receipt: lastReceipt, + visibleReplySent: true, + content: "", + }), + ); + + let observed: unknown; + try { + await deliverIMessageReply({ + cfg: IMESSAGE_TEST_CFG, + payload: { + text: "visible caption", + mediaUrls: ["https://example.com/first.jpg", "https://example.com/second.jpg"], + replyToId: "unsupported-thread", + }, + target: "chat_id:80", + accountId: "default", + runtime, + maxBytes: 4096, + textLimit: 4000, + }); + } catch (error: unknown) { + observed = error; + } + + expect(isChannelPartialDeliveryError(observed)).toBe(true); + if (!isChannelPartialDeliveryError(observed)) { + throw new Error("expected canonical partial delivery error"); + } + expect(observed.deliveryResult).toMatchObject({ + visibleReplySent: true, + messageIds: ["first-attachment", "first-caption", "second-attachment"], + content: "visible caption", + receipt: { + platformMessageIds: ["first-attachment", "first-caption", "second-attachment"], + }, + }); + expect(observed.deliveryResult.receipt).not.toHaveProperty("replyToId"); + }); + + it("returns a recorded non-visible outcome when sanitization removes the complete reply", async () => { + const delivered = await deliverIMessageReply({ + cfg: IMESSAGE_TEST_CFG, + payload: { text: "private reasoning" }, + target: "chat_id:90", + accountId: "default", + runtime, + maxBytes: 4096, + textLimit: 4000, + }); + + expect(sendMessageIMessageMock).not.toHaveBeenCalled(); + expect(delivered).toEqual({ + visibleReplySent: false, + suppression: { reason: "no_visible_result" }, + }); + }); }); diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index 377cb6978169..f5cf97701c20 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,4 +1,12 @@ // Imessage plugin module implements deliver behavior. +import { + createChannelPartialDeliveryError, + isChannelPartialDeliveryError, +} from "openclaw/plugin-sdk/channel-inbound"; +import { + createMessageReceiptFromOutboundResults, + listMessageReceiptPlatformIds, +} from "openclaw/plugin-sdk/channel-outbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { deliverTextOrMediaReply, @@ -16,9 +24,9 @@ import { import type { SentMessageCache } from "./echo-cache.js"; import { sanitizeOutboundText } from "./sanitize-outbound.js"; -export async function deliverReplies(params: { +export async function deliverIMessageReply(params: { cfg: OpenClawConfig; - replies: ReplyPayload[]; + payload: ReplyPayload; target: string; accountId?: string; runtime: RuntimeEnv; @@ -26,7 +34,7 @@ export async function deliverReplies(params: { textLimit: number; sentMessageCache?: Pick; }) { - const { replies, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params; + const { payload, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params; const scope = `${accountId ?? ""}:${target}`; const { cfg } = params; const tableMode = resolveMarkdownTableMode({ @@ -35,50 +43,81 @@ export async function deliverReplies(params: { accountId, }); const chunkMode = resolveChunkMode(cfg, "imessage", accountId); - for (const payload of replies) { - const rawText = sanitizeOutboundText(payload.text ?? ""); - const reply = resolveSendableOutboundReplyParts(payload, { - text: convertMarkdownTables(rawText, tableMode), + const rawText = sanitizeOutboundText(payload.text ?? ""); + const reply = resolveSendableOutboundReplyParts(payload, { + text: convertMarkdownTables(rawText, tableMode), + }); + const accepted: Awaited>[] = []; + const sendAccepted = async (text: string, mediaUrl?: string) => { + const sent = await sendMessageIMessage(target, text, { + config: cfg, + ...(mediaUrl ? { mediaUrl, ...(payload.audioAsVoice ? { audioAsVoice: true } : {}) } : {}), + maxBytes, + accountId, + replyToId: payload.replyToId, }); - const delivered = await deliverTextOrMediaReply({ + accepted.push(sent); + const echoText = sent.echoText ?? (sent.sentText || undefined); + sentMessageCache?.remember(scope, { + ...(echoText ? { text: echoText } : {}), + ...(sent.echoMedia ? { media: sent.echoMedia } : {}), + messageId: sent.messageId, + }); + }; + let delivered: Awaited>; + try { + delivered = await deliverTextOrMediaReply({ payload, text: reply.text, chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), - sendText: async (chunk) => { - const sent = await sendMessageIMessage(target, chunk, { - config: params.cfg, - maxBytes, - accountId, - replyToId: payload.replyToId, - }); - const echoText = sent.echoText ?? sent.sentText; - sentMessageCache?.remember(scope, { - ...(echoText ? { text: echoText } : {}), - ...(sent.echoMedia ? { media: sent.echoMedia } : {}), - messageId: sent.messageId, - }); - }, - sendMedia: async ({ mediaUrl, caption }) => { - const sent = await sendMessageIMessage(target, caption ?? "", { - config: params.cfg, - mediaUrl, - ...(payload.audioAsVoice ? { audioAsVoice: true } : {}), - maxBytes, - accountId, - replyToId: payload.replyToId, - }); - const echoText = sent.echoText ?? (sent.sentText || undefined); - sentMessageCache?.remember(scope, { - ...(echoText ? { text: echoText } : {}), - ...(sent.echoMedia ? { media: sent.echoMedia } : {}), - messageId: sent.messageId, - }); - }, + sendText: sendAccepted, + sendMedia: ({ mediaUrl, caption }) => sendAccepted(caption ?? "", mediaUrl), }); - if (delivered !== "empty") { - runtime.log?.(`imessage: delivered reply to ${target}`); + } catch (error: unknown) { + const partial = isChannelPartialDeliveryError(error) ? error.deliveryResult : undefined; + if (accepted.length === 0 && partial?.visibleReplySent !== true) { + throw error; } + // A native attachment can settle before its caption rejects; preserve every + // previously accepted receipt plus that nested provider-visible subset. + const receipt = createMessageReceiptFromOutboundResults({ + results: [ + ...accepted.map((result) => ({ receipt: result.receipt })), + ...(partial?.receipt + ? [{ receipt: partial.receipt }] + : (partial?.messageIds ?? []).map((messageId) => ({ messageId }))), + ], + kind: reply.mediaUrls.length > 0 ? "media" : "text", + }); + throw createChannelPartialDeliveryError(error, { + messageIds: listMessageReceiptPlatformIds(receipt), + receipt, + visibleReplySent: true, + content: [...accepted.map((result) => result.sentText), partial?.content] + .filter(Boolean) + .join("\n"), + }); } + if (delivered === "empty") { + return { + visibleReplySent: false as const, + suppression: { reason: "no_visible_result" as const }, + }; + } + const receipt = createMessageReceiptFromOutboundResults({ + results: accepted.map((result) => ({ receipt: result.receipt })), + kind: delivered, + }); + runtime.log?.(`imessage: delivered reply to ${target}`); + return { + messageIds: listMessageReceiptPlatformIds(receipt), + receipt, + visibleReplySent: true as const, + content: accepted + .map((result) => result.sentText) + .filter(Boolean) + .join("\n"), + }; } export function createIMessageEchoCachingSend(params: { diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 73e847d751eb..bec966ada7c1 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -78,7 +78,7 @@ import { runIMessageCatchup } from "./catchup-bridge.js"; import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js"; import { combineIMessagePayloads } from "./coalesce.js"; import { repairIMessageConversationAnchor } from "./conversation-repair.js"; -import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js"; +import { createIMessageEchoCachingSend, deliverIMessageReply } from "./deliver.js"; import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js"; import { createIMessageThrottledDropDiagnosticCache } from "./drop-diagnostic-cache.js"; import { createSentMessageCache } from "./echo-cache.js"; @@ -1163,15 +1163,19 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, } : false, - deliver: async (payload: Parameters[0]["replies"][number]) => { + observeMessageSent: true, + deliver: async (payload: Parameters[0]["payload"]) => { const target = ctxPayload.To; if (!target) { runtime.error?.(danger("imessage: missing delivery target")); - return; + return { + visibleReplySent: false, + suppression: { reason: "no_visible_result" }, + } as const; } - await deliverReplies({ + return await deliverIMessageReply({ cfg, - replies: [payload], + payload, target, accountId: accountInfo.accountId, runtime,