diff --git a/CHANGELOG.md b/CHANGELOG.md index b9abd5411..049320ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd. - Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow. +- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow. - Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils. - Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow. - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 5c015dcd5..7272a3081 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -69,6 +69,8 @@ type ReplyDispatcherWithTypingResult = { dispatcher: ReplyDispatcher; replyOptions: Pick; markDispatchIdle: () => void; + /** Signal that the model run is complete so the typing controller can stop. */ + markRunComplete: () => void; }; export type ReplyDispatcher = { @@ -237,5 +239,8 @@ export function createReplyDispatcherWithTyping( typingController?.markDispatchIdle(); resolvedOnIdle?.(); }, + markRunComplete: () => { + typingController?.markRunComplete(); + }, }; } diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 4d0e14e8e..748ee921c 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -103,6 +103,7 @@ vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ }, replyOptions: {}, markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), }), ), })); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index e31ec0bac..646643782 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -571,64 +571,38 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // When draft streaming is active, suppress block streaming to avoid double-streaming. const disableBlockStreamingForDraft = draftStream ? true : undefined; - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - ...prefixOptions, - humanDelay: resolveHumanDelayConfig(cfg, route.agentId), - typingCallbacks, - deliver: async (payload: ReplyPayload, info) => { - const isFinal = info.kind === "final"; - if (payload.isReasoning) { - // Reasoning/thinking payloads should not be delivered to Discord. - return; - } - if (draftStream && isFinal) { - await flushDraft(); - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const finalText = payload.text; - const previewFinalText = resolvePreviewFinalText(finalText); - const previewMessageId = draftStream.messageId(); - - // Try to finalize via preview edit (text-only, fits in 2000 chars, not an error) - const canFinalizeViaPreviewEdit = - !finalizedViaPreviewMessage && - !hasMedia && - typeof previewFinalText === "string" && - typeof previewMessageId === "string" && - !payload.isError; - - if (canFinalizeViaPreviewEdit) { - await draftStream.stop(); - try { - await editMessageDiscord( - deliverChannelId, - previewMessageId, - { content: previewFinalText }, - { rest: client.rest }, - ); - finalizedViaPreviewMessage = true; - replyReference.markSent(); - return; - } catch (err) { - logVerbose( - `discord: preview final edit failed; falling back to standard send (${String(err)})`, - ); - } + const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = + createReplyDispatcherWithTyping({ + ...prefixOptions, + humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, + deliver: async (payload: ReplyPayload, info) => { + const isFinal = info.kind === "final"; + if (payload.isReasoning) { + // Reasoning/thinking payloads should not be delivered to Discord. + return; } + if (draftStream && isFinal) { + await flushDraft(); + const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const finalText = payload.text; + const previewFinalText = resolvePreviewFinalText(finalText); + const previewMessageId = draftStream.messageId(); - // Check if stop() flushed a message we can edit - if (!finalizedViaPreviewMessage) { - await draftStream.stop(); - const messageIdAfterStop = draftStream.messageId(); - if ( - typeof messageIdAfterStop === "string" && - typeof previewFinalText === "string" && + // Try to finalize via preview edit (text-only, fits in 2000 chars, not an error) + const canFinalizeViaPreviewEdit = + !finalizedViaPreviewMessage && !hasMedia && - !payload.isError - ) { + typeof previewFinalText === "string" && + typeof previewMessageId === "string" && + !payload.isError; + + if (canFinalizeViaPreviewEdit) { + await draftStream.stop(); try { await editMessageDiscord( deliverChannelId, - messageIdAfterStop, + previewMessageId, { content: previewFinalText }, { rest: client.rest }, ); @@ -637,45 +611,72 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) return; } catch (err) { logVerbose( - `discord: post-stop preview edit failed; falling back to standard send (${String(err)})`, + `discord: preview final edit failed; falling back to standard send (${String(err)})`, ); } } + + // Check if stop() flushed a message we can edit + if (!finalizedViaPreviewMessage) { + await draftStream.stop(); + const messageIdAfterStop = draftStream.messageId(); + if ( + typeof messageIdAfterStop === "string" && + typeof previewFinalText === "string" && + !hasMedia && + !payload.isError + ) { + try { + await editMessageDiscord( + deliverChannelId, + messageIdAfterStop, + { content: previewFinalText }, + { rest: client.rest }, + ); + finalizedViaPreviewMessage = true; + replyReference.markSent(); + return; + } catch (err) { + logVerbose( + `discord: post-stop preview edit failed; falling back to standard send (${String(err)})`, + ); + } + } + } + + // Clear the preview and fall through to standard delivery + if (!finalizedViaPreviewMessage) { + await draftStream.clear(); + } } - // Clear the preview and fall through to standard delivery - if (!finalizedViaPreviewMessage) { - await draftStream.clear(); - } - } - - const replyToId = replyReference.use(); - await deliverDiscordReply({ - replies: [payload], - target: deliverTarget, - token, - accountId, - rest: client.rest, - runtime, - replyToId, - replyToMode, - textLimit, - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, - tableMode, - chunkMode, - sessionKey: ctxPayload.SessionKey, - threadBindings, - }); - replyReference.markSent(); - }, - onError: (err, info) => { - runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); - }, - onReplyStart: async () => { - await typingCallbacks.onReplyStart(); - await statusReactions.setThinking(); - }, - }); + const replyToId = replyReference.use(); + await deliverDiscordReply({ + replies: [payload], + target: deliverTarget, + token, + accountId, + rest: client.rest, + runtime, + replyToId, + replyToMode, + textLimit, + maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + tableMode, + chunkMode, + sessionKey: ctxPayload.SessionKey, + threadBindings, + }); + replyReference.markSent(); + }, + onError: (err, info) => { + runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); + }, + onReplyStart: async () => { + await typingCallbacks.onReplyStart(); + await statusReactions.setThinking(); + }, + }); let dispatchResult: Awaited> | null = null; let dispatchError = false; @@ -738,6 +739,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // Draft cleanup should never keep typing alive. logVerbose(`discord: draft cleanup failed: ${String(err)}`); } finally { + markRunComplete(); markDispatchIdle(); } if (statusReactionsEnabled) {