Skip to content

Commit 1dcce7b

Browse files
authored
feat(telegram): typing-indicator heartbeat while a turn is in flight (#294)
* feat(telegram): typing-indicator heartbeat while a turn is in flight A chat turn can run up to ~10 min, during which the athlete previously got zero feedback and would assume the bot was dead. Pulse Telegram's native "typing" chat action every 4s for the generation phase of the shared turn skeleton, stopping it in a finally around generation (never delivery). Each pulse is best-effort — a rejected or throwing pulse is logged at debug and never affects the turn or its reply — and the interval is unref'd so a pending pulse can't hold the process open during shutdown or an /update drain. Closes #167 * fix(telegram): guard heartbeat pulses and export the interval constant - add an in-flight guard so a beat is skipped while the previous pulse is still unsettled, preventing pulse pile-up when a pulse is parked in the API retry layer; the flag is released in a finally so a rejected pulse can't wedge it - export TYPING_HEARTBEAT_MS and assert the <=5s auto-clear invariant against the production constant instead of a test-local mirror
1 parent 33aa8bc commit 1dcce7b

3 files changed

Lines changed: 226 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"cycling-coach": patch
3+
---
4+
5+
User-facing: The bot now shows a typing indicator while it works on your message, so a long reply no longer looks like it went silent.
6+
7+
Starts a best-effort heartbeat that re-emits Telegram's native "typing" chat action every 4s for the duration of the generation phase in the shared turn skeleton, then stops it in a `finally` around generation (never around delivery). Each pulse is isolated: a rejected or throwing pulse is logged at debug and can never affect the turn or its reply, and the interval is unref'd so a pending pulse cannot hold the process open during shutdown or an `/update` drain.

packages/core/src/channels/telegram.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,51 @@ const DELIVERY_FAILURE_HINT = `I generated the answer, but Telegram had trouble
4747
// must not be dressed in provider-specific vocabulary.
4848
const GENERIC_TRANSPORT_APOLOGY = "Sorry, something went wrong. Please try again.";
4949

50+
// How often to re-emit Telegram's native "typing" indicator while a turn is in
51+
// flight. Telegram auto-clears the indicator ~5s after each sendChatAction, so we
52+
// refresh faster than that to keep it continuous without flicker.
53+
export const TYPING_HEARTBEAT_MS = 4_000;
54+
5055
function drainBounded(drain: () => Promise<void>, timeoutMs: number): Promise<void> {
5156
return Promise.race([
5257
drain(),
5358
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs).unref?.()),
5459
]);
5560
}
5661

62+
// Pulse Telegram's native "typing" indicator on an interval so a long turn never
63+
// leaves the athlete staring at silence (a turn can now run up to ~10 min). Fires
64+
// once immediately, then every intervalMs. Each pulse is best-effort: a rejected
65+
// (or throwing) pulse is routed to onError and can never affect the turn or its
66+
// reply. The interval is unref'd so a pending pulse cannot hold the process open
67+
// during shutdown / an /update drain. Returns a stop() that clears the interval.
68+
export function startTypingHeartbeat(
69+
pulse: () => Promise<unknown>,
70+
intervalMs: number,
71+
onError: (err: unknown) => void,
72+
): () => void {
73+
// Skip a beat while the previous pulse is still unsettled: under a Telegram
74+
// flood a pulse can be parked inside the API retry layer, and firing a fresh
75+
// one every interval regardless would pile parked pulses up and roughly double
76+
// request volume against an already-throttled API. The flag is cleared in a
77+
// finally so a rejected pulse cannot wedge the guard permanently.
78+
let inFlight = false;
79+
const beat = () => {
80+
if (inFlight) return;
81+
inFlight = true;
82+
void Promise.resolve()
83+
.then(pulse)
84+
.catch(onError)
85+
.finally(() => {
86+
inFlight = false;
87+
});
88+
};
89+
beat();
90+
const timer = setInterval(beat, intervalMs);
91+
timer.unref?.();
92+
return () => clearInterval(timer);
93+
}
94+
5795
// ============================================================================
5896
// TELEGRAM BOT
5997
// ============================================================================
@@ -256,7 +294,10 @@ export function createTelegramBot(
256294
// vice versa. The only per-handler differences (genericReply text, log command
257295
// name, reply-to id) are passed in rather than re-templated.
258296
function runTurn(opts: {
259-
ctx: { reply: (text: string, options?: Record<string, unknown>) => Promise<unknown> };
297+
ctx: {
298+
reply: (text: string, options?: Record<string, unknown>) => Promise<unknown>;
299+
replyWithChatAction: (action: "typing") => Promise<unknown>;
300+
};
260301
command: string;
261302
chatId: string;
262303
message: string;
@@ -265,6 +306,11 @@ export function createTelegramBot(
265306
replyToMessageId?: number;
266307
}): void {
267308
dispatch(async () => {
309+
const stopHeartbeat = startTypingHeartbeat(
310+
() => opts.ctx.replyWithChatAction("typing"),
311+
TYPING_HEARTBEAT_MS,
312+
() => log.debug("typing_heartbeat_failed", { command: opts.command, chatId: opts.chatId }),
313+
);
268314
let response: string;
269315
try {
270316
response = await agent.chat(opts.chatId, opts.message, opts.deps);
@@ -273,6 +319,8 @@ export function createTelegramBot(
273319
const { kind, athleteMessage } = classifyAgentError(err);
274320
await opts.ctx.reply(kind === "unknown" ? opts.genericReply : athleteMessage);
275321
return;
322+
} finally {
323+
stopHeartbeat();
276324
}
277325
writeResend(opts.chatId, response);
278326
try {

packages/core/tests/telegram-dispatch.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { APICallError } from "@ai-sdk/provider";
66
import { cyclingBinary } from "./helpers/cycling-binary-fixture.js";
7+
import { startTypingHeartbeat, TYPING_HEARTBEAT_MS } from "../src/channels/telegram.js";
78

89
let 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

126128
function 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,173 @@ describe("retry transformer / classified errors / delivery split", () => {
446449
});
447450
});
448451

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

0 commit comments

Comments
 (0)