From 4373103c2252c1344a92c984fb4100114641e3f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 22:35:58 +0100 Subject: [PATCH] fix(channels): clear stale terminal status reactions --- CHANGELOG.md | 1 + .../status-reactions.slack-lifecycle.test.ts | 8 +++-- src/channels/status-reactions.test.ts | 33 +++++++++++++++++++ src/channels/status-reactions.ts | 1 + 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8805696354..fa8815b6853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer. - Gateway/config: allow `gateway config.patch` to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24. - Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc. +- Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx. ## 2026.4.30 diff --git a/src/channels/status-reactions.slack-lifecycle.test.ts b/src/channels/status-reactions.slack-lifecycle.test.ts index 1eebc1f4632..954ec1b31ee 100644 --- a/src/channels/status-reactions.slack-lifecycle.test.ts +++ b/src/channels/status-reactions.slack-lifecycle.test.ts @@ -65,7 +65,8 @@ describe("Slack status reaction lifecycle", () => { await ctrl.setDone(); expect(active.has(DEFAULT_EMOJIS.done)).toBe(true); - expect(active.has(DEFAULT_EMOJIS.web)).toBe(true); + expect(active.has(DEFAULT_EMOJIS.web)).toBe(false); + expect(active.has(DEFAULT_EMOJIS.thinking)).toBe(false); await ctrl.clear(); expect(active.size).toBe(0); @@ -87,7 +88,7 @@ describe("Slack status reaction lifecycle", () => { await ctrl.setError(); expect(active.has(DEFAULT_EMOJIS.error)).toBe(true); - expect(active.has("eyes")).toBe(true); + expect(active.has("eyes")).toBe(false); await ctrl.restoreInitial(); expect(active.has("eyes")).toBe(true); @@ -156,7 +157,8 @@ describe("Slack status reaction lifecycle", () => { expect(active.has("eyes")).toBe(true); expect(active.has(DEFAULT_EMOJIS.done)).toBe(false); - expect(adapter.removeReaction).toHaveBeenCalledTimes(1); + expect(adapter.removeReaction).toHaveBeenCalledTimes(2); + expect(adapter.removeReaction).toHaveBeenCalledWith("eyes"); expect(adapter.removeReaction).toHaveBeenCalledWith(DEFAULT_EMOJIS.done); expect(adapter.removeReaction).not.toHaveBeenCalledWith(DEFAULT_EMOJIS.thinking); }); diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 199ead38767..9b4ffd08fba 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -294,6 +294,39 @@ describe("createStatusReactionController", () => { expect(calls).toContainEqual({ method: "remove", emoji: DEFAULT_EMOJIS.thinking }); }); + it("should remove tracked non-terminal emojis when setting done", async () => { + const { calls, controller } = createEnabledController(); + + void controller.setQueued(); + await vi.runAllTimersAsync(); + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + void controller.setTool("exec"); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + await controller.setDone(); + + const removeEmojis = calls.filter((call) => call.method === "remove").map((call) => call.emoji); + expect(removeEmojis).toEqual( + expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, DEFAULT_EMOJIS.coding]), + ); + expect(removeEmojis).not.toContain(DEFAULT_EMOJIS.done); + }); + + it("should not remove reactions on terminal state when adapter lacks removeReaction", async () => { + const { calls, controller } = createSetOnlyController(); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + await controller.setDone(); + + expect(calls).toEqual([ + { method: "set", emoji: DEFAULT_EMOJIS.thinking }, + { method: "set", emoji: DEFAULT_EMOJIS.done }, + ]); + }); + it("should not re-add an already active reaction when returning to it", async () => { const { calls, controller } = createEnabledController(); diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts index 27d1d00523d..d392ec109bf 100644 --- a/src/channels/status-reactions.ts +++ b/src/channels/status-reactions.ts @@ -341,6 +341,7 @@ export function createStatusReactionController(params: { // Directly enqueue to ensure we return the updated promise return enqueue(async () => { await applyEmoji(emoji); + await removeActiveEmojis({ keepEmoji: emoji }); pendingEmoji = ""; }); }