Status reactions: fix stall timers and gating (#22190)

* feat: add shared status reaction controller

* feat: add statusReactions config schema

* feat: wire status reactions for Discord and Telegram

* fix: restore original 10s/30s stall defaults for Discord compatibility

* Status reactions: fix stall timers and gating

* Format status reaction imports

---------

Co-authored-by: Matt <mateus.carniatto@gmail.com>
This commit is contained in:
Shadow
2026-02-20 15:27:42 -06:00
committed by GitHub
parent 47f3979758
commit 30a0d3fce1
10 changed files with 1121 additions and 252 deletions

View File

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

View File

@@ -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<void>;
/** Remove a specific reaction emoji (optional — needed for Discord-style platforms). */
removeReaction?: (emoji: string) => Promise<void>;
};
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> | void;
setThinking: () => Promise<void> | void;
setTool: (toolName?: string) => Promise<void> | void;
setDone: () => Promise<void>;
setError: () => Promise<void>;
clear: () => Promise<void>;
restoreInitial: () => Promise<void>;
};
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
queued: "👀",
thinking: "🤔",
tool: "🔥",
coding: "👨‍💻",
web: "⚡",
done: "👍",
error: "😱",
stallSoft: "🥱",
stallHard: "😨",
};
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {
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<StatusReactionEmojis>,
): 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<StatusReactionEmojis> = {
...DEFAULT_EMOJIS,
queued: params.emojis?.queued ?? initialEmoji,
...params.emojis,
};
const timing: Required<StatusReactionTiming> = {
...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<string>([
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<void>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (!enabled) {
return;
}
clearAllTimers();
await enqueue(async () => {
await applyEmoji(initialEmoji);
pendingEmoji = "";
});
}
return {
setQueued,
setThinking,
setTool,
setDone,
setError,
clear,
restoreInitial,
};
}