diff --git a/CHANGELOG.md b/CHANGELOG.md index efc5fd03cdb..32f063444c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky. - Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant. - Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it. +- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow. ### Fixes @@ -55,6 +56,7 @@ Docs: https://docs.openclaw.ai - Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic. - Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. - Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. +- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. - Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. - Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts new file mode 100644 index 00000000000..fcccffbb266 --- /dev/null +++ b/src/channels/status-reactions.test.ts @@ -0,0 +1,543 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + resolveToolEmoji, + createStatusReactionController, + DEFAULT_EMOJIS, + DEFAULT_TIMING, + CODING_TOOL_TOKENS, + WEB_TOOL_TOKENS, + type StatusReactionAdapter, +} from "./status-reactions.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Mock Adapter +// ───────────────────────────────────────────────────────────────────────────── + +const createMockAdapter = () => { + const calls: { method: string; emoji: string }[] = []; + return { + adapter: { + setReaction: vi.fn(async (emoji: string) => { + calls.push({ method: "set", emoji }); + }), + removeReaction: vi.fn(async (emoji: string) => { + calls.push({ method: "remove", emoji }); + }), + } as StatusReactionAdapter, + calls, + }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe("resolveToolEmoji", () => { + it("should return coding emoji for exec tool", () => { + const result = resolveToolEmoji("exec", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.coding); + }); + + it("should return coding emoji for process tool", () => { + const result = resolveToolEmoji("process", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.coding); + }); + + it("should return web emoji for web_search tool", () => { + const result = resolveToolEmoji("web_search", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.web); + }); + + it("should return web emoji for browser tool", () => { + const result = resolveToolEmoji("browser", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.web); + }); + + it("should return tool emoji for unknown tool", () => { + const result = resolveToolEmoji("unknown_tool", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.tool); + }); + + it("should return tool emoji for empty string", () => { + const result = resolveToolEmoji("", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.tool); + }); + + it("should return tool emoji for undefined", () => { + const result = resolveToolEmoji(undefined, DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.tool); + }); + + it("should be case-insensitive", () => { + const result = resolveToolEmoji("EXEC", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.coding); + }); + + it("should match tokens within tool names", () => { + const result = resolveToolEmoji("my_exec_wrapper", DEFAULT_EMOJIS); + expect(result).toBe(DEFAULT_EMOJIS.coding); + }); +}); + +describe("createStatusReactionController", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("should not call adapter when disabled", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: false, + adapter, + initialEmoji: "👀", + }); + + void controller.setQueued(); + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(1000); + + expect(calls).toHaveLength(0); + }); + + it("should call setReaction with initialEmoji for setQueued immediately", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setQueued(); + await vi.runAllTimersAsync(); + + expect(calls).toContainEqual({ method: "set", emoji: "👀" }); + }); + + it("should debounce setThinking and eventually call adapter", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + + // Before debounce period + await vi.advanceTimersByTimeAsync(500); + expect(calls).toHaveLength(0); + + // After debounce period + await vi.advanceTimersByTimeAsync(300); + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); + }); + + it("should classify tool name and debounce", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setTool("exec"); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.coding }); + }); + + it("should execute setDone immediately without debounce", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + await controller.setDone(); + await vi.runAllTimersAsync(); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.done }); + }); + + it("should execute setError immediately without debounce", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + await controller.setError(); + await vi.runAllTimersAsync(); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.error }); + }); + + it("should ignore setThinking after setDone (terminal state)", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + await controller.setDone(); + const callsAfterDone = calls.length; + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(1000); + + expect(calls.length).toBe(callsAfterDone); + }); + + it("should ignore setTool after setError (terminal state)", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + await controller.setError(); + const callsAfterError = calls.length; + + void controller.setTool("exec"); + await vi.advanceTimersByTimeAsync(1000); + + expect(calls.length).toBe(callsAfterError); + }); + + it("should only fire last state when rapidly changing (debounce)", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(100); + + void controller.setTool("web_search"); + await vi.advanceTimersByTimeAsync(100); + + void controller.setTool("exec"); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Should only have the last one (exec → coding) + const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji); + expect(setEmojis).toEqual([DEFAULT_EMOJIS.coding]); + }); + + it("should deduplicate same emoji calls", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + const callsAfterFirst = calls.length; + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Should not add another call + expect(calls.length).toBe(callsAfterFirst); + }); + + it("should call removeReaction when adapter supports it and emoji changes", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setQueued(); + await vi.runAllTimersAsync(); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Should set thinking, then remove queued + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); + expect(calls).toContainEqual({ method: "remove", emoji: "👀" }); + }); + + it("should only call setReaction when adapter lacks removeReaction", async () => { + const calls: { method: string; emoji: string }[] = []; + const adapter: StatusReactionAdapter = { + setReaction: vi.fn(async (emoji: string) => { + calls.push({ method: "set", emoji }); + }), + // No removeReaction + }; + + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setQueued(); + await vi.runAllTimersAsync(); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Should only have set calls, no remove + const removeCalls = calls.filter((c) => c.method === "remove"); + expect(removeCalls).toHaveLength(0); + expect(calls.filter((c) => c.method === "set").length).toBeGreaterThan(0); + }); + + it("should clear all known emojis when adapter supports removeReaction", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setQueued(); + await vi.runAllTimersAsync(); + + await controller.clear(); + + // Should have removed multiple emojis + const removeCalls = calls.filter((c) => c.method === "remove"); + expect(removeCalls.length).toBeGreaterThan(0); + }); + + it("should handle clear gracefully when adapter lacks removeReaction", async () => { + const calls: { method: string; emoji: string }[] = []; + const adapter: StatusReactionAdapter = { + setReaction: vi.fn(async (emoji: string) => { + calls.push({ method: "set", emoji }); + }), + }; + + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + await controller.clear(); + + // Should not throw, no remove calls + const removeCalls = calls.filter((c) => c.method === "remove"); + expect(removeCalls).toHaveLength(0); + }); + + it("should restore initial emoji", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + await controller.restoreInitial(); + + expect(calls).toContainEqual({ method: "set", emoji: "👀" }); + }); + + it("should use custom emojis when provided", async () => { + const { adapter, calls } = createMockAdapter(); + const customEmojis = { + thinking: "🤔", + done: "🎉", + }; + + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + emojis: customEmojis, + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + expect(calls).toContainEqual({ method: "set", emoji: "🤔" }); + + await controller.setDone(); + await vi.runAllTimersAsync(); + expect(calls).toContainEqual({ method: "set", emoji: "🎉" }); + }); + + it("should use custom timing when provided", async () => { + const { adapter, calls } = createMockAdapter(); + const customTiming = { + debounceMs: 100, + }; + + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + timing: customTiming, + }); + + void controller.setThinking(); + + // Should not fire at 50ms + await vi.advanceTimersByTimeAsync(50); + expect(calls).toHaveLength(0); + + // Should fire at 100ms + await vi.advanceTimersByTimeAsync(60); + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); + }); + + it("should trigger soft stall timer after stallSoftMs", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Advance to soft stall threshold + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.stallSoft }); + }); + + it("should trigger hard stall timer after stallHardMs", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Advance to hard stall threshold + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallHardMs); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.stallHard }); + }); + + it("should reset stall timers on phase change", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Advance halfway to soft stall + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); + + // Change phase + void controller.setTool("exec"); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Advance another halfway - should not trigger stall yet + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); + + const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft); + expect(stallCalls).toHaveLength(0); + }); + + it("should reset stall timers on repeated same-phase updates", async () => { + const { adapter, calls } = createMockAdapter(); + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + // Advance halfway to soft stall + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); + + // Re-affirm same phase (should reset timers) + void controller.setThinking(); + + // Advance another halfway - should not trigger stall yet + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); + + const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft); + expect(stallCalls).toHaveLength(0); + }); + + it("should call onError callback when adapter throws", async () => { + const onError = vi.fn(); + const adapter: StatusReactionAdapter = { + setReaction: vi.fn(async () => { + throw new Error("Network error"); + }), + }; + + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + onError, + }); + + void controller.setQueued(); + await vi.runAllTimersAsync(); + + expect(onError).toHaveBeenCalled(); + }); +}); + +describe("constants", () => { + it("should export CODING_TOOL_TOKENS", () => { + expect(CODING_TOOL_TOKENS).toContain("exec"); + expect(CODING_TOOL_TOKENS).toContain("read"); + expect(CODING_TOOL_TOKENS).toContain("write"); + }); + + it("should export WEB_TOOL_TOKENS", () => { + expect(WEB_TOOL_TOKENS).toContain("web_search"); + expect(WEB_TOOL_TOKENS).toContain("browser"); + }); + + it("should export DEFAULT_EMOJIS with all required keys", () => { + expect(DEFAULT_EMOJIS).toHaveProperty("queued"); + expect(DEFAULT_EMOJIS).toHaveProperty("thinking"); + expect(DEFAULT_EMOJIS).toHaveProperty("tool"); + expect(DEFAULT_EMOJIS).toHaveProperty("coding"); + expect(DEFAULT_EMOJIS).toHaveProperty("web"); + expect(DEFAULT_EMOJIS).toHaveProperty("done"); + expect(DEFAULT_EMOJIS).toHaveProperty("error"); + expect(DEFAULT_EMOJIS).toHaveProperty("stallSoft"); + expect(DEFAULT_EMOJIS).toHaveProperty("stallHard"); + }); + + it("should export DEFAULT_TIMING with all required keys", () => { + expect(DEFAULT_TIMING).toHaveProperty("debounceMs"); + expect(DEFAULT_TIMING).toHaveProperty("stallSoftMs"); + expect(DEFAULT_TIMING).toHaveProperty("stallHardMs"); + expect(DEFAULT_TIMING).toHaveProperty("doneHoldMs"); + expect(DEFAULT_TIMING).toHaveProperty("errorHoldMs"); + }); +}); diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts new file mode 100644 index 00000000000..266f4199e31 --- /dev/null +++ b/src/channels/status-reactions.ts @@ -0,0 +1,390 @@ +/** + * Channel-agnostic status reaction controller. + * Provides a unified interface for displaying agent status via message reactions. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type StatusReactionAdapter = { + /** Set/replace the current reaction emoji. */ + setReaction: (emoji: string) => Promise; + /** Remove a specific reaction emoji (optional — needed for Discord-style platforms). */ + removeReaction?: (emoji: string) => Promise; +}; + +export type StatusReactionEmojis = { + queued?: string; // Default: uses initialEmoji param + thinking?: string; // Default: "🧠" + tool?: string; // Default: "🛠️" + coding?: string; // Default: "💻" + web?: string; // Default: "🌐" + done?: string; // Default: "✅" + error?: string; // Default: "❌" + stallSoft?: string; // Default: "⏳" + stallHard?: string; // Default: "⚠️" +}; + +export type StatusReactionTiming = { + debounceMs?: number; // Default: 700 + stallSoftMs?: number; // Default: 10000 + stallHardMs?: number; // Default: 30000 + doneHoldMs?: number; // Default: 1500 (not used in controller, but exported for callers) + errorHoldMs?: number; // Default: 2500 (not used in controller, but exported for callers) +}; + +export type StatusReactionController = { + setQueued: () => Promise | void; + setThinking: () => Promise | void; + setTool: (toolName?: string) => Promise | void; + setDone: () => Promise; + setError: () => Promise; + clear: () => Promise; + restoreInitial: () => Promise; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +export const DEFAULT_EMOJIS: Required = { + queued: "👀", + thinking: "🤔", + tool: "🔥", + coding: "👨‍💻", + web: "⚡", + done: "👍", + error: "😱", + stallSoft: "🥱", + stallHard: "😨", +}; + +export const DEFAULT_TIMING: Required = { + debounceMs: 700, + stallSoftMs: 10_000, + stallHardMs: 30_000, + doneHoldMs: 1500, + errorHoldMs: 2500, +}; + +export const CODING_TOOL_TOKENS: string[] = [ + "exec", + "process", + "read", + "write", + "edit", + "session_status", + "bash", +]; + +export const WEB_TOOL_TOKENS: string[] = [ + "web_search", + "web-search", + "web_fetch", + "web-fetch", + "browser", +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Functions +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve the appropriate emoji for a tool invocation. + */ +export function resolveToolEmoji( + toolName: string | undefined, + emojis: Required, +): string { + const normalized = toolName?.trim().toLowerCase() ?? ""; + if (!normalized) { + return emojis.tool; + } + if (WEB_TOOL_TOKENS.some((token) => normalized.includes(token))) { + return emojis.web; + } + if (CODING_TOOL_TOKENS.some((token) => normalized.includes(token))) { + return emojis.coding; + } + return emojis.tool; +} + +/** + * Create a status reaction controller. + * + * Features: + * - Promise chain serialization (prevents concurrent API calls) + * - Debouncing (intermediate states debounce, terminal states are immediate) + * - Stall timers (soft/hard warnings on inactivity) + * - Terminal state protection (done/error mark finished, subsequent updates ignored) + */ +export function createStatusReactionController(params: { + enabled: boolean; + adapter: StatusReactionAdapter; + initialEmoji: string; + emojis?: StatusReactionEmojis; + timing?: StatusReactionTiming; + onError?: (err: unknown) => void; +}): StatusReactionController { + const { enabled, adapter, initialEmoji, onError } = params; + + // Merge user-provided overrides with defaults + const emojis: Required = { + ...DEFAULT_EMOJIS, + queued: params.emojis?.queued ?? initialEmoji, + ...params.emojis, + }; + + const timing: Required = { + ...DEFAULT_TIMING, + ...params.timing, + }; + + // State + let currentEmoji = ""; + let pendingEmoji = ""; + let debounceTimer: NodeJS.Timeout | null = null; + let stallSoftTimer: NodeJS.Timeout | null = null; + let stallHardTimer: NodeJS.Timeout | null = null; + let finished = false; + let chainPromise = Promise.resolve(); + + // Known emojis for clear operation + const knownEmojis = new Set([ + initialEmoji, + emojis.queued, + emojis.thinking, + emojis.tool, + emojis.coding, + emojis.web, + emojis.done, + emojis.error, + emojis.stallSoft, + emojis.stallHard, + ]); + + /** + * Serialize async operations to prevent race conditions. + */ + function enqueue(fn: () => Promise): Promise { + chainPromise = chainPromise.then(fn, fn); + return chainPromise; + } + + /** + * Clear all timers. + */ + function clearAllTimers(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (stallSoftTimer) { + clearTimeout(stallSoftTimer); + stallSoftTimer = null; + } + if (stallHardTimer) { + clearTimeout(stallHardTimer); + stallHardTimer = null; + } + } + + /** + * Clear debounce timer only (used during phase transitions). + */ + function clearDebounceTimer(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + } + + /** + * Reset stall timers (called on each phase change). + */ + function resetStallTimers(): void { + if (stallSoftTimer) { + clearTimeout(stallSoftTimer); + } + if (stallHardTimer) { + clearTimeout(stallHardTimer); + } + + stallSoftTimer = setTimeout(() => { + scheduleEmoji(emojis.stallSoft, { immediate: true, skipStallReset: true }); + }, timing.stallSoftMs); + + stallHardTimer = setTimeout(() => { + scheduleEmoji(emojis.stallHard, { immediate: true, skipStallReset: true }); + }, timing.stallHardMs); + } + + /** + * Apply an emoji: set new reaction and optionally remove old one. + */ + async function applyEmoji(newEmoji: string): Promise { + if (!enabled) { + return; + } + + try { + const previousEmoji = currentEmoji; + await adapter.setReaction(newEmoji); + + // If adapter supports removeReaction and there's a different previous emoji, remove it + if (adapter.removeReaction && previousEmoji && previousEmoji !== newEmoji) { + await adapter.removeReaction(previousEmoji); + } + + currentEmoji = newEmoji; + } catch (err) { + if (onError) { + onError(err); + } + } + } + + /** + * Schedule an emoji change (debounced or immediate). + */ + function scheduleEmoji( + emoji: string, + options: { immediate?: boolean; skipStallReset?: boolean } = {}, + ): void { + if (!enabled || finished) { + return; + } + + // Deduplicate: if already scheduled/current, skip send but keep stall timers fresh + if (emoji === currentEmoji || emoji === pendingEmoji) { + if (!options.skipStallReset) { + resetStallTimers(); + } + return; + } + + pendingEmoji = emoji; + clearDebounceTimer(); + + if (options.immediate) { + // Immediate execution for terminal states + void enqueue(async () => { + await applyEmoji(emoji); + pendingEmoji = ""; + }); + } else { + // Debounced execution for intermediate states + debounceTimer = setTimeout(() => { + void enqueue(async () => { + await applyEmoji(emoji); + pendingEmoji = ""; + }); + }, timing.debounceMs); + } + + // Reset stall timers on phase change (unless triggered by stall timer itself) + if (!options.skipStallReset) { + resetStallTimers(); + } + } + + // ─────────────────────────────────────────────────────────────────────────── + // Controller API + // ─────────────────────────────────────────────────────────────────────────── + + function setQueued(): void { + scheduleEmoji(emojis.queued, { immediate: true }); + } + + function setThinking(): void { + scheduleEmoji(emojis.thinking); + } + + function setTool(toolName?: string): void { + const emoji = resolveToolEmoji(toolName, emojis); + scheduleEmoji(emoji); + } + + function setDone(): Promise { + if (!enabled) { + return Promise.resolve(); + } + + finished = true; + clearAllTimers(); + + // Directly enqueue to ensure we return the updated promise + return enqueue(async () => { + await applyEmoji(emojis.done); + pendingEmoji = ""; + }); + } + + function setError(): Promise { + if (!enabled) { + return Promise.resolve(); + } + + finished = true; + clearAllTimers(); + + // Directly enqueue to ensure we return the updated promise + return enqueue(async () => { + await applyEmoji(emojis.error); + pendingEmoji = ""; + }); + } + + async function clear(): Promise { + if (!enabled) { + return; + } + + clearAllTimers(); + finished = true; + + await enqueue(async () => { + if (adapter.removeReaction) { + // Remove all known emojis (Discord-style) + const emojisToRemove = Array.from(knownEmojis); + for (const emoji of emojisToRemove) { + try { + await adapter.removeReaction(emoji); + } catch (err) { + if (onError) { + onError(err); + } + } + } + } else { + // For platforms without removeReaction, set empty or just skip + // (Telegram handles this atomically on the next setReaction) + } + currentEmoji = ""; + pendingEmoji = ""; + }); + } + + async function restoreInitial(): Promise { + if (!enabled) { + return; + } + + clearAllTimers(); + await enqueue(async () => { + await applyEmoji(initialEmoji); + pendingEmoji = ""; + }); + } + + return { + setQueued, + setThinking, + setTool, + setDone, + setError, + clear, + restoreInitial, + }; +} diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index be65b0e3c1c..79f8bc05e3c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -373,6 +373,14 @@ export const FIELD_HELP: Record = { "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", "messages.ackReactionScope": 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "messages.statusReactions": + "Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).", + "messages.statusReactions.enabled": + "Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.", + "messages.statusReactions.emojis": + "Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", + "messages.statusReactions.timing": + "Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).", "messages.inbound.debounceMs": "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "channels.telegram.dmPolicy": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index cc7aac534a0..8822d361839 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -241,6 +241,10 @@ export const FIELD_LABELS: Record = { "messages.suppressToolErrors": "Suppress Tool Error Warnings", "messages.ackReaction": "Ack Reaction Emoji", "messages.ackReactionScope": "Ack Reaction Scope", + "messages.statusReactions": "Status Reactions", + "messages.statusReactions.enabled": "Enable Status Reactions", + "messages.statusReactions.emojis": "Status Reaction Emojis", + "messages.statusReactions.timing": "Status Reaction Timing", "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", "talk.apiKey": "Talk API Key", "channels.whatsapp": "WhatsApp", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 9a21769c605..f1f685deef9 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -49,6 +49,39 @@ export type AudioConfig = { }; }; +export type StatusReactionsEmojiConfig = { + thinking?: string; + tool?: string; + coding?: string; + web?: string; + done?: string; + error?: string; + stallSoft?: string; + stallHard?: string; +}; + +export type StatusReactionsTimingConfig = { + /** Debounce interval for intermediate states (ms). Default: 700. */ + debounceMs?: number; + /** Soft stall warning timeout (ms). Default: 25000. */ + stallSoftMs?: number; + /** Hard stall warning timeout (ms). Default: 60000. */ + stallHardMs?: number; + /** How long to hold done emoji before cleanup (ms). Default: 1500. */ + doneHoldMs?: number; + /** How long to hold error emoji before cleanup (ms). Default: 2500. */ + errorHoldMs?: number; +}; + +export type StatusReactionsConfig = { + /** Enable lifecycle status reactions (default: false). */ + enabled?: boolean; + /** Override default emojis. */ + emojis?: StatusReactionsEmojiConfig; + /** Override default timing. */ + timing?: StatusReactionsTimingConfig; +}; + export type MessagesConfig = { /** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */ messagePrefix?: string; @@ -82,6 +115,8 @@ export type MessagesConfig = { ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; /** Remove ack reaction after reply is sent (default: false). */ removeAckAfterReply?: boolean; + /** Lifecycle status reactions configuration. */ + statusReactions?: StatusReactionsConfig; /** When true, suppress ⚠️ tool-error warnings from being shown to the user. Default: false. */ suppressToolErrors?: boolean; /** Text-to-speech settings for outbound replies. */ diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 5bc55942b17..1b69b88eb9e 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -114,6 +114,35 @@ export const MessagesSchema = z ackReaction: z.string().optional(), ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(), removeAckAfterReply: z.boolean().optional(), + statusReactions: z + .object({ + enabled: z.boolean().optional(), + emojis: z + .object({ + thinking: z.string().optional(), + tool: z.string().optional(), + coding: z.string().optional(), + web: z.string().optional(), + done: z.string().optional(), + error: z.string().optional(), + stallSoft: z.string().optional(), + stallHard: z.string().optional(), + }) + .strict() + .optional(), + timing: z + .object({ + debounceMs: z.number().int().min(0).optional(), + stallSoftMs: z.number().int().min(0).optional(), + stallHardMs: z.number().int().min(0).optional(), + doneHoldMs: z.number().int().min(0).optional(), + errorHoldMs: z.number().int().min(0).optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), suppressToolErrors: z.boolean().optional(), tts: TtsConfigSchema, }) diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index a93e020d1b2..e5b862283e0 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -15,6 +15,11 @@ import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-r import { logTypingFailure, logAckFailure } from "../../channels/logging.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; +import { + createStatusReactionController, + DEFAULT_TIMING, + type StatusReactionAdapter, +} from "../../channels/status-reactions.js"; import { createTypingCallbacks } from "../../channels/typing.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; @@ -43,240 +48,12 @@ import { deliverDiscordReply } from "./reply-delivery.js"; import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js"; import { sendTyping } from "./typing.js"; -const DISCORD_STATUS_THINKING_EMOJI = "🧠"; -const DISCORD_STATUS_TOOL_EMOJI = "🛠️"; -const DISCORD_STATUS_CODING_EMOJI = "💻"; -const DISCORD_STATUS_WEB_EMOJI = "🌐"; -const DISCORD_STATUS_DONE_EMOJI = "✅"; -const DISCORD_STATUS_ERROR_EMOJI = "❌"; -const DISCORD_STATUS_STALL_SOFT_EMOJI = "⏳"; -const DISCORD_STATUS_STALL_HARD_EMOJI = "⚠️"; -const DISCORD_STATUS_DONE_HOLD_MS = 1500; -const DISCORD_STATUS_ERROR_HOLD_MS = 2500; -const DISCORD_STATUS_DEBOUNCE_MS = 700; -const DISCORD_STATUS_STALL_SOFT_MS = 10_000; -const DISCORD_STATUS_STALL_HARD_MS = 30_000; - -const CODING_STATUS_TOOL_TOKENS = [ - "exec", - "process", - "read", - "write", - "edit", - "session_status", - "bash", -]; - -const WEB_STATUS_TOOL_TOKENS = ["web_search", "web-search", "web_fetch", "web-fetch", "browser"]; - -function resolveToolStatusEmoji(toolName?: string): string { - const normalized = toolName?.trim().toLowerCase() ?? ""; - if (!normalized) { - return DISCORD_STATUS_TOOL_EMOJI; - } - if (WEB_STATUS_TOOL_TOKENS.some((token) => normalized.includes(token))) { - return DISCORD_STATUS_WEB_EMOJI; - } - if (CODING_STATUS_TOOL_TOKENS.some((token) => normalized.includes(token))) { - return DISCORD_STATUS_CODING_EMOJI; - } - return DISCORD_STATUS_TOOL_EMOJI; -} - function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } -function createDiscordStatusReactionController(params: { - enabled: boolean; - channelId: string; - messageId: string; - initialEmoji: string; - rest: unknown; -}) { - let activeEmoji: string | null = null; - let chain: Promise = Promise.resolve(); - let pendingEmoji: string | null = null; - let pendingTimer: ReturnType | null = null; - let finished = false; - let softStallTimer: ReturnType | null = null; - let hardStallTimer: ReturnType | null = null; - - const enqueue = (work: () => Promise) => { - chain = chain.then(work).catch((err) => { - logAckFailure({ - log: logVerbose, - channel: "discord", - target: `${params.channelId}/${params.messageId}`, - error: err, - }); - }); - return chain; - }; - - const clearStallTimers = () => { - if (softStallTimer) { - clearTimeout(softStallTimer); - softStallTimer = null; - } - if (hardStallTimer) { - clearTimeout(hardStallTimer); - hardStallTimer = null; - } - }; - - const clearPendingDebounce = () => { - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingTimer = null; - } - pendingEmoji = null; - }; - - const applyEmoji = (emoji: string) => - enqueue(async () => { - if (!params.enabled || !emoji || activeEmoji === emoji) { - return; - } - const previousEmoji = activeEmoji; - await reactMessageDiscord(params.channelId, params.messageId, emoji, { - rest: params.rest as never, - }); - activeEmoji = emoji; - if (previousEmoji && previousEmoji !== emoji) { - await removeReactionDiscord(params.channelId, params.messageId, previousEmoji, { - rest: params.rest as never, - }); - } - }); - - const requestEmoji = (emoji: string, options?: { immediate?: boolean }) => { - if (!params.enabled || !emoji) { - return Promise.resolve(); - } - if (options?.immediate) { - clearPendingDebounce(); - return applyEmoji(emoji); - } - pendingEmoji = emoji; - if (pendingTimer) { - clearTimeout(pendingTimer); - } - pendingTimer = setTimeout(() => { - pendingTimer = null; - const emojiToApply = pendingEmoji; - pendingEmoji = null; - if (!emojiToApply || emojiToApply === activeEmoji) { - return; - } - void applyEmoji(emojiToApply); - }, DISCORD_STATUS_DEBOUNCE_MS); - return Promise.resolve(); - }; - - const scheduleStallTimers = () => { - if (!params.enabled || finished) { - return; - } - clearStallTimers(); - softStallTimer = setTimeout(() => { - if (finished) { - return; - } - void requestEmoji(DISCORD_STATUS_STALL_SOFT_EMOJI, { immediate: true }); - }, DISCORD_STATUS_STALL_SOFT_MS); - hardStallTimer = setTimeout(() => { - if (finished) { - return; - } - void requestEmoji(DISCORD_STATUS_STALL_HARD_EMOJI, { immediate: true }); - }, DISCORD_STATUS_STALL_HARD_MS); - }; - - const setPhase = (emoji: string) => { - if (!params.enabled || finished) { - return Promise.resolve(); - } - scheduleStallTimers(); - return requestEmoji(emoji); - }; - - const setTerminal = async (emoji: string) => { - if (!params.enabled) { - return; - } - finished = true; - clearStallTimers(); - await requestEmoji(emoji, { immediate: true }); - }; - - const clear = async () => { - if (!params.enabled) { - return; - } - finished = true; - clearStallTimers(); - clearPendingDebounce(); - await enqueue(async () => { - const cleanupCandidates = new Set([ - params.initialEmoji, - activeEmoji ?? "", - DISCORD_STATUS_THINKING_EMOJI, - DISCORD_STATUS_TOOL_EMOJI, - DISCORD_STATUS_CODING_EMOJI, - DISCORD_STATUS_WEB_EMOJI, - DISCORD_STATUS_DONE_EMOJI, - DISCORD_STATUS_ERROR_EMOJI, - DISCORD_STATUS_STALL_SOFT_EMOJI, - DISCORD_STATUS_STALL_HARD_EMOJI, - ]); - activeEmoji = null; - for (const emoji of cleanupCandidates) { - if (!emoji) { - continue; - } - try { - await removeReactionDiscord(params.channelId, params.messageId, emoji, { - rest: params.rest as never, - }); - } catch (err) { - logAckFailure({ - log: logVerbose, - channel: "discord", - target: `${params.channelId}/${params.messageId}`, - error: err, - }); - } - } - }); - }; - - const restoreInitial = async () => { - if (!params.enabled) { - return; - } - finished = true; - clearStallTimers(); - clearPendingDebounce(); - await requestEmoji(params.initialEmoji, { immediate: true }); - }; - - return { - setQueued: () => { - scheduleStallTimers(); - return requestEmoji(params.initialEmoji, { immediate: true }); - }, - setThinking: () => setPhase(DISCORD_STATUS_THINKING_EMOJI), - setTool: (toolName?: string) => setPhase(resolveToolStatusEmoji(toolName)), - setDone: () => setTerminal(DISCORD_STATUS_DONE_EMOJI), - setError: () => setTerminal(DISCORD_STATUS_ERROR_EMOJI), - clear, - restoreInitial, - }; -} - export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) { const { cfg, @@ -349,12 +126,30 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }), ); const statusReactionsEnabled = shouldAckReaction(); - const statusReactions = createDiscordStatusReactionController({ + const discordAdapter: StatusReactionAdapter = { + setReaction: async (emoji) => { + await reactMessageDiscord(messageChannelId, message.id, emoji, { + rest: client.rest as never, + }); + }, + removeReaction: async (emoji) => { + await removeReactionDiscord(messageChannelId, message.id, emoji, { + rest: client.rest as never, + }); + }, + }; + const statusReactions = createStatusReactionController({ enabled: statusReactionsEnabled, - channelId: messageChannelId, - messageId: message.id, + adapter: discordAdapter, initialEmoji: ackReaction, - rest: client.rest, + onError: (err) => { + logAckFailure({ + log: logVerbose, + channel: "discord", + target: `${messageChannelId}/${message.id}`, + error: err, + }); + }, }); if (statusReactionsEnabled) { void statusReactions.setQueued(); @@ -914,7 +709,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } if (removeAckAfterReply) { void (async () => { - await sleep(dispatchError ? DISCORD_STATUS_ERROR_HOLD_MS : DISCORD_STATUS_DONE_HOLD_MS); + await sleep(dispatchError ? DEFAULT_TIMING.errorHoldMs : DEFAULT_TIMING.doneHoldMs); await statusReactions.clear(); })(); } else { diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 3be196e57f0..416e1f8fcb3 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -23,6 +23,10 @@ import { formatLocationText, toLocationContext } from "../channels/location.js"; import { logInboundDrop } from "../channels/logging.js"; import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; import { recordInboundSession } from "../channels/session.js"; +import { + createStatusReactionController, + type StatusReactionController, +} from "../channels/status-reactions.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; @@ -521,8 +525,41 @@ export const buildTelegramMessageContext = async ({ }; const reactionApi = typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null; - const ackReactionPromise = - shouldAckReaction() && msg.message_id && reactionApi + + // Status Reactions controller (lifecycle reactions) + const statusReactionsConfig = cfg.messages?.statusReactions; + const statusReactionsEnabled = + statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction(); + const statusReactionController: StatusReactionController | null = + statusReactionsEnabled && msg.message_id + ? createStatusReactionController({ + enabled: true, + adapter: { + setReaction: async (emoji: string) => { + if (reactionApi) { + await reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji }]); + } + }, + // Telegram replaces atomically — no removeReaction needed + }, + initialEmoji: ackReaction, + emojis: statusReactionsConfig?.emojis, + timing: statusReactionsConfig?.timing, + onError: (err) => { + logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`); + }, + }) + : null; + + // When status reactions are enabled, setQueued() replaces the simple ack reaction + const ackReactionPromise = statusReactionController + ? shouldAckReaction() + ? Promise.resolve(statusReactionController.setQueued()).then( + () => true, + () => false, + ) + : null + : shouldAckReaction() && msg.message_id && reactionApi ? withTelegramApiErrorLogging({ operation: "setMessageReaction", fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]), @@ -741,6 +778,7 @@ export const buildTelegramMessageContext = async ({ ackReactionPromise, reactionApi, removeAckAfterReply, + statusReactionController, accountId: account.accountId, }; }; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 14a6787b586..a2419da6586 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -119,6 +119,7 @@ export const dispatchTelegramMessage = async ({ ackReactionPromise, reactionApi, removeAckAfterReply, + statusReactionController, } = context; const draftMaxChars = Math.min(textLimit, 4096); @@ -545,6 +546,11 @@ export const dispatchTelegramMessage = async ({ }; let queuedFinal = false; + + if (statusReactionController) { + void statusReactionController.setThinking(); + } + try { ({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, @@ -691,6 +697,11 @@ export const dispatchTelegramMessage = async ({ splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; } : undefined, + onToolStart: statusReactionController + ? async (payload) => { + await statusReactionController.setTool(payload.name); + } + : undefined, onModelSelected, }, })); @@ -737,26 +748,40 @@ export const dispatchTelegramMessage = async ({ } const hasFinalResponse = queuedFinal || sentFallback; + + if (statusReactionController && !hasFinalResponse) { + void statusReactionController.setError().catch((err) => { + logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`); + }); + } + if (!hasFinalResponse) { clearGroupHistory(); return; } - removeAckReactionAfterReply({ - removeAfterReply: removeAckAfterReply, - ackReactionPromise, - ackReactionValue: ackReactionPromise ? "ack" : null, - remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(), - onError: (err) => { - if (!msg.message_id) { - return; - } - logAckFailure({ - log: logVerbose, - channel: "telegram", - target: `${chatId}/${msg.message_id}`, - error: err, - }); - }, - }); + + if (statusReactionController) { + void statusReactionController.setDone().catch((err) => { + logVerbose(`telegram: status reaction finalize failed: ${String(err)}`); + }); + } else { + removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionPromise ? "ack" : null, + remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(), + onError: (err) => { + if (!msg.message_id) { + return; + } + logAckFailure({ + log: logVerbose, + channel: "telegram", + target: `${chatId}/${msg.message_id}`, + error: err, + }); + }, + }); + } clearGroupHistory(); };