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", () => ({ mergeStreamingText: (previousText: string | undefined, nextText: string | undefined) => { const previous = typeof previousText === "string" ? previousText : ""; const next = typeof nextText === "string" ? nextText : ""; if (!next) { return previous; } if (!previous || next === previous) { return next; } if (next.startsWith(previous)) { return next; } if (previous.startsWith(next)) { return previous; } return `${previous}${next}`; }, 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 streaming behavior", () => { beforeEach(() => { vi.clearAllMocks(); streamingInstances.length = 0; sendMediaFeishuMock.mockResolvedValue(undefined); 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 typing indicator 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 typing indicator 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 typing 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("keeps typing indicator for fresh messages", 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(addTypingIndicatorMock).toHaveBeenCalledTimes(1); expect(addTypingIndicatorMock).toHaveBeenCalledWith( expect.objectContaining({ messageId: "om_parent", }), ); }); it("keeps auto mode plain text on non-streaming send path", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "plain text" }, { kind: "final" }); expect(streamingInstances).toHaveLength(0); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("suppresses internal block payload delivery", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" }); expect(streamingInstances).toHaveLength(0); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); expect(sendMediaFeishuMock).not.toHaveBeenCalled(); }); it("uses streaming session for auto mode markdown payloads", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", rootId: "om_root_topic", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { replyToMessageId: undefined, replyInThread: undefined, rootId: "om_root_topic", }); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("closes streaming with block text when final reply is missing", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" }); await options.onIdle?.(); expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```"); }); it("delivers distinct final payloads after streaming close", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" }); await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(2); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```"); expect(streamingInstances[1].close).toHaveBeenCalledTimes(1); expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```"); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("skips exact duplicate final text after streaming close", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" }); await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```"); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("suppresses duplicate final text while still sending media", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", appSecret: "app_secret", domain: "feishu", config: { renderMode: "auto", streaming: false, }, }); createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "plain final" }, { kind: "final" }); await options.deliver( { text: "plain final", mediaUrl: "https://example.com/a.png" }, { kind: "final" }, ); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); expect(sendMessageFeishuMock).toHaveBeenLastCalledWith( expect.objectContaining({ text: "plain final", }), ); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ mediaUrl: "https://example.com/a.png", }), ); }); it("keeps distinct non-streaming final payloads", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", appSecret: "app_secret", domain: "feishu", config: { renderMode: "auto", streaming: false, }, }); createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "notice header" }, { kind: "final" }); await options.deliver({ text: "actual answer body" }, { kind: "final" }); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2); expect(sendMessageFeishuMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ text: "notice header" }), ); expect(sendMessageFeishuMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ text: "actual answer body" }), ); }); it("treats block updates as delta chunks", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", appSecret: "app_secret", domain: "feishu", config: { renderMode: "card", streaming: true, }, }); const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); await result.replyOptions.onPartialReply?.({ text: "hello" }); await options.deliver({ text: "lo world" }, { kind: "block" }); await options.onIdle?.(); expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world"); }); it("sends media-only payloads as attachments", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ to: "oc_chat", mediaUrl: "https://example.com/a.png", }), ); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver( { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] }, { kind: "final" }, ); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ mediaUrl: "https://example.com/a.png", }), ); }); it("sends attachments after streaming final markdown replies", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver( { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] }, { kind: "final" }, ); expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ mediaUrl: "https://example.com/a.png", }), ); }); it("passes replyInThread to sendMessageFeishu for plain text", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_msg", replyInThread: true, }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "plain text" }, { kind: "final" }); expect(sendMessageFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_msg", replyInThread: true, }), ); }); it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", appSecret: "app_secret", domain: "feishu", config: { renderMode: "card", streaming: false, }, }); createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_msg", replyInThread: true, }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "card text" }, { kind: "final" }); expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_msg", replyInThread: true, }), ); }); it("passes replyToMessageId and replyInThread to streaming.start()", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", replyToMessageId: "om_msg", replyInThread: true, }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { replyToMessageId: "om_msg", replyInThread: true, }); }); it("disables streaming for thread replies and keeps reply metadata", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, chatId: "oc_chat", replyToMessageId: "om_msg", replyInThread: false, threadReply: true, rootId: "om_root_topic", }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(0); expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_msg", replyInThread: true, }), ); }); it("passes replyInThread to media attachments", async () => { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", replyToMessageId: "om_msg", replyInThread: true, }); const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); expect(sendMediaFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_msg", replyInThread: true, }), ); }); });