import { describe, expect, it, vi } from "vitest"; const streamInstances = vi.hoisted( () => [] as Array<{ hasContent: boolean; isFinalized: boolean; isFailed: boolean; streamedLength: number; messageId?: string; previewStreamId?: string; sendInformativeUpdate: ReturnType; update: ReturnType; replaceInformativeWithFinal: ReturnType; finalize: ReturnType; }>, ); vi.mock("./streaming-message.js", () => ({ TeamsHttpStream: class { hasContent = false; isFinalized = false; isFailed = false; streamedLength = 0; messageId: string | undefined; previewStreamId = "preview-stream"; sendInformativeUpdate = vi.fn(async () => {}); update = vi.fn(function ( this: { hasContent: boolean; isFailed: boolean; streamedLength: number }, payloadText?: string, ) { if ((payloadText?.length ?? 0) > 4000) { this.hasContent = false; this.isFailed = true; this.streamedLength = 0; return; } this.hasContent = true; this.streamedLength = payloadText?.length ?? 0; }); replaceInformativeWithFinal = vi.fn(async function ( this: { hasContent: boolean; isFailed: boolean; isFinalized: boolean; streamedLength: number; messageId?: string; update: (payloadText?: string) => void; }, payloadText: string, ) { this.update(payloadText); if (this.isFailed) { return false; } this.isFinalized = true; this.messageId = "final-message"; return this.hasContent; }); finalize = vi.fn(async function (this: { isFinalized: boolean; messageId?: string }) { this.isFinalized = true; this.messageId = "final-message"; }); constructor() { streamInstances.push(this as never); } }, })); import { createTeamsReplyStreamController } from "./reply-stream-controller.js"; describe("createTeamsReplyStreamController", () => { function createController() { streamInstances.length = 0; return createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, }); } it("suppresses fallback for first text segment that was streamed", async () => { const ctrl = createController(); ctrl.onPartialReply({ text: "Hello world" }); const result = await ctrl.preparePayload({ text: "Hello world" }); expect(result).toBeUndefined(); }); it("when stream fails after partial delivery, fallback sends only remaining text", async () => { const ctrl = createController(); const fullText = "a".repeat(4000) + "b".repeat(200); ctrl.onPartialReply({ text: fullText }); streamInstances[0].hasContent = false; streamInstances[0].isFailed = true; streamInstances[0].isFinalized = true; streamInstances[0].streamedLength = 4000; const result = await ctrl.preparePayload({ text: fullText }); expect(result).toEqual({ text: "b".repeat(200) }); }); it("when stream fails before sending content, fallback sends full text", async () => { const ctrl = createController(); const fullText = "Failure at first chunk"; ctrl.onPartialReply({ text: fullText }); streamInstances[0].hasContent = false; streamInstances[0].isFailed = true; streamInstances[0].isFinalized = true; streamInstances[0].streamedLength = 0; const result = await ctrl.preparePayload({ text: fullText }); expect(result).toEqual({ text: fullText }); }); it("allows fallback delivery for second text segment after tool calls", async () => { const ctrl = createController(); // First text segment: streaming tokens arrive ctrl.onPartialReply({ text: "First segment" }); // First segment complete: preparePayload suppresses (stream handled it) const result1 = await ctrl.preparePayload({ text: "First segment" }); expect(result1).toBeUndefined(); // Tool calls happen... then second text segment arrives via deliver() // preparePayload should allow fallback delivery for this segment const result2 = await ctrl.preparePayload({ text: "Second segment after tools" }); expect(result2).toEqual({ text: "Second segment after tools" }); }); it("finalizes the stream when suppressing first segment", async () => { const ctrl = createController(); ctrl.onPartialReply({ text: "Streamed text" }); await ctrl.preparePayload({ text: "Streamed text" }); await ctrl.finalize(); expect(streamInstances[0]?.finalize).toHaveBeenCalled(); expect(ctrl.liveState().phase).toBe("finalized"); expect(ctrl.liveState().receipt?.primaryPlatformMessageId).toBe("final-message"); }); it("uses fallback even when onPartialReply fires after stream finalized", async () => { const ctrl = createController(); // First text segment: streaming tokens arrive ctrl.onPartialReply({ text: "First segment" }); // First segment complete: preparePayload suppresses and finalizes stream const result1 = await ctrl.preparePayload({ text: "First segment" }); expect(result1).toBeUndefined(); expect(streamInstances[0]?.isFinalized).toBe(true); // Post-tool partial replies fire again (stream.update is a no-op since finalized) ctrl.onPartialReply({ text: "Second segment" }); // Must still use fallback because stream is finalized and can't deliver const result2 = await ctrl.preparePayload({ text: "Second segment" }); expect(result2).toEqual({ text: "Second segment" }); }); it("delivers all segments across 3+ tool call rounds", async () => { const ctrl = createController(); // Round 1: text → tool ctrl.onPartialReply({ text: "Segment 1" }); await expect(ctrl.preparePayload({ text: "Segment 1" })).resolves.toBeUndefined(); // Round 2: text → tool ctrl.onPartialReply({ text: "Segment 2" }); const r2 = await ctrl.preparePayload({ text: "Segment 2" }); expect(r2).toEqual({ text: "Segment 2" }); // Round 3: final text ctrl.onPartialReply({ text: "Segment 3" }); const r3 = await ctrl.preparePayload({ text: "Segment 3" }); expect(r3).toEqual({ text: "Segment 3" }); }); it("passes media+text payload through fully after stream finalized", async () => { const ctrl = createController(); // First segment streamed and finalized ctrl.onPartialReply({ text: "Streamed text" }); await ctrl.preparePayload({ text: "Streamed text" }); // Second segment has both text and media — should pass through fully const result = await ctrl.preparePayload({ text: "Post-tool text with image", mediaUrl: "https://example.com/tool-output.png", }); expect(result).toEqual({ text: "Post-tool text with image", mediaUrl: "https://example.com/tool-output.png", }); }); it("still strips text from media payloads when stream handled text", async () => { const ctrl = createController(); ctrl.onPartialReply({ text: "Some text" }); const result = await ctrl.preparePayload({ text: "Some text", mediaUrl: "https://example.com/image.png", }); expect(result).toEqual({ text: undefined, mediaUrl: "https://example.com/image.png", }); }); it("falls back to normal delivery when progress final streaming fails", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "progress" } } as never, }); await ctrl.noteProgressWork({ toolName: "exec" }); await ctrl.noteProgressWork(); const fullText = "x".repeat(4200); const result = await ctrl.preparePayload({ text: fullText }); expect(result).toEqual({ text: fullText }); expect(streamInstances[0]?.replaceInformativeWithFinal).toHaveBeenCalledWith(fullText); }); it("records lifecycle receipt when progress final streaming succeeds", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "progress" } } as never, }); await ctrl.noteProgressWork({ toolName: "exec" }); await ctrl.noteProgressWork(); await expect(ctrl.preparePayload({ text: "complete final answer" })).resolves.toBeUndefined(); expect(ctrl.liveState().phase).toBe("finalized"); expect(ctrl.liveState().receipt?.primaryPlatformMessageId).toBe("final-message"); }); it("falls back with full text when progress final send fails after streaming text", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "progress" } } as never, }); await ctrl.onReplyStart(); streamInstances[0].replaceInformativeWithFinal.mockImplementationOnce( async function (this: { hasContent: boolean; isFailed: boolean; isFinalized: boolean; streamedLength: number; }) { this.hasContent = true; this.isFailed = true; this.isFinalized = true; this.streamedLength = 12; return false; }, ); const result = await ctrl.preparePayload({ text: "complete final answer" }); expect(result).toEqual({ text: "complete final answer" }); }); it("honors disabled Teams progress labels", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "progress", progress: { label: false } } } as never, }); await ctrl.onReplyStart(); expect(streamInstances).toHaveLength(1); expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled(); }); it("streams compact Teams progress lines when tool progress is enabled", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "progress", progress: { label: "Working", maxLines: 1, }, }, } as never, }); await ctrl.pushProgressLine("tool: search"); await ctrl.pushProgressLine("tool: exec"); expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "progress", progress: { toolProgress: false, }, }, } as never, }); await ctrl.pushProgressLine("tool: search"); expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(false); expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled(); }); it("does not start native streaming for Teams block mode", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "personal", context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, msteamsConfig: { streaming: { mode: "block" } } as never, }); await ctrl.onReplyStart(); ctrl.onPartialReply({ text: "block partial" }); expect(streamInstances).toHaveLength(0); await expect(ctrl.preparePayload({ text: "block final" })).resolves.toEqual({ text: "block final", }); expect(ctrl.hasStream()).toBe(false); }); describe("isStreamActive", () => { it("returns false before any tokens arrive so typing keepalive can warm up", () => { const ctrl = createController(); expect(ctrl.isStreamActive()).toBe(false); }); it("returns false after the informative update but before tokens arrive", async () => { const ctrl = createController(); await ctrl.onReplyStart(); expect(ctrl.isStreamActive()).toBe(false); }); it("returns true while the stream is actively receiving tokens", () => { const ctrl = createController(); ctrl.onPartialReply({ text: "Streaming tokens" }); expect(ctrl.isStreamActive()).toBe(true); }); it("returns false after the stream is finalized between tool rounds", async () => { const ctrl = createController(); ctrl.onPartialReply({ text: "First segment" }); expect(ctrl.isStreamActive()).toBe(true); // First segment complete: stream is finalized so the typing keepalive // can resume during the tool chain that follows. await ctrl.preparePayload({ text: "First segment" }); expect(ctrl.isStreamActive()).toBe(false); }); it("returns false when the stream has failed", () => { const ctrl = createController(); ctrl.onPartialReply({ text: "First segment" }); expect(ctrl.isStreamActive()).toBe(true); streamInstances[0].isFailed = true; expect(ctrl.isStreamActive()).toBe(false); }); it("returns false when conversationType is not personal", () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ conversationType: "channel", context: { sendActivity: vi.fn() } as never, feedbackLoopEnabled: false, log: { debug: vi.fn() } as never, }); ctrl.onPartialReply({ text: "anything" }); expect(ctrl.isStreamActive()).toBe(false); }); }); });