Skip to content

feat(telegram): typing-indicator heartbeat while a turn is in flight#294

Merged
yerzhansa merged 2 commits into
mainfrom
feature/typing-indicator-heartbeat
Jul 2, 2026
Merged

feat(telegram): typing-indicator heartbeat while a turn is in flight#294
yerzhansa merged 2 commits into
mainfrom
feature/typing-indicator-heartbeat

Conversation

@yerzhansa

Copy link
Copy Markdown
Owner

Summary

A chat turn can now legitimately run up to ~10 minutes. During that window the athlete previously got zero feedback — the Telegram channel dispatches the turn fire-and-forget and sends nothing until the final reply or the error copy. Silence for minutes reads as "the bot is dead," so athletes re-send, and the re-send queues behind the in-flight turn on the per-chat session lock, compounding the perceived hang.

This adds Telegram's native typing indicator as a heartbeat while a turn is in flight.

Closes #167.

Changes

  • startTypingHeartbeat(pulse, intervalMs, onError) (exported, unit-tested): fires one pulse immediately, then every TYPING_HEARTBEAT_MS (4s — under Telegram's ~5s auto-clear so it never flickers). Each pulse is best-effort — a rejected/throwing pulse is routed to onError (logged at debug) and can never affect the turn. The interval is unref'd so a pending pulse can't hold the process open during shutdown / an /update drain.
  • Wired into runTurn: the heartbeat starts immediately before agent.chat(...) is awaited and stops in a finally around the generation try/catch only (never delivery) — so it clears on success, on a classified-error reply, and on a throw. opts.ctx widened to include replyWithChatAction. The existing generation/delivery/resend logic and all copy are byte-identical.
  • Heartbeat is scoped to runTurn (the agent.chat generation phase, where the 10-minute silence lives); non-generation command handlers are untouched.

Acceptance criteria

  • AC-1 — indicator shown continuously while agent.chat is in flight (refreshed ≤ every 5s).
  • AC-2 — indicator stops within one interval tick of the turn finishing, on success / classified error / throw.
  • AC-3 — a sendChatAction failure never surfaces to the athlete and never alters the reply or error path.
  • AC-4 — the interval is unref'd.

All four are covered by new fake-timer integration tests + startTypingHeartbeat unit tests in telegram-dispatch.test.ts.

Testing

  • pnpm check → exit 0 (build, typecheck incl. tests, lint, trademarks, fixture-privacy, registry-isolation, changeset gates).
  • Full suite: telegram suites green; telegram-dispatch.test.ts 53 passed (47 pre-existing + 6 new).

Note on the base

This work depends on the split generation/delivery runTurn introduced in #285. It is branched off that PR, so until #285 merges, this PR's diff also carries #285's commits. Once #285 lands on main I'll rebase this branch onto main for a clean single-commit diff.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69d40929f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +141 to +142
bot.api.config.use(
autoRetry({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude typing heartbeats from auto-retry

When Telegram rate-limits sendChatAction, this global transformer also applies to the new replyWithChatAction("typing") heartbeat. Those pulse promises are intentionally fire-and-forget, and stopHeartbeat() only clears future intervals, so a 429 with retry_after can leave already-scheduled chat-action retries sleeping for up to 300 seconds and then firing after the turn has completed, showing stale typing indicators and potentially creating a burst of extra API calls. Consider bypassing auto-retry for chat actions or making heartbeat pulses abortable/non-retried.

Useful? React with 👍 / 👎.

Comment on lines +24 to +25
Timeout: "provider-down",
Network: "provider-down",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route intervals network failures to intervals copy

For an intervals-icu-api Timeout or Network error, isIntervalsApiError takes this routing before the generic classifier, and the later provider-down branch tells the athlete that the model provider is having trouble. In the scenario where intervals.icu is unreachable but the model is fine, that is the wrong user-facing diagnosis; these kinds should use the intervals-specific copy or a separate “couldn't reach intervals.icu” message.

Useful? React with 👍 / 👎.

@yerzhansa yerzhansa force-pushed the feature/typing-indicator-heartbeat branch from 69d4092 to ca10d51 Compare July 2, 2026 06:48
@yerzhansa yerzhansa changed the base branch from main to bugfix/telegram-delivery-error-copy July 2, 2026 06:49
@yerzhansa

Copy link
Copy Markdown
Owner Author

Rebased onto the updated delivery branch and addressed the two findings that live in the heartbeat code (commit ca10d51):

  • F2 (pulse pile-up / late typing): startTypingHeartbeat now skips a beat while the previous pulse is still unsettled (in-flight guard, released in a finally so a rejected pulse can't wedge it). Combined with the delivery branch's maxDelaySeconds: 5, parked pulses can no longer stack up. Added fake-timer coverage proving ticks skip while a pulse stays pending and resume once it settles.
  • F8 (mirrored constant): TYPING_HEARTBEAT_MS is now exported; the test imports it and asserts the ≤5s auto-clear invariant against the production constant instead of a local mirror, so bumping it past 5s now fails CI.

Base retargeted to bugfix/telegram-delivery-error-copy so this PR's diff shows only the heartbeat delta. pnpm check green; full core suite green (2478 passing).

Base automatically changed from bugfix/telegram-delivery-error-copy to main July 2, 2026 07:06
yerzhansa added 2 commits July 2, 2026 12:07
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
- 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
@yerzhansa yerzhansa force-pushed the feature/typing-indicator-heartbeat branch from ca10d51 to 432ed5a Compare July 2, 2026 07:07
@yerzhansa yerzhansa merged commit 1dcce7b into main Jul 2, 2026
2 checks passed
@yerzhansa yerzhansa deleted the feature/typing-indicator-heartbeat branch July 2, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant