diff --git a/extensions/imessage/src/monitor.gating.test-support.ts b/extensions/imessage/src/monitor.gating.test-support.ts index a5b03a9fb74e..7b9fc2b9cd6c 100644 --- a/extensions/imessage/src/monitor.gating.test-support.ts +++ b/extensions/imessage/src/monitor.gating.test-support.ts @@ -198,27 +198,36 @@ describe("imessage monitor gating + envelope builders", () => { expect(ctxPayload.MessageSidFull).toBe("full-message-guid"); }); - it("includes reply-to context fields + suffix", async () => { - const cfg = baseCfg(); - const message: IMessagePayload = { - id: 5, - chat_id: 55, - sender: "+15550001111", - is_from_me: false, - text: "replying now", - is_group: false, - reply_to_id: 9001, - reply_to_text: "original message", - reply_to_sender: "+15559998888", - }; - const ctxPayload = await buildDispatchContextPayload({ cfg, message }); + it.each([ + { parent: { reply_to_guid: "reply-parent" }, expected: "reply-parent" }, + { parent: { thread_originator_guid: "thread-parent" }, expected: "thread-parent" }, + { + parent: { thread_originator_guid: "thread-parent", reply_to_guid: "reply-parent" }, + expected: "thread-parent", + }, + ])( + "includes the authoritative reply parent, context fields, and suffix", + async ({ parent, expected }) => { + const message: IMessagePayload = { + id: 5, + chat_id: 55, + sender: "+15550001111", + is_from_me: false, + text: "replying now", + is_group: false, + ...parent, + reply_to_text: "original message", + reply_to_sender: "+15559998888", + }; + const ctxPayload = await buildDispatchContextPayload({ cfg: baseCfg(), message }); - expect(ctxPayload.ReplyToId).toBe("9001"); - expect(ctxPayload.ReplyToBody).toBe("original message"); - expect(ctxPayload.ReplyToSender).toBe("+15559998888"); - expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]"); - expect(ctxPayload.Body ?? "").toContain("original message"); - }); + expect(ctxPayload.ReplyToId).toBe(expected); + expect(ctxPayload.ReplyToBody).toBe("original message"); + expect(ctxPayload.ReplyToSender).toBe("+15559998888"); + expect(ctxPayload.Body ?? "").toContain(`[Replying to +15559998888 id:${expected}]`); + expect(ctxPayload.Body ?? "").toContain("original message"); + }, + ); it("drops group reply context from non-allowlisted senders in allowlist mode", async () => { const cfg = baseCfg(); @@ -234,7 +243,7 @@ describe("imessage monitor gating + envelope builders", () => { is_from_me: false, text: "@openclaw replying now", is_group: true, - reply_to_id: 9001, + reply_to_guid: "parent-9001", reply_to_text: "blocked quote", reply_to_sender: "+15559998888", }; @@ -266,7 +275,7 @@ describe("imessage monitor gating + envelope builders", () => { is_from_me: false, text: "@openclaw replying now", is_group: true, - reply_to_id: 9001, + reply_to_guid: "parent-9001", reply_to_text: "quoted context", reply_to_sender: "+15559998888", }; @@ -278,10 +287,10 @@ describe("imessage monitor gating + envelope builders", () => { groupPolicy: "allowlist", }); - expect(ctxPayload.ReplyToId).toBe("9001"); + expect(ctxPayload.ReplyToId).toBe("parent-9001"); expect(ctxPayload.ReplyToBody).toBe("quoted context"); expect(ctxPayload.ReplyToSender).toBe("+15559998888"); - expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]"); + expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:parent-9001]"); }); it("keeps group reply context when the group allowlist matches an access group", async () => { @@ -304,7 +313,7 @@ describe("imessage monitor gating + envelope builders", () => { is_from_me: false, text: "@openclaw replying now", is_group: true, - reply_to_id: 9002, + reply_to_guid: "parent-9002", reply_to_text: "own quoted context", reply_to_sender: "+15559998888", }; @@ -316,10 +325,10 @@ describe("imessage monitor gating + envelope builders", () => { groupPolicy: "allowlist", }); - expect(ctxPayload.ReplyToId).toBe("9002"); + expect(ctxPayload.ReplyToId).toBe("parent-9002"); expect(ctxPayload.ReplyToBody).toBe("own quoted context"); expect(ctxPayload.ReplyToSender).toBe("+15559998888"); - expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9002]"); + expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:parent-9002]"); }); it("keeps group reply context in allowlist_quote mode", async () => { @@ -336,7 +345,7 @@ describe("imessage monitor gating + envelope builders", () => { is_from_me: false, text: "@openclaw replying now", is_group: true, - reply_to_id: 9001, + reply_to_guid: "parent-9001", reply_to_text: "quoted context", reply_to_sender: "+15559998888", }; @@ -348,10 +357,10 @@ describe("imessage monitor gating + envelope builders", () => { groupPolicy: "allowlist", }); - expect(ctxPayload.ReplyToId).toBe("9001"); + expect(ctxPayload.ReplyToId).toBe("parent-9001"); expect(ctxPayload.ReplyToBody).toBe("quoted context"); expect(ctxPayload.ReplyToSender).toBe("+15559998888"); - expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]"); + expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:parent-9001]"); }); it("treats configured chat_id as a group session even when is_group is false", async () => { diff --git a/extensions/imessage/src/monitor/coalesce.test-support.ts b/extensions/imessage/src/monitor/coalesce.test-support.ts index 12504be4d914..ee1e6c653a60 100644 --- a/extensions/imessage/src/monitor/coalesce.test-support.ts +++ b/extensions/imessage/src/monitor/coalesce.test-support.ts @@ -129,17 +129,63 @@ describe("combineIMessagePayloads", () => { const reply = makePayload({ text: "follow-up", guid: "row-2", - reply_to_id: "parent-msg", + reply_to_guid: "parent-msg", reply_to_text: "earlier", reply_to_sender: "+15555550199", }); const merged = combineIMessagePayloads([noReply, reply]); - expect(merged.reply_to_id).toBe("parent-msg"); + expect(merged.reply_to_guid).toBe("parent-msg"); expect(merged.reply_to_text).toBe("earlier"); expect(merged.reply_to_sender).toBe("+15555550199"); }); + it.each([ + { reply_to_guid: "reply-parent" }, + { thread_originator_guid: "thread-parent" }, + { reply_to_guid: "reply-parent", thread_originator_guid: "thread-parent" }, + ])("preserves the complete real provider reply tuple from a later row", (parent) => { + const first = makePayload({ text: "hello", guid: "row-1" }); + const reply = makePayload({ + text: "follow-up", + guid: "row-2", + ...parent, + reply_to_text: "the original question", + reply_to_sender: "+15555550199", + }); + const merged = combineIMessagePayloads([first, reply]); + + expect(merged).toMatchObject({ + ...parent, + reply_to_text: "the original question", + reply_to_sender: "+15555550199", + }); + }); + + it("keeps the parent GUID and quote metadata from the same reply row", () => { + const first = makePayload({ + text: "first", + guid: "row-1", + reply_to_text: "unrelated stale quote", + reply_to_sender: "+15555550001", + }); + const reply = makePayload({ + text: "second", + guid: "row-2", + thread_originator_guid: "thread-parent", + reply_to_guid: "reply-parent", + reply_to_text: "actual parent question", + reply_to_sender: "+15555550199", + }); + + expect(combineIMessagePayloads([first, reply])).toMatchObject({ + thread_originator_guid: "thread-parent", + reply_to_guid: "reply-parent", + reply_to_text: "actual parent question", + reply_to_sender: "+15555550199", + }); + }); + it("does not set coalescedMessageGuids when no entry carries a GUID", () => { const a = makePayload({ text: "a", guid: null }); const b = makePayload({ text: "b", guid: null }); diff --git a/extensions/imessage/src/monitor/coalesce.ts b/extensions/imessage/src/monitor/coalesce.ts index abd763c5f9dd..222b33f3700b 100644 --- a/extensions/imessage/src/monitor/coalesce.ts +++ b/extensions/imessage/src/monitor/coalesce.ts @@ -120,17 +120,21 @@ export function combineIMessagePayloads(payloads: IMessagePayload[]): CoalescedI coalescedMessageGuids.push(guid); } - // Reply context: prefer any entry that carries one. - const entryWithReply = payloads.find((p) => p.reply_to_id != null); + // Keep both parent GUIDs and their quote fields attached to the same source. + const reply = + payloads.find( + (payload) => payload.thread_originator_guid != null || payload.reply_to_guid != null, + ) ?? first; return { ...first, text: combinedText, attachments: allAttachments.length > 0 ? allAttachments : null, created_at: latestCreatedAt, - reply_to_id: entryWithReply?.reply_to_id ?? first.reply_to_id ?? null, - reply_to_text: entryWithReply?.reply_to_text ?? first.reply_to_text ?? null, - reply_to_sender: entryWithReply?.reply_to_sender ?? first.reply_to_sender ?? null, + thread_originator_guid: reply.thread_originator_guid ?? null, + reply_to_guid: reply.reply_to_guid ?? null, + reply_to_text: reply.reply_to_text ?? null, + reply_to_sender: reply.reply_to_sender ?? null, coalescedMessageGuids: coalescedMessageGuids.length > 0 ? coalescedMessageGuids : undefined, coalescedCatchupCursor: Number.isFinite(maxRowid) && Number.isFinite(maxDateMs) diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 0568e9774058..c16586266fc5 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -183,7 +183,9 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext | if (!body) { return null; } - const id = normalizeReplyField(message.reply_to_id); + const id = + normalizeReplyField(message.thread_originator_guid) ?? + normalizeReplyField(message.reply_to_guid); const sender = normalizeReplyField(message.reply_to_sender); return { body, id, sender }; } diff --git a/extensions/imessage/src/monitor/parse-notification.test-support.ts b/extensions/imessage/src/monitor/parse-notification.test-support.ts index 74d00cc26569..59346ceee562 100644 --- a/extensions/imessage/src/monitor/parse-notification.test-support.ts +++ b/extensions/imessage/src/monitor/parse-notification.test-support.ts @@ -15,7 +15,7 @@ describe("parseIMessageNotification", () => { destination_caller_id: null, is_from_me: false, text: wrappedText, - reply_to_id: null, + reply_to_guid: null, reply_to_text: wrappedReply, reply_to_sender: null, created_at: null, @@ -68,6 +68,34 @@ describe("parseIMessageNotification", () => { expect(parsed?.reacted_to_guid).toBe("target-guid"); }); + it("preserves the provider's thread-originator and direct-reply GUIDs", () => { + const parsed = parseIMessageNotification({ + message: { + guid: "message-guid", + thread_originator_guid: "thread-parent", + reply_to_guid: "reply-parent", + reply_to_text: "parent question", + reply_to_sender: "+10000000000", + }, + }); + + expect(parsed).toMatchObject({ + thread_originator_guid: "thread-parent", + reply_to_guid: "reply-parent", + reply_to_text: "parent question", + reply_to_sender: "+10000000000", + }); + }); + + it.each([42, true, {}, ["thread-parent"]])( + "rejects malformed provider thread-originator GUIDs", + (threadOriginatorGuid) => { + expect( + parseIMessageNotification({ message: { thread_originator_guid: threadOriginatorGuid } }), + ).toBeNull(); + }, + ); + it("accepts iMessage attachment transfer_name and uti metadata", () => { const parsed = parseIMessageNotification({ message: { diff --git a/extensions/imessage/src/monitor/parse-notification.ts b/extensions/imessage/src/monitor/parse-notification.ts index 9347366e000d..2a59d5065e4c 100644 --- a/extensions/imessage/src/monitor/parse-notification.ts +++ b/extensions/imessage/src/monitor/parse-notification.ts @@ -7,12 +7,6 @@ function isOptionalString(value: unknown): value is string | null | undefined { return value === undefined || value === null || typeof value === "string"; } -function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined { - return ( - value === undefined || value === null || typeof value === "string" || typeof value === "number" - ); -} - function isOptionalNumber(value: unknown): value is number | null | undefined { return value === undefined || value === null || typeof value === "number"; } @@ -68,7 +62,7 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null !isOptionalString(message.destination_caller_id) || !isOptionalBoolean(message.is_from_me) || !isOptionalString(message.text) || - !isOptionalStringOrNumber(message.reply_to_id) || + !isOptionalString(message.thread_originator_guid) || !isOptionalString(message.reply_to_guid) || !isOptionalString(message.reply_to_text) || !isOptionalString(message.reply_to_sender) || diff --git a/extensions/imessage/src/monitor/types.ts b/extensions/imessage/src/monitor/types.ts index 3fe5c8416d4b..332d23a97615 100644 --- a/extensions/imessage/src/monitor/types.ts +++ b/extensions/imessage/src/monitor/types.ts @@ -46,10 +46,8 @@ export type IMessagePayload = { destination_caller_id?: string | null; is_from_me?: boolean | null; text?: string | null; - reply_to_id?: number | string | null; - // imsg emits the replied-to message's GUID here (its inbound events carry - // `reply_to_guid`, not a numeric `reply_to_id`); the poll-comment fold matches - // a caption's `reply_to_guid` against the poll balloon's guid. + // Thread origin owns quote identity; direct reply GUID also links poll captions. + thread_originator_guid?: string | null; reply_to_guid?: string | null; reply_to_text?: string | null; reply_to_sender?: string | null;