
























@@ -399,6 +399,46 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
399399expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
400400});
401401402+it("skips distinct late final text after streaming card close", async () => {
403+resolveFeishuAccountMock.mockReturnValue({
404+accountId: "main",
405+appId: "app_id",
406+appSecret: "app_secret",
407+domain: "feishu",
408+config: {
409+renderMode: "card",
410+streaming: true,
411+},
412+});
413+414+const { options } = createDispatcherHarness({
415+runtime: createRuntimeLogger(),
416+});
417+418+await options.deliver({ text: "First complete answer" }, { kind: "final" });
419+await options.onIdle?.();
420+await options.deliver(
421+{ text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
422+{ kind: "final" },
423+);
424+await options.onIdle?.();
425+426+expect(streamingInstances).toHaveLength(1);
427+expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
428+expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
429+note: "Agent: agent",
430+});
431+expect(sendMessageFeishuMock).not.toHaveBeenCalled();
432+expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
433+expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
434+expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
435+expect(sendMediaFeishuMock).toHaveBeenCalledWith(
436+expect.objectContaining({
437+mediaUrl: "https://example.com/a.png",
438+}),
439+);
440+});
441+402442it("suppresses duplicate final text while still sending media", async () => {
403443const options = setupNonStreamingAutoDispatcher();
404444await options.deliver({ text: "plain final" }, { kind: "final" });
@@ -918,6 +958,86 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
918958});
919959});
920960961+it("does not suppress a later final after error closeout", async () => {
962+resolveFeishuAccountMock.mockReturnValue({
963+accountId: "main",
964+appId: "app_id",
965+appSecret: "app_secret",
966+domain: "feishu",
967+config: {
968+renderMode: "card",
969+streaming: true,
970+},
971+});
972+sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
973+974+const { options } = createDispatcherHarness({
975+runtime: createRuntimeLogger(),
976+});
977+978+await expect(
979+options.deliver(
980+{ text: "First answer", mediaUrl: "https://example.com/a.png" },
981+{ kind: "final" },
982+),
983+).rejects.toThrow("media failed");
984+await Promise.all([
985+options.onError?.(new Error("media failed"), { kind: "final" }),
986+options.onIdle?.(),
987+]);
988+await options.deliver({ text: "Second answer" }, { kind: "final" });
989+await options.onIdle?.();
990+991+expect(streamingInstances).toHaveLength(2);
992+expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
993+note: "Agent: agent",
994+});
995+expect(streamingInstances[1].close).toHaveBeenCalledWith("Second answer", {
996+note: "Agent: agent",
997+});
998+expect(sendMessageFeishuMock).not.toHaveBeenCalled();
999+expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1000+});
1001+1002+it("does not suppress a recovery final after late media failure", async () => {
1003+resolveFeishuAccountMock.mockReturnValue({
1004+accountId: "main",
1005+appId: "app_id",
1006+appSecret: "app_secret",
1007+domain: "feishu",
1008+config: {
1009+renderMode: "card",
1010+streaming: true,
1011+},
1012+});
1013+1014+const { options } = createDispatcherHarness({
1015+runtime: createRuntimeLogger(),
1016+});
1017+1018+await options.deliver({ text: "First answer" }, { kind: "final" });
1019+await options.onIdle?.();
1020+sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1021+await expect(
1022+options.deliver(
1023+{ text: "Late attachment", mediaUrl: "https://example.com/a.png" },
1024+{ kind: "final" },
1025+),
1026+).rejects.toThrow("media failed");
1027+await options.onError?.(new Error("media failed"), { kind: "final" });
1028+await options.deliver({ text: "Recovered answer" }, { kind: "final" });
1029+await options.onIdle?.();
1030+1031+expect(streamingInstances).toHaveLength(2);
1032+expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
1033+note: "Agent: agent",
1034+});
1035+expect(streamingInstances[1].close).toHaveBeenCalledWith("Recovered answer", {
1036+note: "Agent: agent",
1037+});
1038+expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1039+});
1040+9211041it("cleans streaming state even when close throws", async () => {
9221042const origPush = streamingInstances.push.bind(streamingInstances);
9231043streamingInstances.push = (...args: StreamingSessionStub[]) => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。