Files
openclaw/src/channels/status-reactions.test.ts
Cypherm 61d219cb39 feat: show status reaction during context compaction (#35474)
Merged via squash.

Prepared head SHA: 145a7b7c4e1939718c41a300899ae813bd9c511b
Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-12 21:06:15 -07:00

491 lines
16 KiB
TypeScript

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,
};
};
const createEnabledController = (
overrides: Partial<Parameters<typeof createStatusReactionController>[0]> = {},
) => {
const { adapter, calls } = createMockAdapter();
const controller = createStatusReactionController({
enabled: true,
adapter,
initialEmoji: "👀",
...overrides,
});
return { adapter, calls, controller };
};
const createSetOnlyController = () => {
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: "👀",
});
return { calls, controller };
};
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe("resolveToolEmoji", () => {
const cases: Array<{
name: string;
tool: string | undefined;
expected: string;
}> = [
{ name: "returns coding emoji for exec tool", tool: "exec", expected: DEFAULT_EMOJIS.coding },
{
name: "returns coding emoji for process tool",
tool: "process",
expected: DEFAULT_EMOJIS.coding,
},
{
name: "returns web emoji for web_search tool",
tool: "web_search",
expected: DEFAULT_EMOJIS.web,
},
{ name: "returns web emoji for browser tool", tool: "browser", expected: DEFAULT_EMOJIS.web },
{
name: "returns tool emoji for unknown tool",
tool: "unknown_tool",
expected: DEFAULT_EMOJIS.tool,
},
{ name: "returns tool emoji for empty string", tool: "", expected: DEFAULT_EMOJIS.tool },
{ name: "returns tool emoji for undefined", tool: undefined, expected: DEFAULT_EMOJIS.tool },
{ name: "is case-insensitive", tool: "EXEC", expected: DEFAULT_EMOJIS.coding },
{
name: "matches tokens within tool names",
tool: "my_exec_wrapper",
expected: DEFAULT_EMOJIS.coding,
},
];
for (const testCase of cases) {
it(`should ${testCase.name}`, () => {
expect(resolveToolEmoji(testCase.tool, DEFAULT_EMOJIS)).toBe(testCase.expected);
});
}
});
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 { calls, controller } = createEnabledController();
void controller.setQueued();
await vi.runAllTimersAsync();
expect(calls).toContainEqual({ method: "set", emoji: "👀" });
});
it("should debounce setThinking and eventually call adapter", async () => {
const { calls, controller } = createEnabledController();
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 debounce setCompacting and eventually call adapter", async () => {
const { calls, controller } = createEnabledController();
void controller.setCompacting();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.compacting });
});
it("should classify tool name and debounce", async () => {
const { calls, controller } = createEnabledController();
void controller.setTool("exec");
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.coding });
});
const immediateTerminalCases = [
{
name: "setDone",
run: (controller: ReturnType<typeof createStatusReactionController>) => controller.setDone(),
expected: DEFAULT_EMOJIS.done,
},
{
name: "setError",
run: (controller: ReturnType<typeof createStatusReactionController>) => controller.setError(),
expected: DEFAULT_EMOJIS.error,
},
] as const;
for (const testCase of immediateTerminalCases) {
it(`should execute ${testCase.name} immediately without debounce`, async () => {
const { calls, controller } = createEnabledController();
await testCase.run(controller);
await vi.runAllTimersAsync();
expect(calls).toContainEqual({ method: "set", emoji: testCase.expected });
});
}
const terminalIgnoreCases = [
{
name: "ignore setThinking after setDone (terminal state)",
terminal: (controller: ReturnType<typeof createStatusReactionController>) =>
controller.setDone(),
followup: (controller: ReturnType<typeof createStatusReactionController>) => {
void controller.setThinking();
},
},
{
name: "ignore setTool after setError (terminal state)",
terminal: (controller: ReturnType<typeof createStatusReactionController>) =>
controller.setError(),
followup: (controller: ReturnType<typeof createStatusReactionController>) => {
void controller.setTool("exec");
},
},
] as const;
for (const testCase of terminalIgnoreCases) {
it(`should ${testCase.name}`, async () => {
const { calls, controller } = createEnabledController();
await testCase.terminal(controller);
const callsAfterTerminal = calls.length;
testCase.followup(controller);
await vi.advanceTimersByTimeAsync(1000);
expect(calls.length).toBe(callsAfterTerminal);
});
}
it("should only fire last state when rapidly changing (debounce)", async () => {
const { calls, controller } = createEnabledController();
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 { calls, controller } = createEnabledController();
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 cancel a pending compacting emoji before resuming thinking", async () => {
const { calls, controller } = createEnabledController();
void controller.setCompacting();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs - 1);
controller.cancelPending();
void controller.setThinking();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji);
expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]);
});
it("should call removeReaction when adapter supports it and emoji changes", async () => {
const { calls, controller } = createEnabledController();
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, controller } = createSetOnlyController();
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 { calls, controller } = createEnabledController();
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, controller } = createSetOnlyController();
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 { calls, controller } = createEnabledController();
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 { calls, controller } = createEnabledController({
emojis: {
thinking: "🤔",
done: "🎉",
},
});
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 { calls, controller } = createEnabledController({
timing: {
debounceMs: 100,
},
});
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 });
});
const stallCases = [
{
name: "soft stall timer after stallSoftMs",
delayMs: DEFAULT_TIMING.stallSoftMs,
expected: DEFAULT_EMOJIS.stallSoft,
},
{
name: "hard stall timer after stallHardMs",
delayMs: DEFAULT_TIMING.stallHardMs,
expected: DEFAULT_EMOJIS.stallHard,
},
] as const;
const createControllerAfterThinking = async () => {
const state = createEnabledController();
void state.controller.setThinking();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
return state;
};
for (const testCase of stallCases) {
it(`should trigger ${testCase.name}`, async () => {
const { calls } = await createControllerAfterThinking();
await vi.advanceTimersByTimeAsync(testCase.delayMs);
expect(calls).toContainEqual({ method: "set", emoji: testCase.expected });
});
}
const stallResetCases = [
{
name: "phase change",
runUpdate: (controller: ReturnType<typeof createStatusReactionController>) => {
void controller.setTool("exec");
return vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
},
},
{
name: "repeated same-phase updates",
runUpdate: (controller: ReturnType<typeof createStatusReactionController>) => {
void controller.setThinking();
return Promise.resolve();
},
},
] as const;
for (const testCase of stallResetCases) {
it(`should reset stall timers on ${testCase.name}`, async () => {
const { calls, controller } = await createControllerAfterThinking();
// Advance halfway to soft stall.
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2);
await testCase.runUpdate(controller);
// 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", () => {
for (const token of ["exec", "read", "write"]) {
expect(CODING_TOOL_TOKENS).toContain(token);
}
});
it("should export WEB_TOOL_TOKENS", () => {
for (const token of ["web_search", "browser"]) {
expect(WEB_TOOL_TOKENS).toContain(token);
}
});
it("should export DEFAULT_EMOJIS with all required keys", () => {
const emojiKeys = [
"queued",
"thinking",
"compacting",
"tool",
"coding",
"web",
"done",
"error",
"stallSoft",
"stallHard",
] as const;
for (const key of emojiKeys) {
expect(DEFAULT_EMOJIS).toHaveProperty(key);
}
});
it("should export DEFAULT_TIMING with all required keys", () => {
for (const key of ["debounceMs", "stallSoftMs", "stallHardMs", "doneHoldMs", "errorHoldMs"]) {
expect(DEFAULT_TIMING).toHaveProperty(key);
}
});
});