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 finalization behavior", () => { type ReplyDispatcherArgs = Parameters[0]; 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), }, }, }); }); function setupNonStreamingAutoDispatcher() { 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", }); return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; } function createRuntimeLogger() { return { log: vi.fn(), error: vi.fn() } as never; } function createDispatcherHarness(overrides: Partial = {}) { createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", ...overrides, }); return { options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0], }; } it("closes streaming with block text when final reply is missing", async () => { const { options } = createDispatcherHarness({ runtime: createRuntimeLogger(), }); 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 () => { const { options } = createDispatcherHarness({ runtime: createRuntimeLogger(), }); 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 () => { const { options } = createDispatcherHarness({ runtime: createRuntimeLogger(), }); 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 () => { const options = setupNonStreamingAutoDispatcher(); 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 () => { const options = setupNonStreamingAutoDispatcher(); 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" }), ); }); });