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
- Run gateway with Discord channel configured for a DM session.
- Use an agent whose visible-output route is
message_tool_only (default for current main agent profile).
- Send any prompt that the agent answers via a
message(action=send) tool call.
- 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)
-
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.
-
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.
-
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.
Discord typing indicator lingers ~10s after reply delivery in
message_tool_onlysource-reply pathEnvironment
2026.5.17@openclaw/discordplugin2026.5.12chat_type=direct)message_tool_onlySummary
In Discord direct‑message sessions where
sourceReplyDeliveryMode === "message_tool_only", the typing indicator stays visible for the fullDISPATCH_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
message_tool_only(default for current main agent profile).message(action=send)tool call.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.After
markRunComplete()there is a 10 s grace timer that forcescleanup()ifdispatchIdlenever arrives:In
dist/message-handler.process-n0M18Gxu.jsthe dispatcher'sonSettledcallback wires both signals only on the abort path (settleDispatchBeforeStart) and in thefinally(after the full pipeline returns):When
sourceRepliesAreToolOnly === true, no streamed dispatcher is in play for the visible-output path (delivery happens directly via themessagetool body). The pipeline still goes through the normal lifecycle, butmarkDispatchIdle()ends up being called from thefinallytogether withmarkRunComplete()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 samefinallyblock.typingIntervalSeconds) has already issued a freshsendTypingpacket during the in-flight tool call, and Discord's server-side typing TTL is ~10 s. So even when the controller correctly transitions tocleanup(), 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_MSfrom1e4to e.g.1500makes things worse. The shorter grace forcescleanup()before the genuinemarkDispatchIdle()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)
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. Themessage_tool_onlysend path should make sure typing-loop tick is cancelled and no furthersendTypingis issued after the user-visible message dispatch starts. Currently the keepalive can fire after the message is queued.Suppress typing keepalive in
message_tool_onlymode.resolveTypingModealready 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.Wire
markDispatchIdleinto themessage(action=send)tool persistence path. Whenopts.sourceReplyDeliveryMode === "message_tool_only"and a successfulmessage.sendtoolResult lands, mark dispatch idle in the same callback that resolves the existingMESSAGE_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.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(thefinallyblock that double-fires both signals together formessage_tool_onlypaths).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.