openclaw - ✅(Solved) Fix Discord typing indicator lingers ~10s after reply delivery in message_tool_only source-reply path [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#84276Fetched 2026-05-20 03:41:54
View on GitHub
Comments
1
Participants
2
Timeline
9
Reactions
1
Author
Timeline (top)
labeled ×5cross-referenced ×2commented ×1referenced ×1

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.

Root Cause

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.

Fix Action

Fix / Workaround

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.

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

PR fix notes

PR #84300: fix(discord): stop typing keepalive on message_tool_only delivery (#84276)

Description (problem / solution / changelog)

Summary

  • Problem: in message_tool_only source-reply mode the Discord typing bubble keeps showing for ~10s after the reply has already landed in the channel.
  • Solution: add an explicit "source reply delivered" signal so the typing keepalive is sealed the moment the message tool's send resolves, instead of waiting for the dispatcher's idle grace window. The signal only fires for sends that actually target the source conversation.
  • What changed: new TypingController.markSourceReplyDelivered(); message(action=send) fires an optional onSourceReplyDelivered callback when sourceReplyDeliveryMode === "message_tool_only", the send is a real (non-dry-run) success, and the resolved channel + target match the source conversation (or it's the webchat internal-source sink). The callback is threaded through both runtime paths: the default Pi runtime (createOpenClawToolspi-toolsrunEmbeddedPiAgent) and the Codex app-server dynamic message tool (extensions/codex/src/app-server/run-attempt.ts → buildDynamicTools). Wired in agent-runner.ts / followup-runner.ts to call typing.markSourceReplyDelivered().
  • What did NOT change: the heartbeat typing pipeline is untouched, DISPATCH_IDLE_GRACE_MS is left at 10s (shortening it makes the bug worse — see the linked issue), and the automatic source-reply path is unaffected. A message.send to a different channel/recipient inside the same turn is left alone — only the source-conversation send seals the source typing.

Motivation

Users perceive the bot as "still typing" for ~10s after a reply has already arrived in DM. It's small but it makes the agent feel laggy or stuck. Issue #84276 traces it to the keepalive loop racing with the dispatcher's finally block; this PR addresses it on the path the issue calls out (Option 2 / Option 3 in the write-up).

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #84276
  • Related #
  • This PR fixes a bug or regression

Real behavior proof (required for external PRs)

  • Behavior or issue addressed: Discord typing indicator no longer lingers after a message_tool_only reply lands, and only the source conversation's typing is sealed when the message tool delivers.
  • Real environment tested: I don't have a live Discord workspace from this machine. To exercise the fix end to end without a live channel, I ran the runtime semantics + the new source-conversation guard directly under node. Terminal capture below shows the actual stdout from node verify-typing-fix.mjs after the patch.
  • Exact steps or command run after this patch:
    node verify-typing-fix.mjs
    (The script inlines the post-patch TypingController lifecycle and the new sendTargetIsSourceConversation guard from src/agents/tools/message-tool.ts, then drives 8 scenarios against them.)
  • Evidence after fix (terminal capture, real stdout):
Verifying TypingController.markSourceReplyDelivered + source-conversation guard (issue #84276)

[1/8] keepalive stops on markSourceReplyDelivered  (replyStart=2, cleanup=1)
[2/8] idempotent across repeat calls           (cleanup=1)
[3/8] late markRunComplete/markDispatchIdle/cleanup are no-ops once sealed
[4/8] dispatch-idle grace timer is cleared when delivery wins the race
[5/8] no-op when typing was never started     (reply=0, cleanup=0)
[6/8] source-conversation send matches even with channel-target prefix
[7/8] sends to other channels or recipients do not seal the source typing
[8/8] webchat internal-source sink always counts as a source-conversation send

All 8 scenarios pass. Discord typing keepalive halts in lockstep with delivery, and only for the source conversation.
  • Observed result after fix: keepalive halts the moment message(action=send) reports a successful non-dry-run send to the source conversation. Subsequent markRunComplete / markDispatchIdle from the dispatcher's finally block are no-ops, so no further sendTyping packets refresh Discord's TTL after the reply has landed. Cross-channel and cross-recipient sends in the same turn do not affect the source-channel typing.
  • What was not tested: a live Discord DM run with a real bot token. I do not have a spare bot/token wired up here. If a maintainer or @openclaw-mantis can capture the visible bubble disappearance for the redacted live recording the reviewer suggested, that closes the loop.
  • Before evidence: the lingering ~10s typing bubble is documented in the original issue write-up (logs + repro steps in #84276).

Root Cause

  • Root cause: createTypingController only calls cleanup() once both runComplete and dispatchIdle are true. In message_tool_only mode those signals are both fired together from the dispatcher's finally block, after the message tool has already returned. The 6s keepalive can issue one more sendTyping while the tool is in flight, and Discord's ~10s typing TTL keeps the bubble alive long after we think we cleaned up.
  • Missing detection / guardrail: there was no signal between the message tool delivering a visible reply and the typing controller. The controller had no concept of "the visible reply is on screen, stop refreshing the TTL".
  • Contributing context: shortening DISPATCH_IDLE_GRACE_MS makes things worse — it seals the controller before the legitimate markDispatchIdle arrives, but Discord's TTL is independent of our cleanup so the bubble still hangs.

Regression Test Plan

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/auto-reply/reply/typing-persistence.test.ts, src/agents/tools/message-tool.test.ts.
  • Scenario the test should lock in: when the message tool successfully delivers a visible reply under sourceReplyDeliveryMode === "message_tool_only" to the source conversation, the keepalive stops immediately, onCleanup fires once, and any late markRunComplete / markDispatchIdle from the dispatcher's finally block are no-ops. A message.send to a different channel/recipient inside the same turn must not seal the source typing.
  • Why this is the smallest reliable guardrail: the bug class lives in the typing controller's lifecycle gate plus the hand-off from the message tool. Both sides are now covered with unit tests (5 new lifecycle tests + 9 new message-tool tests covering source/cross/internal-source/dry-run/automatic/non-send/throwing-callback paths) and don't require a live channel to exercise.
  • Existing test that already covers this: nothing. There were tests for markRunComplete / markDispatchIdle separately, but nothing for the message_tool_only delivery path.
  • If no new test is added: N/A, new tests are added.

User-visible / Behavior Changes

Discord (and any other channel using message_tool_only) clears the typing indicator in lockstep with delivery instead of holding it for the channel-side TTL. No config flag. The path that was broken now matches what users already expect from the automatic path. Side messages to other conversations within the same turn are not affected.

Diagram

Before:
[message tool send] -> [reply delivered]
            keepalive tick at 6s -> sendTyping (refreshes ~10s TTL)
[run finally] -> markRunComplete + markDispatchIdle
            controller cleans up, but Discord still shows typing
            until its TTL expires (~10s after the reply landed)

After:
[message tool send to source convo] -> [reply delivered]
            -> onSourceReplyDelivered()  (guarded: source channel + target match)
            -> typing.markSourceReplyDelivered()
            -> keepalive loop stops, onCleanup fires, controller sealed
            (no further sendTyping refreshes the TTL)

[message tool send to a different conversation in the same turn]
            -> guard returns false, source typing is left alone

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No — if anything we make slightly fewer outbound sendTyping calls per turn.
  • Command/tool execution surface changed? No
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation: N/A

Repro + Verification

Environment

  • Integration/channel: Discord DM, chat_type=direct, sourceReplyDeliveryMode === "message_tool_only".
  • Relevant config (redacted):
agents:
  defaults:
    typingMode: instant
messages:
  visibleReplies: message_tool

Steps

  1. Send any prompt to the agent in a Discord DM with message_tool_only configured.
  2. Watch the typing indicator after the reply text appears.
  3. Before this patch: typing bubble persists for ~10s. After: bubble disappears in lockstep with delivery.

Expected

  • Typing bubble clears as soon as the reply message lands in the channel.

Actual

  • With this patch applied, the keepalive loop is stopped via markSourceReplyDelivered() the moment the message tool reports a successful non-dry-run send to the source conversation, so no further sendTyping packets refresh the TTL.

Human Verification (required)

  • Verified scenarios:
    • markSourceReplyDelivered() stops the keepalive immediately and fires onCleanup once.
    • Idempotent across repeat calls.
    • Late markRunComplete / markDispatchIdle / cleanup calls are no-ops once sealed.
    • The 10s dispatch-idle grace timer is cleared if delivery wins the race against markRunComplete.
    • Calling it before typing has started is a clean no-op.
    • Message-tool callback fires only when action === "send", sourceReplyDeliveryMode === "message_tool_only", the result is a send, and dryRun !== true. It does not fire for dry runs, automatic, or non-send actions.
    • Source-conversation guard: callback fires for default-routed sends, prefix-normalized matches (user:123 vs 123), and webchat handledBy === "internal-source" sends. It does not fire for cross-channel sends or same-channel sends to a different recipient.
    • A throwing onSourceReplyDelivered callback does not fail the tool call.
  • Edge cases checked: repeat delivery (idempotent), late tool/block callbacks after delivery (controller sealed so they can't restart typing), heartbeat path (uses a separate typing pipeline so it stays unchanged), cross-conversation message.send inside the same turn (source typing untouched).
  • What I did not verify: a live Discord bot run end to end. I don't have a spare bot/token on this machine.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes. The new onSourceReplyDelivered option and markSourceReplyDelivered method are both optional. Callers that don't pass the callback get exactly the previous behavior.
  • Config/env changes? No.
  • Migration needed? No.
  • If yes, exact upgrade steps: N/A.

Risks and Mitigations

  • Risk: the message tool's onSourceReplyDelivered callback throws and we swallow the error.
    • Mitigation: the swallow is intentional and narrowly scoped to the callback invocation. Typing cleanup is best-effort and should never fail a successful send. The rest of the tool result path is untouched.
  • Risk: a heuristically-routed send is misclassified as cross-conversation and we miss the chance to seal typing.
    • Mitigation: the source-conversation guard normalizes the channel-target prefix on both sides before comparing, and the webchat handledBy === "internal-source" sink short-circuits to "always source". The fall-through behavior is the same as the original bug (typing lingers for the channel TTL), so a misclassification can only ever degrade to the pre-patch state — it cannot kill typing for a wrong conversation.
  • Risk: an agent that calls message(action=send) to the source conversation more than once in a single turn won't get a typing indicator for the 2nd/3rd call.
    • Mitigation: in message_tool_only mode disableBlockStreaming is already true, so block streaming wasn't driving typing for those follow-up sends anyway. The trade-off (no typing for follow-up sends to the same conversation inside one turn) is the explicit design in Option 2 of the linked issue write-up, and is preferable to a typing bubble that lingers after the user has already seen the first reply.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • extensions/codex/src/app-server/run-attempt.ts (modified, +1/-0)
  • extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts (modified, +1/-0)
  • src/agents/openclaw-tools.ts (modified, +9/-0)
  • src/agents/pi-embedded-runner/run/attempt.ts (modified, +1/-0)
  • src/agents/pi-embedded-runner/run/params.ts (modified, +7/-0)
  • src/agents/pi-tools.ts (modified, +7/-0)
  • src/agents/tools/message-tool.test.ts (modified, +238/-0)
  • src/agents/tools/message-tool.ts (modified, +68/-0)
  • src/auto-reply/dispatch.test.ts (modified, +1/-0)
  • src/auto-reply/reply/agent-runner-execution.ts (modified, +7/-0)
  • src/auto-reply/reply/agent-runner.ts (modified, +5/-0)
  • src/auto-reply/reply/followup-runner.ts (modified, +5/-0)
  • src/auto-reply/reply/get-reply-directives.target-session.test.ts (modified, +2/-0)
  • src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts (modified, +1/-0)
  • src/auto-reply/reply/reply.test-helpers.ts (modified, +1/-0)
  • src/auto-reply/reply/test-helpers.ts (modified, +1/-0)
  • src/auto-reply/reply/typing-persistence.test.ts (modified, +68/-0)
  • src/auto-reply/reply/typing.ts (modified, +33/-0)

PR #76091: Fix Discord reply typing lifecycle

Description (problem / solution / changelog)

Summary

Fixes Discord reply typing feedback so an accepted inbound message uses one typing lifecycle from preflight acceptance through queued processing and reply dispatch, instead of starting a Discord-only early cue and later starting a separate dispatcher typing owner.

This branch now also covers the narrower message_tool_only stale typing path reported in #84276 and addressed separately by #84288: unconfigured Discord message-tool-only source replies send the initial typing cue but do not keep refreshing Discord typing after the visible message tool reply path has taken over.

Real behavior proof

  • Behavior or issue addressed: Discord reply typing feedback can be delayed or split into typing -> idle gap -> typing after OpenClaw accepts an inbound Discord message, leaving users without immediate acknowledgement while processing is already underway.
  • Real environment tested: Real OpenClaw + Discord setup using public OpenClaw release 2026.5.7 for the before-fix reproduction, with the PR #76091 patch used for the after-fix comparison. The current PR branch has since been updated to workspace version 2026.5.17.
  • Exact steps or command run after this patch: Reproduce the Discord reply flow on the public release, apply the PR #76091 patch, restart OpenClaw, then send the same Discord reply flow again and observe Discord typing feedback during processing.
  • Evidence after fix: Recording: https://github.com/user-attachments/assets/81bd8a69-d2c3-46f2-a23a-40f00694d8f7. The first 24 seconds show the pre-fix delay; the portion after 24 seconds shows the after-fix behavior with PR #76091 applied.
  • Observed result after fix: Typing feedback starts promptly after preflight accepts the Discord message and stays consistent during queued processing and reply dispatch. The visible long delay is removed.
  • What was not tested: A new live Discord DM proof for #84276's message_tool_only post-delivery lingering path has not been captured on this branch; that coverage is locked with focused lifecycle tests.

Current branch audit

Checked after folding in the #84276/#84288 method:

  • OpenClaw workspace version: 2026.5.17.
  • Current PR head: 13721aa081bfb95621f33e0fdba96d7ba26250eb.
  • Current main merged into this PR: d124c5aa2005d959a239bdf64f326d62f12682d6.
  • PR state after push: OPEN, ready for review, Mergeability: MERGEABLE, mergeStateStatus: UNSTABLE while checks rerun.
  • Scope cleanup from earlier remains intact: unrelated cron, macOS, oc-path, LINE, GitHub Copilot, incidental test drift, and the earlier shared createTypingCallbacks semantic change were removed from the PR diff.

Root cause

Discord had two independent typing owners for the same user turn:

  • message-handler.ts sent an accepted-message typing cue after preflight.
  • message-handler.process.ts later created the normal reply pipeline typing callback and started it from dispatcher onReplyStart.

That split could make users see typing -> idle gap -> typing again while OpenClaw was already working. The first visible response could also be delayed until the later reply dispatcher path if the early feedback path missed, timed out, or expired.

The newer #84276/#84288 path exposed a second edge of the same ownership problem: message_tool_only Discord replies can refresh typing through both the core reply typing controller and the Discord callback keepalive even after the visible message tool reply has been delivered.

Changes

  • Add reply-typing-feedback.ts for a Discord reply typing feedback owner backed by shared createTypingCallbacks.
  • Treat accepted Discord preflight as the visible acknowledgement boundary: start the carried typing feedback immediately for accepted messages, including guild allowlist channels with requireMention: false and no bot mention.
  • Preserve the explicit opt-out: typingMode: never still skips accepted typing feedback.
  • Carry the same accepted reply typing feedback through the queued inbound job and reuse it in processDiscordMessage.
  • Retarget the same feedback owner to the resolved delivery channel/thread once the reply plan is known.
  • Clean up accepted typing feedback when processing completes and when queued work is skipped.
  • Disable the Discord callback-owned keepalive loop so the core reply typing controller is the only periodic owner.
  • Add typingKeepalive?: boolean to the core reply options and use typingKeepalive: false only for unconfigured Discord message_tool_only runs; explicit session.typingMode / agents.defaults.typingMode keeps the existing core keepalive behavior.
  • Add focused queue, process, channel run queue, shared typing, and reply typing tests, including the accepted guild allowlist/no-mention regression case and the message_tool_only one-shot typing case.

Validation

Current local validation after folding in #84276/#84288 coverage:

  • node scripts/run-vitest.mjs run --config test/vitest/vitest.auto-reply-reply.config.ts src/auto-reply/reply/reply-utils.test.ts src/auto-reply/reply/typing-persistence.test.ts
  • node scripts/run-vitest.mjs run --config test/vitest/vitest.extension-discord.config.ts extensions/discord/src/monitor/message-handler.process.test.ts
  • pnpm test -- src/channels/typing.test.ts
  • node_modules/.bin/oxlint src/auto-reply/reply/typing.ts src/auto-reply/get-reply-options.types.ts src/auto-reply/reply/get-reply.ts extensions/discord/src/monitor/reply-typing-feedback.ts extensions/discord/src/monitor/message-handler.process.ts src/auto-reply/reply/reply-utils.test.ts src/channels/typing.test.ts extensions/discord/src/monitor/message-handler.process.test.ts
  • pnpm exec oxfmt --check --threads=1 src/auto-reply/reply/typing.ts src/auto-reply/get-reply-options.types.ts src/auto-reply/reply/get-reply.ts extensions/discord/src/monitor/reply-typing-feedback.ts extensions/discord/src/monitor/message-handler.process.ts src/auto-reply/reply/reply-utils.test.ts src/channels/typing.test.ts extensions/discord/src/monitor/message-handler.process.test.ts
  • git diff --check

GitHub Actions are rerunning on head 13721aa081bfb95621f33e0fdba96d7ba26250eb.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • docs/plugins/sdk-subpaths.md (modified, +1/-1)
  • extensions/discord/src/monitor/inbound-job.ts (modified, +3/-0)
  • extensions/discord/src/monitor/message-handler.preflight.types.ts (modified, +2/-0)
  • extensions/discord/src/monitor/message-handler.process.test.ts (modified, +77/-1)
  • extensions/discord/src/monitor/message-handler.process.ts (modified, +29/-16)
  • extensions/discord/src/monitor/message-handler.queue.test.ts (modified, +272/-30)
  • extensions/discord/src/monitor/message-handler.ts (modified, +59/-30)
  • extensions/discord/src/monitor/message-run-queue.ts (modified, +31/-8)
  • extensions/discord/src/monitor/reply-typing-feedback.ts (added, +55/-0)
  • src/auto-reply/get-reply-options.types.ts (modified, +2/-0)
  • src/auto-reply/reply/get-reply.ts (modified, +1/-0)
  • src/auto-reply/reply/reply-utils.test.ts (modified, +32/-0)
  • src/auto-reply/reply/typing.ts (modified, +6/-0)
  • src/channels/typing.test.ts (modified, +15/-0)
  • src/plugin-sdk/channel-lifecycle.core.ts (modified, +15/-2)
  • src/plugin-sdk/channel-lifecycle.queue.test.ts (modified, +3/-1)

Code Example

const maybeStopOnIdle = () => {
  if (!active) return;
  if (runComplete && dispatchIdle) cleanup();
};

---

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);
};

---

const settleDispatchBeforeStart = async () => {
  dispatchSettledBeforeStart = true;
  await settleReplyDispatcher({
    dispatcher,
    onSettled: () => {
      markRunComplete();
      markDispatchIdle();
    }
  });
};
} finally {
  if (!dispatchSettledBeforeStart) {
    markRunComplete();
    markDispatchIdle();
  }
}
RAW_BUFFERClick to expand / collapse

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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING