import { beforeEach, describe, expect, it, vi } from "vitest"; const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); const getFeishuRuntimeMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" }))); const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {})); const streamingInstances = vi.hoisted(() => [] as any[]); vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock })); vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock })); vi.mock("./send.js", () => ({ sendMessageFeishu: sendMessageFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, })); vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock })); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock })); vi.mock("./typing.js", () => ({ addTypingIndicator: addTypingIndicatorMock, removeTypingIndicator: removeTypingIndicatorMock, })); vi.mock("./streaming-card.js", async () => { const actual = await vi.importActual("./streaming-card.js"); return { mergeStreamingText: actual.mergeStreamingText, FeishuStreamingSession: class { active = false; start = vi.fn(async () => { this.active = true; }); update = vi.fn(async () => {}); close = vi.fn(async () => { this.active = false; }); isActive = vi.fn(() => this.active); constructor() { streamingInstances.push(this); } }, }; }); import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; describe("createFeishuReplyDispatcher receipt reaction", () => { beforeEach(() => { vi.useRealTimers(); }); beforeEach(() => { vi.clearAllMocks(); streamingInstances.length = 0; resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", appSecret: "app_secret", domain: "feishu", config: { renderMode: "auto", streaming: true, }, }); resolveReceiveIdTypeMock.mockReturnValue("chat_id"); createFeishuClientMock.mockReturnValue({}); createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({ dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn(), _opts: opts, })); getFeishuRuntimeMock.mockReturnValue({ channel: { text: { resolveTextChunkLimit: vi.fn(() => 4000), resolveChunkMode: vi.fn(() => "line"), resolveMarkdownTableMode: vi.fn(() => "preserve"), convertMarkdownTables: vi.fn((text) => text), chunkTextWithMode: vi.fn((text) => [text]), }, reply: { createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, resolveHumanDelayConfig: vi.fn(() => undefined), }, }, }); }); it("skips receipt reaction when account typingIndicator is disabled", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", appSecret: "app_secret", domain: "feishu", config: { renderMode: "auto", streaming: true, typingIndicator: false, }, }); createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_parent", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); it("skips receipt reaction for stale replayed messages", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_parent", messageCreateTimeMs: Date.now() - 3 * 60_000, }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); it("treats second-based timestamps as stale for receipt suppression", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_parent", messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000), }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); it("falls back to receipt reaction for fresh messages even when streaming is enabled", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_parent", messageCreateTimeMs: Date.now() - 30_000, }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); expect(streamingInstances).toHaveLength(0); expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1); expect(addTypingIndicatorMock).toHaveBeenCalledWith( expect.objectContaining({ messageId: "om_parent", }), ); }); it("adds a receipt reaction immediately on reply start instead of starting streaming", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", replyToMessageId: "om_parent", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); expect(streamingInstances).toHaveLength(0); expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1); expect(addTypingIndicatorMock).toHaveBeenCalledWith( expect.objectContaining({ messageId: "om_parent", }), ); }); });