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 streaming 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 = {}) { const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", ...overrides, }); return { result, options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0], }; } it("keeps auto mode plain text on non-streaming send path", async () => { const { options } = createDispatcherHarness(); 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 () => { const { options } = createDispatcherHarness(); 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("sets disableBlockStreaming in replyOptions to prevent silent reply drops", async () => { const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, chatId: "oc_chat", }); expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true); }); it("uses streaming session for auto mode markdown payloads", async () => { const { options } = createDispatcherHarness({ runtime: createRuntimeLogger(), rootId: "om_root_topic", }); 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("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, options } = createDispatcherHarness({ runtime: createRuntimeLogger(), }); 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 () => { const { options } = createDispatcherHarness(); 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 () => { const { options } = createDispatcherHarness(); 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 () => { const { options } = createDispatcherHarness({ runtime: createRuntimeLogger(), }); 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", }), ); }); });