@@ -256,6 +256,183 @@ describe("iMessage monitor last-route updates", () => {
256256 } ) ;
257257 } ) ;
258258
259+ it ( "keeps direct progress options when imsg lacks native typing support" , async ( ) => {
260+ setCachedIMessagePrivateApiStatus ( "imsg" , {
261+ available : true ,
262+ v2Ready : true ,
263+ selectors : { } ,
264+ rpcMethods : [ "watch.subscribe" , "send" , "read" ] ,
265+ } ) ;
266+ dispatchInboundMessageMock . mockImplementationOnce ( async ( params ) => {
267+ expect ( params . replyOptions ?. suppressDefaultToolProgressMessages ) . toBe ( true ) ;
268+ expect ( params . replyOptions ?. allowProgressCallbacksWhenSourceDeliverySuppressed ) . toBe ( true ) ;
269+ expect ( params . replyOptions ?. onToolStart ) . toBeUndefined ( ) ;
270+ return { queuedFinal : false , counts : { tool : 0 , block : 0 , final : 0 } } as const ;
271+ } ) ;
272+
273+ let onNotification : ( ( message : { method : string ; params : unknown } ) => void ) | undefined ;
274+ const client = {
275+ request : vi . fn ( async ( method : string ) => {
276+ if ( method === "watch.subscribe" ) {
277+ return { subscription : 1 } ;
278+ }
279+ if ( method === "typing" ) {
280+ throw new Error ( "typing should not start without native typing support" ) ;
281+ }
282+ throw new Error ( `unexpected imsg method ${ method } ` ) ;
283+ } ) ,
284+ waitForClose : vi . fn ( async ( ) => {
285+ onNotification ?.( {
286+ method : "message" ,
287+ params : {
288+ message : {
289+ id : 13 ,
290+ chat_id : 123 ,
291+ sender : "+15550001111" ,
292+ is_from_me : false ,
293+ text : "run a long script without native typing" ,
294+ is_group : false ,
295+ created_at : new Date ( ) . toISOString ( ) ,
296+ } ,
297+ } ,
298+ } ) ;
299+ await Promise . resolve ( ) ;
300+ await Promise . resolve ( ) ;
301+ } ) ,
302+ stop : vi . fn ( async ( ) => { } ) ,
303+ } ;
304+ createIMessageRpcClientMock . mockImplementation ( async ( params ) => {
305+ if ( ! params ?. onNotification ) {
306+ throw new Error ( "expected iMessage notification handler" ) ;
307+ }
308+ onNotification = params . onNotification ;
309+ return client as never ;
310+ } ) ;
311+
312+ await monitorIMessageProvider ( {
313+ config : {
314+ channels : {
315+ imessage : {
316+ dmPolicy : "allowlist" ,
317+ allowFrom : [ "+15550001111" ] ,
318+ sendReadReceipts : false ,
319+ } ,
320+ } ,
321+ messages : { inbound : { debounceMs : 0 } } ,
322+ session : { mainKey : "main" } ,
323+ } as never ,
324+ runtime : { error : vi . fn ( ) , exit : vi . fn ( ) , log : vi . fn ( ) } ,
325+ } ) ;
326+
327+ await vi . waitFor ( ( ) => {
328+ expect ( dispatchInboundMessageMock ) . toHaveBeenCalledTimes ( 1 ) ;
329+ } ) ;
330+ expect ( client . request ) . not . toHaveBeenCalledWith (
331+ "typing" ,
332+ expect . objectContaining ( { typing : true } ) ,
333+ expect . anything ( ) ,
334+ ) ;
335+ } ) ;
336+
337+ it ( "starts direct typing before dispatching the inbound turn" , async ( ) => {
338+ setCachedIMessagePrivateApiStatus ( "imsg" , {
339+ available : true ,
340+ v2Ready : true ,
341+ selectors : { } ,
342+ rpcMethods : [ "watch.subscribe" , "send" , "typing" ] ,
343+ } ) ;
344+
345+ let onNotification : ( ( message : { method : string ; params : unknown } ) => void ) | undefined ;
346+ const earlyTypingClient = {
347+ request : vi . fn ( async ( method : string ) => {
348+ if ( method === "typing" ) {
349+ return { ok : true } ;
350+ }
351+ throw new Error ( `unexpected imsg typing-client method ${ method } ` ) ;
352+ } ) ,
353+ stop : vi . fn ( async ( ) => { } ) ,
354+ } ;
355+ const watchClient = {
356+ request : vi . fn ( async ( method : string ) => {
357+ if ( method === "watch.subscribe" ) {
358+ return { subscription : 1 } ;
359+ }
360+ if ( method === "typing" ) {
361+ return { ok : true } ;
362+ }
363+ throw new Error ( `unexpected imsg watch-client method ${ method } ` ) ;
364+ } ) ,
365+ waitForClose : vi . fn ( async ( ) => {
366+ onNotification ?.( {
367+ method : "message" ,
368+ params : {
369+ message : {
370+ id : 12 ,
371+ chat_id : 123 ,
372+ sender : "+15550001111" ,
373+ is_from_me : false ,
374+ text : "respond after a slow context build" ,
375+ is_group : false ,
376+ created_at : new Date ( ) . toISOString ( ) ,
377+ } ,
378+ } ,
379+ } ) ;
380+ await vi . waitFor ( ( ) => {
381+ expect ( earlyTypingClient . request ) . toHaveBeenCalledWith (
382+ "typing" ,
383+ expect . objectContaining ( { typing : true , to : "+15550001111" } ) ,
384+ expect . any ( Object ) ,
385+ ) ;
386+ expect ( dispatchInboundMessageMock ) . toHaveBeenCalledTimes ( 1 ) ;
387+ } ) ;
388+ } ) ,
389+ stop : vi . fn ( async ( ) => { } ) ,
390+ } ;
391+ createIMessageRpcClientMock . mockImplementation ( async ( params ) => {
392+ if ( params ?. onNotification ) {
393+ onNotification = params . onNotification ;
394+ return watchClient as never ;
395+ }
396+ return earlyTypingClient as never ;
397+ } ) ;
398+ dispatchInboundMessageMock . mockImplementationOnce ( async ( ) => {
399+ expect ( earlyTypingClient . request ) . toHaveBeenCalledWith (
400+ "typing" ,
401+ expect . objectContaining ( { typing : true , to : "+15550001111" } ) ,
402+ expect . any ( Object ) ,
403+ ) ;
404+ return { queuedFinal : false , counts : { tool : 0 , block : 0 , final : 0 } } as const ;
405+ } ) ;
406+
407+ await monitorIMessageProvider ( {
408+ config : {
409+ channels : {
410+ imessage : {
411+ dmPolicy : "allowlist" ,
412+ allowFrom : [ "+15550001111" ] ,
413+ sendReadReceipts : false ,
414+ } ,
415+ } ,
416+ messages : { inbound : { debounceMs : 0 } } ,
417+ session : { mainKey : "main" } ,
418+ } as never ,
419+ runtime : { error : vi . fn ( ) , exit : vi . fn ( ) , log : vi . fn ( ) } ,
420+ } ) ;
421+
422+ expect ( watchClient . request ) . not . toHaveBeenCalledWith (
423+ "typing" ,
424+ expect . objectContaining ( { typing : true } ) ,
425+ expect . anything ( ) ,
426+ ) ;
427+ await vi . waitFor ( ( ) => {
428+ expect ( earlyTypingClient . request ) . toHaveBeenCalledWith (
429+ "typing" ,
430+ expect . objectContaining ( { typing : false , to : "+15550001111" } ) ,
431+ expect . any ( Object ) ,
432+ ) ;
433+ } ) ;
434+ } ) ;
435+
259436 it . each ( [ "never" , "message" , "thinking" ] as const ) (
260437 "does not start direct tool typing when typingMode is %s" ,
261438 async ( typingMode ) => {
@@ -420,6 +597,87 @@ describe("iMessage monitor last-route updates", () => {
420597 ) ;
421598 } ) ;
422599
600+ it ( "does not wait for read receipts before dispatching the inbound turn" , async ( ) => {
601+ setCachedIMessagePrivateApiStatus ( "imsg" , {
602+ available : true ,
603+ v2Ready : true ,
604+ selectors : { } ,
605+ rpcMethods : [ "watch.subscribe" , "read" ] ,
606+ } ) ;
607+
608+ let onNotification : ( ( message : { method : string ; params : unknown } ) => void ) | undefined ;
609+ const readClient = {
610+ request : vi . fn ( ( method : string ) => {
611+ if ( method === "read" ) {
612+ return new Promise ( ( ) => { } ) ;
613+ }
614+ return Promise . reject ( new Error ( `unexpected imsg read-client method ${ method } ` ) ) ;
615+ } ) ,
616+ stop : vi . fn ( async ( ) => { } ) ,
617+ } ;
618+ const watchClient = {
619+ request : vi . fn ( ( method : string ) => {
620+ if ( method === "watch.subscribe" ) {
621+ return Promise . resolve ( { subscription : 1 } ) ;
622+ }
623+ return Promise . reject ( new Error ( `unexpected imsg watch-client method ${ method } ` ) ) ;
624+ } ) ,
625+ waitForClose : vi . fn ( async ( ) => {
626+ onNotification ?.( {
627+ method : "message" ,
628+ params : {
629+ message : {
630+ id : 11 ,
631+ chat_id : 123 ,
632+ sender : "+15550001111" ,
633+ is_from_me : false ,
634+ text : "respond without waiting for read receipt" ,
635+ is_group : false ,
636+ created_at : new Date ( ) . toISOString ( ) ,
637+ } ,
638+ } ,
639+ } ) ;
640+ await vi . waitFor ( ( ) => {
641+ expect ( dispatchInboundMessageMock ) . toHaveBeenCalledTimes ( 1 ) ;
642+ } ) ;
643+ } ) ,
644+ stop : vi . fn ( async ( ) => { } ) ,
645+ } ;
646+ createIMessageRpcClientMock . mockImplementation ( async ( params ) => {
647+ if ( params ?. onNotification ) {
648+ onNotification = params . onNotification ;
649+ return watchClient as never ;
650+ }
651+ return readClient as never ;
652+ } ) ;
653+
654+ await monitorIMessageProvider ( {
655+ config : {
656+ channels : {
657+ imessage : {
658+ dmPolicy : "allowlist" ,
659+ allowFrom : [ "+15550001111" ] ,
660+ } ,
661+ } ,
662+ messages : { inbound : { debounceMs : 0 } } ,
663+ session : { mainKey : "main" } ,
664+ } as never ,
665+ runtime : { error : vi . fn ( ) , exit : vi . fn ( ) , log : vi . fn ( ) } ,
666+ } ) ;
667+
668+ expect ( readClient . request ) . toHaveBeenCalledWith (
669+ "read" ,
670+ expect . objectContaining ( { to : "+15550001111" } ) ,
671+ expect . any ( Object ) ,
672+ ) ;
673+ expect ( watchClient . request ) . not . toHaveBeenCalledWith (
674+ "read" ,
675+ expect . anything ( ) ,
676+ expect . anything ( ) ,
677+ ) ;
678+ expect ( dispatchInboundMessageMock ) . toHaveBeenCalledTimes ( 1 ) ;
679+ } ) ;
680+
423681 it . each ( [
424682 {
425683 label : "flat true" ,
0 commit comments