@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
44import { join } from "node:path" ;
55import { APICallError } from "@ai-sdk/provider" ;
66import { cyclingBinary } from "./helpers/cycling-binary-fixture.js" ;
7- import { startTypingHeartbeat } from "../src/channels/telegram.js" ;
7+ import { startTypingHeartbeat , TYPING_HEARTBEAT_MS } from "../src/channels/telegram.js" ;
88
99let dataDir : string ;
1010
@@ -450,10 +450,6 @@ describe("retry transformer / classified errors / delivery split", () => {
450450} ) ;
451451
452452describe ( "typing-indicator heartbeat" , ( ) => {
453- // Mirrors the un-exported TYPING_HEARTBEAT_MS in telegram.ts. The <= 5s
454- // assertion below keeps the heartbeat under Telegram's ~5s typing auto-clear window.
455- const HEARTBEAT_MS = 4_000 ;
456-
457453 describe ( "in-flight pulses (integration)" , ( ) => {
458454 beforeEach ( ( ) => {
459455 vi . useFakeTimers ( ) ;
@@ -482,10 +478,10 @@ describe("typing-indicator heartbeat", () => {
482478 expect ( afterImmediate ) . toBeGreaterThanOrEqual ( 1 ) ;
483479
484480 // One interval → at least one more pulse; and a second interval → another.
485- await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS ) ;
486- await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS ) ;
481+ await vi . advanceTimersByTimeAsync ( TYPING_HEARTBEAT_MS ) ;
482+ await vi . advanceTimersByTimeAsync ( TYPING_HEARTBEAT_MS ) ;
487483 expect ( ctx . replyWithChatAction . mock . calls . length ) . toBeGreaterThanOrEqual ( afterImmediate + 2 ) ;
488- expect ( HEARTBEAT_MS ) . toBeLessThanOrEqual ( 5_000 ) ;
484+ expect ( TYPING_HEARTBEAT_MS ) . toBeLessThanOrEqual ( 5_000 ) ;
489485
490486 // Let the turn finish so no fake interval leaks past the test.
491487 resolveChat ( "done" ) ;
@@ -504,14 +500,14 @@ describe("typing-indicator heartbeat", () => {
504500 const ctx = makeCtx ( { message : { text : "how's my form?" } } ) ;
505501
506502 await getMessageText ( bot ) ( ctx ) ;
507- await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS ) ;
503+ await vi . advanceTimersByTimeAsync ( TYPING_HEARTBEAT_MS ) ;
508504
509505 resolveChat ( "the answer body" ) ;
510506 await drainPending ( ) ;
511507 expect ( someReply ( ctx , "the answer body" ) ) . toBe ( true ) ;
512508
513509 const frozen = ctx . replyWithChatAction . mock . calls . length ;
514- await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS * 5 ) ;
510+ await vi . advanceTimersByTimeAsync ( TYPING_HEARTBEAT_MS * 5 ) ;
515511 expect ( ctx . replyWithChatAction . mock . calls . length ) . toBe ( frozen ) ;
516512 } ) ;
517513
@@ -526,7 +522,7 @@ describe("typing-indicator heartbeat", () => {
526522 expect ( someReply ( ctx , "Rate limited — please try again in ~30 seconds." ) ) . toBe ( true ) ;
527523
528524 const frozen = ctx . replyWithChatAction . mock . calls . length ;
529- await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS * 5 ) ;
525+ await vi . advanceTimersByTimeAsync ( TYPING_HEARTBEAT_MS * 5 ) ;
530526 expect ( ctx . replyWithChatAction . mock . calls . length ) . toBe ( frozen ) ;
531527 } ) ;
532528
@@ -572,7 +568,7 @@ describe("typing-indicator heartbeat", () => {
572568 vi . stubGlobal ( "setInterval" , setIntervalSpy ) ;
573569 vi . stubGlobal ( "clearInterval" , clearIntervalSpy ) ;
574570 try {
575- const stop = startTypingHeartbeat ( async ( ) => undefined , HEARTBEAT_MS , ( ) => { } ) ;
571+ const stop = startTypingHeartbeat ( async ( ) => undefined , TYPING_HEARTBEAT_MS , ( ) => { } ) ;
576572 expect ( setIntervalSpy ) . toHaveBeenCalledTimes ( 1 ) ;
577573 expect ( unref ) . toHaveBeenCalledTimes ( 1 ) ;
578574 expect ( clearIntervalSpy ) . not . toHaveBeenCalled ( ) ;
@@ -584,6 +580,39 @@ describe("typing-indicator heartbeat", () => {
584580 vi . unstubAllGlobals ( ) ;
585581 }
586582 } ) ;
583+
584+ it ( "skips beats while the previous pulse is still unsettled, then resumes once it settles" , async ( ) => {
585+ vi . useFakeTimers ( ) ;
586+ try {
587+ let settle ! : ( ) => void ;
588+ const pulse = vi . fn (
589+ ( ) =>
590+ new Promise < void > ( ( resolve ) => {
591+ settle = resolve ;
592+ } ) ,
593+ ) ;
594+ const stop = startTypingHeartbeat ( pulse , 10_000 , ( ) => { } ) ;
595+
596+ // The immediate beat fires exactly one pulse, which stays pending.
597+ await vi . advanceTimersByTimeAsync ( 0 ) ;
598+ expect ( pulse ) . toHaveBeenCalledTimes ( 1 ) ;
599+
600+ // Several intervals elapse while that pulse is parked — every tick is
601+ // skipped by the in-flight guard, so still only the one pulse.
602+ await vi . advanceTimersByTimeAsync ( 10_000 * 3 ) ;
603+ expect ( pulse ) . toHaveBeenCalledTimes ( 1 ) ;
604+
605+ // Settle the parked pulse; the guard releases and the next tick beats.
606+ settle ( ) ;
607+ await vi . advanceTimersByTimeAsync ( 0 ) ;
608+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
609+ expect ( pulse ) . toHaveBeenCalledTimes ( 2 ) ;
610+
611+ stop ( ) ;
612+ } finally {
613+ vi . useRealTimers ( ) ;
614+ }
615+ } ) ;
587616 } ) ;
588617} ) ;
589618
0 commit comments