@@ -6,8 +6,21 @@ import {
66 resolveBlueBubblesInboundDedupeKey ,
77} from "./inbound-dedupe.js" ;
88
9- async function claimAndFinalize ( guid : string | undefined , accountId : string ) : Promise < string > {
10- const claim = await claimBlueBubblesInboundMessage ( { guid, accountId } ) ;
9+ type TestMessage = Parameters < typeof claimBlueBubblesInboundMessage > [ 0 ] [ "message" ] ;
10+
11+ function newMessage ( messageId : string | undefined ) : TestMessage {
12+ return { messageId, eventType : "new-message" } ;
13+ }
14+
15+ function updatedMessage (
16+ messageId : string | undefined ,
17+ attachments : TestMessage [ "attachments" ] = [ ] ,
18+ ) : TestMessage {
19+ return { messageId, eventType : "updated-message" , attachments } ;
20+ }
21+
22+ async function claimAndFinalize ( message : TestMessage , accountId : string ) : Promise < string > {
23+ const claim = await claimBlueBubblesInboundMessage ( { message, accountId } ) ;
1124 if ( claim . kind === "claimed" ) {
1225 await claim . finalize ( ) ;
1326 }
@@ -20,42 +33,85 @@ describe("claimBlueBubblesInboundMessage", () => {
2033 } ) ;
2134
2235 it ( "claims a new guid and rejects committed duplicates" , async ( ) => {
23- expect ( await claimAndFinalize ( "g1" , "acc" ) ) . toBe ( "claimed" ) ;
24- expect ( await claimAndFinalize ( "g1" , "acc" ) ) . toBe ( "duplicate" ) ;
36+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "acc" ) ) . toBe ( "claimed" ) ;
37+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "acc" ) ) . toBe ( "duplicate" ) ;
2538 } ) ;
2639
2740 it ( "scopes dedupe per account" , async ( ) => {
28- expect ( await claimAndFinalize ( "g1" , "a" ) ) . toBe ( "claimed" ) ;
29- expect ( await claimAndFinalize ( "g1" , "b" ) ) . toBe ( "claimed" ) ;
41+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "a" ) ) . toBe ( "claimed" ) ;
42+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "b" ) ) . toBe ( "claimed" ) ;
3043 } ) ;
3144
3245 it ( "reports skip when guid is missing or blank" , async ( ) => {
33- expect ( ( await claimBlueBubblesInboundMessage ( { guid : undefined , accountId : "acc" } ) ) . kind ) . toBe (
34- "skip" ,
35- ) ;
36- expect ( ( await claimBlueBubblesInboundMessage ( { guid : "" , accountId : "acc" } ) ) . kind ) . toBe (
37- "skip" ,
38- ) ;
39- expect ( ( await claimBlueBubblesInboundMessage ( { guid : " " , accountId : "acc" } ) ) . kind ) . toBe (
40- "skip" ,
41- ) ;
46+ expect (
47+ ( await claimBlueBubblesInboundMessage ( { message : newMessage ( undefined ) , accountId : "acc" } ) )
48+ . kind ,
49+ ) . toBe ( "skip" ) ;
50+ expect (
51+ ( await claimBlueBubblesInboundMessage ( { message : newMessage ( "" ) , accountId : "acc" } ) ) . kind ,
52+ ) . toBe ( "skip" ) ;
53+ expect (
54+ ( await claimBlueBubblesInboundMessage ( { message : newMessage ( " " ) , accountId : "acc" } ) ) . kind ,
55+ ) . toBe ( "skip" ) ;
4256 } ) ;
4357
4458 it ( "rejects overlong guids to cap on-disk size" , async ( ) => {
4559 const huge = "x" . repeat ( 10_000 ) ;
46- expect ( ( await claimBlueBubblesInboundMessage ( { guid : huge , accountId : "acc" } ) ) . kind ) . toBe (
47- "skip" ,
48- ) ;
60+ expect (
61+ ( await claimBlueBubblesInboundMessage ( { message : newMessage ( huge ) , accountId : "acc" } ) ) . kind ,
62+ ) . toBe ( "skip" ) ;
4963 } ) ;
5064
5165 it ( "releases the claim so a later replay can retry after a transient failure" , async ( ) => {
52- const first = await claimBlueBubblesInboundMessage ( { guid : "g1" , accountId : "acc" } ) ;
66+ const first = await claimBlueBubblesInboundMessage ( {
67+ message : newMessage ( "g1" ) ,
68+ accountId : "acc" ,
69+ } ) ;
5370 expect ( first . kind ) . toBe ( "claimed" ) ;
5471 if ( first . kind === "claimed" ) {
5572 first . release ( ) ;
5673 }
5774 // Released claims should be re-claimable on the next delivery.
58- expect ( await claimAndFinalize ( "g1" , "acc" ) ) . toBe ( "claimed" ) ;
75+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "acc" ) ) . toBe ( "claimed" ) ;
76+ } ) ;
77+
78+ it ( "treats no-attachment updated-message follow-ups as duplicates once the base GUID committed" , async ( ) => {
79+ // Original new-message: agent processes and replies, base GUID gets committed.
80+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "acc" ) ) . toBe ( "claimed" ) ;
81+ // Follow-up updated-message with no attachments for the same GUID: even
82+ // though `g1:updated` has never been claimed, the base commit is enough to
83+ // recognize replay noise so it cannot re-trigger a reply (especially after
84+ // losing group chat context).
85+ expect ( await claimAndFinalize ( updatedMessage ( "g1" ) , "acc" ) ) . toBe ( "duplicate" ) ;
86+ } ) ;
87+
88+ it ( "preserves late attachment-bearing updated-message processing after the base committed" , async ( ) => {
89+ // Attachment indexing can arrive after the initial text-only event; this
90+ // path must stay claimable even when the new-message base GUID committed.
91+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "acc" ) ) . toBe ( "claimed" ) ;
92+ expect (
93+ await claimAndFinalize (
94+ updatedMessage ( "g1" , [ { guid : "att-1" , mimeType : "image/png" } ] ) ,
95+ "acc" ,
96+ ) ,
97+ ) . toBe ( "claimed" ) ;
98+ expect (
99+ await claimAndFinalize (
100+ updatedMessage ( "g1" , [ { guid : "att-1" , mimeType : "image/png" } ] ) ,
101+ "acc" ,
102+ ) ,
103+ ) . toBe ( "duplicate" ) ;
104+ } ) ;
105+
106+ it ( "lets an updated-message-first webhook through when the base GUID was never committed" , async ( ) => {
107+ // Rare case: BlueBubbles delivers only the updated-message webhook (e.g.
108+ // attachment-only path with no preceding new-message). Without a prior
109+ // base commit, the suffixed key proceeds normally so the agent still sees
110+ // the message.
111+ expect ( await claimAndFinalize ( updatedMessage ( "g1" ) , "acc" ) ) . toBe ( "claimed" ) ;
112+ // A subsequent updated-message with the same GUID is a duplicate via the
113+ // standard `:updated` key dedupe.
114+ expect ( await claimAndFinalize ( updatedMessage ( "g1" ) , "acc" ) ) . toBe ( "duplicate" ) ;
59115 } ) ;
60116} ) ;
61117
@@ -66,17 +122,17 @@ describe("commitBlueBubblesCoalescedMessageIds", () => {
66122
67123 it ( "marks every coalesced source messageId as seen so a later replay dedupes" , async ( ) => {
68124 // Primary was processed via claim+finalize by the debouncer flush.
69- expect ( await claimAndFinalize ( "primary" , "acc" ) ) . toBe ( "claimed" ) ;
125+ expect ( await claimAndFinalize ( newMessage ( "primary" ) , "acc" ) ) . toBe ( "claimed" ) ;
70126 // Secondaries reach dedupe through the bulk-commit path.
71127 await commitBlueBubblesCoalescedMessageIds ( {
72128 messageIds : [ "secondary-1" , "secondary-2" ] ,
73129 accountId : "acc" ,
74130 } ) ;
75131 // A MessagePoller replay of any individual source event is now a duplicate
76132 // 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" ) ;
133+ expect ( await claimAndFinalize ( newMessage ( "primary" ) , "acc" ) ) . toBe ( "duplicate" ) ;
134+ expect ( await claimAndFinalize ( newMessage ( "secondary-1" ) , "acc" ) ) . toBe ( "duplicate" ) ;
135+ expect ( await claimAndFinalize ( newMessage ( "secondary-2" ) , "acc" ) ) . toBe ( "duplicate" ) ;
80136 } ) ;
81137
82138 it ( "scopes coalesced commits per account" , async ( ) => {
@@ -85,18 +141,18 @@ describe("commitBlueBubblesCoalescedMessageIds", () => {
85141 accountId : "a" ,
86142 } ) ;
87143 // 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" ) ;
144+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "a" ) ) . toBe ( "duplicate" ) ;
145+ expect ( await claimAndFinalize ( newMessage ( "g1" ) , "b" ) ) . toBe ( "claimed" ) ;
90146 } ) ;
91147
92148 it ( "skips empty or overlong guids without throwing" , async ( ) => {
93149 await commitBlueBubblesCoalescedMessageIds ( {
94150 messageIds : [ "" , " " , "x" . repeat ( 10_000 ) , "valid" ] ,
95151 accountId : "acc" ,
96152 } ) ;
97- expect ( await claimAndFinalize ( "valid" , "acc" ) ) . toBe ( "duplicate" ) ;
153+ expect ( await claimAndFinalize ( newMessage ( "valid" ) , "acc" ) ) . toBe ( "duplicate" ) ;
98154 // Overlong guid was skipped by sanitization, not committed.
99- expect ( await claimAndFinalize ( "x" . repeat ( 10_000 ) , "acc" ) ) . toBe ( "skip" ) ;
155+ expect ( await claimAndFinalize ( newMessage ( "x" . repeat ( 10_000 ) ) , "acc" ) ) . toBe ( "skip" ) ;
100156 } ) ;
101157} ) ;
102158
0 commit comments