Skip to content

Discord typing indicator lingers ~10s after reply delivery in message_tool_only source-reply path #84276

Description

@xli025

Discord typing indicator lingers ~10s after reply delivery in message_tool_only source-reply path

Environment

  • OpenClaw 2026.5.17
  • @openclaw/discord plugin 2026.5.12
  • Channel: Discord (DM, chat_type=direct)
  • Source reply delivery mode: message_tool_only
  • Container: gateway runs as PID 1 under tini

Summary

In Discord direct‑message sessions where sourceReplyDeliveryMode === "message_tool_only", the typing indicator stays visible for the full DISPATCH_IDLE_GRACE_MS (10 s) after each turn finishes, even though the reply has already been delivered. The user perceives this as the assistant "still typing" long after the message has arrived.

Repro

  1. Run gateway with Discord channel configured for a DM session.
  2. Use an agent whose visible-output route is message_tool_only (default for current main agent profile).
  3. Send any prompt that the agent answers via a message(action=send) tool call.
  4. Observe Discord — the typing bubble persists ~10 s after the reply lands.

Root cause analysis

In dist/get-reply-BJtqZxVq.js#createTypingController, the indicator is cleaned up only when both signals fire:

  • markRunComplete() — model run finished.
  • markDispatchIdle() — outbound dispatcher drained.
const maybeStopOnIdle = () => {
  if (!active) return;
  if (runComplete && dispatchIdle) cleanup();
};

After markRunComplete() there is a 10 s grace timer that forces cleanup() if dispatchIdle never arrives:

const DISPATCH_IDLE_GRACE_MS = 1e4;
const markRunComplete = () => {
  runComplete = true;
  maybeStopOnIdle();
  if (!sealed && !dispatchIdle) dispatchIdleTimer = setTimeout(() => {
    if (!sealed && !dispatchIdle) {
      log?.("typing: dispatch idle not received after run complete; forcing cleanup");
      cleanup();
    }
  }, DISPATCH_IDLE_GRACE_MS);
};

In dist/message-handler.process-n0M18Gxu.js the dispatcher's onSettled callback wires both signals only on the abort path (settleDispatchBeforeStart) and in the finally (after the full pipeline returns):

const settleDispatchBeforeStart = async () => {
  dispatchSettledBeforeStart = true;
  await settleReplyDispatcher({
    dispatcher,
    onSettled: () => {
      markRunComplete();
      markDispatchIdle();
    }
  });
};

} finally {
  
  if (!dispatchSettledBeforeStart) {
    markRunComplete();
    markDispatchIdle();
  }
}

When sourceRepliesAreToolOnly === true, no streamed dispatcher is in play for the visible-output path (delivery happens directly via the message tool body). The pipeline still goes through the normal lifecycle, but markDispatchIdle() ends up being called from the finally together with markRunComplete() rather than asynchronously after a real dispatcher drain.

The relative timing in practice (observed via gateway logs):

  • markRunComplete() fires when the model returns its last block.
  • markDispatchIdle() fires immediately after, in the same finally block.
  • BUT — the typing keepalive loop (interval = 6 s, typingIntervalSeconds) has already issued a fresh sendTyping packet during the in-flight tool call, and Discord's server-side typing TTL is ~10 s. So even when the controller correctly transitions to cleanup(), the bubble keeps showing until Discord's TTL elapses.

The combination → user always sees ~10 s of trailing typing, regardless of how fast the runtime cleaned up.

Why the obvious fix backfires

Reducing DISPATCH_IDLE_GRACE_MS from 1e4 to e.g. 1500 makes things worse. The shorter grace forces cleanup() before the genuine markDispatchIdle() arrives, which seals the controller (sealed = true). A subsequent legitimate signal can't re-open it. Discord's TTL is independent of our cleanup, so the bubble still hangs.

Verified empirically in this environment: 10 s → 1.5 s grace caused longer perceived lingering, not shorter.

Suggested fixes (any one would resolve)

  1. Send a typing-stop hint on cleanup. Discord's REST API doesn't expose an explicit stopTyping, but sending the actual reply message clears typing immediately for that author. The message_tool_only send path should make sure typing-loop tick is cancelled and no further sendTyping is issued after the user-visible message dispatch starts. Currently the keepalive can fire after the message is queued.

  2. Suppress typing keepalive in message_tool_only mode. resolveTypingMode already returns "instant" for this mode (no streaming-driven typing). But the keepalive loop ticks regardless, refreshing the indicator while the model is still composing — including after the visible reply has been delivered through the message tool. Gate the keepalive ticks on "no reply yet sent" for source-reply paths.

  3. Wire markDispatchIdle into the message(action=send) tool persistence path. When opts.sourceReplyDeliveryMode === "message_tool_only" and a successful message.send toolResult lands, mark dispatch idle in the same callback that resolves the existing MESSAGE_SEND_WRITE_FENCE.

Option 3 is the smallest surgical change and aligns with the existing fence registry pattern already added in selection-DZ2iSMe4.js.

Workarounds

  • agents.defaults.typingMode = "never" — disables the indicator entirely. Acceptable if you don't want the typing UX at all.
  • Local source patch — only safe path is option 3 above; shortening the grace timer (DISPATCH_IDLE_GRACE_MS) makes things worse.

Related code refs

  • dist/get-reply-BJtqZxVq.js#createTypingController (DISPATCH_IDLE_GRACE_MS, markRunComplete, markDispatchIdle).
  • dist/message-handler.process-n0M18Gxu.js (the finally block that double-fires both signals together for message_tool_only paths).
  • dist/selection-DZ2iSMe4.js (MESSAGE_SEND_WRITE_FENCE_*) — same pattern can be reused for dispatch-idle signalling.
  • @openclaw/discord/dist/typing-_jePdFIw.js#sendTyping — the keepalive call.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:otherThis issue has meaningful maintainer-visible impact outside the owned taxonomy.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions