@@ -4,6 +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" ;
78
89let dataDir : string ;
910
@@ -121,6 +122,7 @@ interface FakeCtx {
121122 message : { text : string ; message_id ?: number } ;
122123 reply : ReturnType < typeof vi . fn > ;
123124 replyWithDocument : ReturnType < typeof vi . fn > ;
125+ replyWithChatAction : ReturnType < typeof vi . fn > ;
124126}
125127
126128function makeCtx ( overrides ?: Partial < FakeCtx > ) : FakeCtx {
@@ -130,6 +132,7 @@ function makeCtx(overrides?: Partial<FakeCtx>): FakeCtx {
130132 message : { text : "hi" } ,
131133 reply : vi . fn ( async ( ) => undefined ) ,
132134 replyWithDocument : vi . fn ( async ( ) => undefined ) ,
135+ replyWithChatAction : vi . fn ( async ( ) => true ) ,
133136 ...overrides ,
134137 } ;
135138}
@@ -446,6 +449,144 @@ describe("retry transformer / classified errors / delivery split", () => {
446449 } ) ;
447450} ) ;
448451
452+ describe ( "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+
457+ describe ( "in-flight pulses (integration)" , ( ) => {
458+ beforeEach ( ( ) => {
459+ vi . useFakeTimers ( ) ;
460+ } ) ;
461+ afterEach ( ( ) => {
462+ vi . useRealTimers ( ) ;
463+ } ) ;
464+
465+ it ( "pulses 'typing' immediately and again each interval while agent.chat is in flight" , async ( ) => {
466+ const { bot, agent, drainPending } = await buildBot ( ) ;
467+ agent . hasSession . mockReturnValue ( true ) ;
468+ let resolveChat ! : ( v : string ) => void ;
469+ agent . chat . mockReturnValue (
470+ new Promise < string > ( ( r ) => {
471+ resolveChat = r ;
472+ } ) ,
473+ ) ;
474+ const ctx = makeCtx ( { message : { text : "how's my form?" } } ) ;
475+
476+ await getMessageText ( bot ) ( ctx ) ;
477+
478+ // Flush the immediate pulse microtask (fires before any interval elapses).
479+ await vi . advanceTimersByTimeAsync ( 0 ) ;
480+ expect ( ctx . replyWithChatAction ) . toHaveBeenCalledWith ( "typing" ) ;
481+ const afterImmediate = ctx . replyWithChatAction . mock . calls . length ;
482+ expect ( afterImmediate ) . toBeGreaterThanOrEqual ( 1 ) ;
483+
484+ // One interval → at least one more pulse; and a second interval → another.
485+ await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS ) ;
486+ await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS ) ;
487+ expect ( ctx . replyWithChatAction . mock . calls . length ) . toBeGreaterThanOrEqual ( afterImmediate + 2 ) ;
488+ expect ( HEARTBEAT_MS ) . toBeLessThanOrEqual ( 5_000 ) ;
489+
490+ // Let the turn finish so no fake interval leaks past the test.
491+ resolveChat ( "done" ) ;
492+ await drainPending ( ) ;
493+ } ) ;
494+
495+ it ( "stops the interval on normal completion — no further pulses" , async ( ) => {
496+ const { bot, agent, drainPending } = await buildBot ( ) ;
497+ agent . hasSession . mockReturnValue ( true ) ;
498+ let resolveChat ! : ( v : string ) => void ;
499+ agent . chat . mockReturnValue (
500+ new Promise < string > ( ( r ) => {
501+ resolveChat = r ;
502+ } ) ,
503+ ) ;
504+ const ctx = makeCtx ( { message : { text : "how's my form?" } } ) ;
505+
506+ await getMessageText ( bot ) ( ctx ) ;
507+ await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS ) ;
508+
509+ resolveChat ( "the answer body" ) ;
510+ await drainPending ( ) ;
511+ expect ( someReply ( ctx , "the answer body" ) ) . toBe ( true ) ;
512+
513+ const frozen = ctx . replyWithChatAction . mock . calls . length ;
514+ await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS * 5 ) ;
515+ expect ( ctx . replyWithChatAction . mock . calls . length ) . toBe ( frozen ) ;
516+ } ) ;
517+
518+ it ( "stops the interval on a generation error, and still fires the classified reply" , async ( ) => {
519+ const { bot, agent, drainPending } = await buildBot ( ) ;
520+ agent . hasSession . mockReturnValue ( true ) ;
521+ agent . chat . mockRejectedValue ( rateLimitError ( 30 ) ) ;
522+ const ctx = makeCtx ( { message : { text : "plan my week" } } ) ;
523+
524+ await getMessageText ( bot ) ( ctx ) ;
525+ await drainPending ( ) ;
526+ expect ( someReply ( ctx , "Rate limited — please try again in ~30 seconds." ) ) . toBe ( true ) ;
527+
528+ const frozen = ctx . replyWithChatAction . mock . calls . length ;
529+ await vi . advanceTimersByTimeAsync ( HEARTBEAT_MS * 5 ) ;
530+ expect ( ctx . replyWithChatAction . mock . calls . length ) . toBe ( frozen ) ;
531+ } ) ;
532+
533+ it ( "a pulse that rejects on every call never affects the turn's reply path" , async ( ) => {
534+ const { bot, agent, drainPending } = await buildBot ( ) ;
535+ agent . hasSession . mockReturnValue ( true ) ;
536+ agent . chat . mockResolvedValue ( "the delivered answer" ) ;
537+ const ctx = makeCtx ( { message : { text : "how's my form?" } } ) ;
538+ ctx . replyWithChatAction . mockRejectedValue ( new Error ( "chat action failed" ) ) ;
539+
540+ await getMessageText ( bot ) ( ctx ) ;
541+ await drainPending ( ) ;
542+
543+ // The reply still lands unchanged despite every pulse rejecting.
544+ expect ( someReply ( ctx , "the delivered answer" ) ) . toBe ( true ) ;
545+ const htmlReply = ctx . reply . mock . calls . find (
546+ ( c : unknown [ ] ) => ( c [ 1 ] as { parse_mode ?: string } | undefined ) ?. parse_mode === "HTML" ,
547+ ) ;
548+ expect ( htmlReply ) . toBeDefined ( ) ;
549+ } ) ;
550+ } ) ;
551+
552+ describe ( "startTypingHeartbeat (unit)" , ( ) => {
553+ it ( "a rejecting pulse routes to onError and never throws to the caller" , async ( ) => {
554+ const onError = vi . fn ( ) ;
555+ const stop = startTypingHeartbeat (
556+ ( ) => Promise . reject ( new Error ( "pulse boom" ) ) ,
557+ 10_000 ,
558+ onError ,
559+ ) ;
560+ // Drain all pending microtasks (a rejected thenable adoption spans several
561+ // ticks) so the immediate best-effort pulse settles into .catch(onError).
562+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
563+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
564+ expect ( ( ) => stop ( ) ) . not . toThrow ( ) ;
565+ } ) ;
566+
567+ it ( "unrefs the interval on start and clears exactly that timer on stop" , ( ) => {
568+ const unref = vi . fn ( ) ;
569+ const fakeTimer = { unref } ;
570+ const setIntervalSpy = vi . fn ( ( ) => fakeTimer ) ;
571+ const clearIntervalSpy = vi . fn ( ) ;
572+ vi . stubGlobal ( "setInterval" , setIntervalSpy ) ;
573+ vi . stubGlobal ( "clearInterval" , clearIntervalSpy ) ;
574+ try {
575+ const stop = startTypingHeartbeat ( async ( ) => undefined , HEARTBEAT_MS , ( ) => { } ) ;
576+ expect ( setIntervalSpy ) . toHaveBeenCalledTimes ( 1 ) ;
577+ expect ( unref ) . toHaveBeenCalledTimes ( 1 ) ;
578+ expect ( clearIntervalSpy ) . not . toHaveBeenCalled ( ) ;
579+
580+ stop ( ) ;
581+ expect ( clearIntervalSpy ) . toHaveBeenCalledTimes ( 1 ) ;
582+ expect ( clearIntervalSpy ) . toHaveBeenCalledWith ( fakeTimer ) ;
583+ } finally {
584+ vi . unstubAllGlobals ( ) ;
585+ }
586+ } ) ;
587+ } ) ;
588+ } ) ;
589+
449590describe ( "message:text — greet / re-greet logic" , ( ) => {
450591 it ( "first message from a newcomer (no session) → WELCOME then chat" , async ( ) => {
451592 const { bot, agent, drainPending } = await buildBot ( ) ;
0 commit comments