Skip to content

[Bug]: Telegram "typing..." indicator persists indefinitely after agent run completes #27177

@rui-miao

Description

@rui-miao

Telegram "typing..." indicator persists indefinitely after agent run completes

Description

The Telegram bot shows "typing..." indefinitely after certain agent interactions. The indicator never clears until the gateway is restarted. This happens because the typing keepalive loop can restart itself after the agent run completes.

Environment

  • OpenClaw 2026.2.24
  • Telegram channel with streaming: true
  • Model: Qwen3.5-122B-A10B via openai-completions API (llama.cpp backend), but likely affects all models

Steps to Reproduce

  1. Send a message to the bot via Telegram that triggers tool use (e.g., "run whoami")
  2. Wait for the bot to respond
  3. Observe the "typing..." indicator — it may persist indefinitely after the response is delivered

The issue is intermittent and more likely to occur when:

  • The agent run involves multiple tool-call turns (longer runs)
  • There are LLM errors during the run (context exceeded, timeouts, 502/503)
  • The slug-generator times out concurrently

Root Cause

Two issues in src/auto-reply/reply/typing.ts and the Telegram channel handler:

1. triggerTyping() does not check runComplete

In createTypingController, the triggerTyping function only checks sealed:

const triggerTyping = async () => {
    if (sealed) return;
    await onReplyStart?.();
};

The typingLoop (6-second interval) calls triggerTyping, which calls onReplyStart from createTypingCallbacks. That onReplyStart restarts the keepalive loop:

const onReplyStart = async () => {
    stopSent = false;
    keepaliveLoop.stop();
    await fireStart();      // sends sendChatAction("typing")
    keepaliveLoop.start();  // restarts the 3-second keepalive
};

The cleanup lifecycle requires both runComplete AND dispatchIdle to call cleanup() (which sets sealed = true). Between markRunComplete() and the dispatch becoming idle, the typingLoop can fire and restart the keepalive loop via onReplyStart. If cleanup() is delayed or never fires (e.g., due to a stuck message send), the keepalive loop runs forever.

2. Telegram createTypingCallbacks has no stop callback

Unlike the Slack implementation which has an explicit stop callback to clear the status:

// Slack (has stop)
const typingCallbacks = createTypingCallbacks({
    start: async () => { await ctx.setSlackThreadStatus({ status: "is typing..." }); },
    stop: async () => { await ctx.setSlackThreadStatus({ status: "" }); },  // clears typing
    ...
});

// Telegram (missing stop)
const typingCallbacks = createTypingCallbacks({
    start: sendTyping,
    // no stop callback
    ...
});

In fireStop(), when stop is undefined, no cleanup action is taken:

const fireStop = () => {
    keepaliveLoop.stop();
    if (!stop || stopSent) return;  // returns early for Telegram
    stopSent = true;
    stop().catch(...);
};

Suggested Fix

Fix 1: Check runComplete in triggerTyping()

const triggerTyping = async () => {
    if (sealed) return;
    if (runComplete) return;  // prevent restart after run completes
    await onReplyStart?.();
};

This is the critical fix — it prevents the typing loop from restarting the keepalive after the agent run finishes.

Fix 2: Add stop callback for Telegram

const typingCallbacks = createTypingCallbacks({
    start: sendTyping,
    stop: async () => {
        try {
            await bot.api.sendChatAction(chatId, "cancel", buildTypingThreadParams(replyThreadId));
        } catch {}
    },
    ...
});

This explicitly cancels the typing indicator on cleanup, matching Slack's behavior.

Affected Files

The typing lifecycle spans multiple dist chunks. The active file for
Telegram message handling is reply-*.js (e.g., reply-Cx57rl6c.js), not
pi-embedded-*.js. Both files contain copies of createTypingController and
createTypingCallbacks. The fix must be applied to whichever chunk is loaded
at runtime for the Telegram channel handler.

Workaround

Restarting the gateway (systemctl --user restart openclaw-gateway) clears the stuck typing state. The patches above can be applied manually to the dist file (reply-*.js) but will be overwritten by updates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions