Skip to content

Commit 26dc28e

Browse files
committed
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
1 parent 37e7871 commit 26dc28e

3 files changed

Lines changed: 184 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: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,38 @@ 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+
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+
const beat = () => {
74+
void Promise.resolve().then(pulse).catch(onError);
75+
};
76+
beat();
77+
const timer = setInterval(beat, intervalMs);
78+
timer.unref?.();
79+
return () => clearInterval(timer);
80+
}
81+
5782
// ============================================================================
5883
// TELEGRAM BOT
5984
// ============================================================================
@@ -256,7 +281,10 @@ export function createTelegramBot(
256281
// vice versa. The only per-handler differences (genericReply text, log command
257282
// name, reply-to id) are passed in rather than re-templated.
258283
function runTurn(opts: {
259-
ctx: { reply: (text: string, options?: Record<string, unknown>) => Promise<unknown> };
284+
ctx: {
285+
reply: (text: string, options?: Record<string, unknown>) => Promise<unknown>;
286+
replyWithChatAction: (action: "typing") => Promise<unknown>;
287+
};
260288
command: string;
261289
chatId: string;
262290
message: string;
@@ -265,6 +293,11 @@ export function createTelegramBot(
265293
replyToMessageId?: number;
266294
}): void {
267295
dispatch(async () => {
296+
const stopHeartbeat = startTypingHeartbeat(
297+
() => opts.ctx.replyWithChatAction("typing"),
298+
TYPING_HEARTBEAT_MS,
299+
() => log.debug("typing_heartbeat_failed", { command: opts.command, chatId: opts.chatId }),
300+
);
268301
let response: string;
269302
try {
270303
response = await agent.chat(opts.chatId, opts.message, opts.deps);
@@ -273,6 +306,8 @@ export function createTelegramBot(
273306
const { kind, athleteMessage } = classifyAgentError(err);
274307
await opts.ctx.reply(kind === "unknown" ? opts.genericReply : athleteMessage);
275308
return;
309+
} finally {
310+
stopHeartbeat();
276311
}
277312
writeResend(opts.chatId, response);
278313
try {

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

Lines changed: 141 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 } 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,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+
449590
describe("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

Comments
 (0)