UNPKG

@agentica/core

Version:

Agentic AI Library specialized in LLM Function Calling

910 lines (815 loc) 25.7 kB
import type { ChatCompletionChunk } from "openai/resources"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { reduceStreamingWithDispatch } from "./ChatGptCompletionStreamingUtil"; import { StreamUtil } from "./StreamUtil"; describe("reduceStreamingWithDispatch", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("basic functionality", () => { it("should process single chunk successfully", async () => { const mockChunk: ChatCompletionChunk = { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { controller.enqueue(mockChunk); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(result.object).toBe("chat.completion"); expect(eventProcessor).toHaveBeenCalledTimes(1); }); it("should handle multiple chunks with content accumulation", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " World" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "!" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(result.object).toBe("chat.completion"); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello World!"); }); it("should handle empty content chunks", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello"); }); }); describe("multiple choices handling", () => { it("should handle multiple choices with different indices", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Choice 1" }, finish_reason: null, }, { index: 1, delta: { content: "Choice 2" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " continued" }, finish_reason: "stop", }, { index: 1, delta: { content: " continued" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(2); const firstCall = eventProcessor.mock.calls[0]?.[0]; const secondCall = eventProcessor.mock.calls[1]?.[0]; expect(firstCall.get()).toBe("Choice 1 continued"); expect(secondCall.get()).toBe("Choice 2 continued"); }); }); describe("finish reason handling", () => { it("should close context when finish_reason is provided", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " World" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello World"); expect(eventCall.done()).toBe(true); }); }); describe("stream processing", () => { it("should provide working stream in event processor", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " World" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const streamedContent: string[] = []; await new Promise(async (resolve) => { const eventProcessor = vi.fn(({ stream: contentStream }) => { void (async () => { for await (const content of contentStream) { streamedContent.push(content as string); } resolve(true); })().catch(() => {}); }); await reduceStreamingWithDispatch(stream, eventProcessor); }); expect(streamedContent).toEqual(["Hello", " World"]); }); it("should provide working join function", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " World" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); let joinedContent = ""; const eventProcessor = vi.fn(async ({ join }) => { joinedContent = await join(); }); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(joinedContent).toBe("Hello World"); }); }); describe("error handling", () => { it("should throw error for empty stream", async () => { const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { controller.close(); }, }); const eventProcessor = vi.fn(); await expect(reduceStreamingWithDispatch(stream, eventProcessor)).rejects.toThrow( "StreamUtil.reduce did not produce a ChatCompletion", ); }); it("should handle stream with only finish_reason chunks", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: null }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).not.toHaveBeenCalled(); }); }); describe("complex scenarios", () => { it("should handle mixed content and finish_reason chunks", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: null }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " World" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: null }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello World"); }); }); describe("edge cases and exceptions", () => { it("should handle null delta content", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: null }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello"); }); it("should handle missing delta object", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: {}, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello"); }); it("should handle chunks with no choices", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe("Hello"); }); it("should handle very large content chunks", async () => { const largeContent = "x".repeat(10000); const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: largeContent }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); // Now single chunk with content should trigger eventProcessor expect(eventProcessor).toHaveBeenCalledOnce(); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe(largeContent); }); it("should handle rapid consecutive chunks", async () => { const chunks: ChatCompletionChunk[] = Array.from({ length: 100 }, (_, i) => ({ id: "test-id", object: "chat.completion.chunk" as const, created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: i.toString() }, finish_reason: i === 99 ? "stop" as const : null, }, ], })); const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(1); const eventCall = eventProcessor.mock.calls[0]?.[0]; const expectedContent = Array.from({ length: 100 }, (_, i) => i.toString()).join(""); expect(eventCall.get()).toBe(expectedContent); }); it("should handle out-of-order choice indices", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 2, delta: { content: "Third" }, finish_reason: null, }, { index: 0, delta: { content: "First" }, finish_reason: null, }, { index: 1, delta: { content: "Second" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " content" }, finish_reason: "stop", }, { index: 1, delta: { content: " content" }, finish_reason: "stop", }, { index: 2, delta: { content: " content" }, finish_reason: "stop", }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { chunks.forEach(chunk => controller.enqueue(chunk)); controller.close(); }, }); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(3); const calls = eventProcessor.mock.calls.map(call => call[0]); expect(calls[0].get()).toBe("Third content"); expect(calls[1].get()).toBe("First content"); expect(calls[2].get()).toBe("Second content"); }); it("should handle mixed finish reasons", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, { index: 1, delta: { content: "World" }, finish_reason: null, }, ], }, { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: " there" }, finish_reason: "stop", }, { index: 1, delta: { content: "!" }, finish_reason: "length", }, ], }, ]; const stream = StreamUtil.from(...chunks); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); expect(eventProcessor).toHaveBeenCalledTimes(2); const firstCall = eventProcessor.mock.calls[0]?.[0]; const secondCall = eventProcessor.mock.calls[1]?.[0]; expect(firstCall.get()).toBe("Hello there"); expect(secondCall.get()).toBe("World!"); await firstCall.join(); await secondCall.join(); expect(firstCall.done()).toBe(true); expect(secondCall.done()).toBe(true); }); it("should handle Unicode and special characters", async () => { const specialContent = "Hello 🌍! 안녕하세요 مرحبا 🚀 ñáéíóú"; const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: specialContent }, finish_reason: "stop", }, ], }, ]; const stream = StreamUtil.from(...chunks); const eventProcessor = vi.fn(); const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); // Now single chunk with content should trigger eventProcessor expect(eventProcessor).toHaveBeenCalledOnce(); const eventCall = eventProcessor.mock.calls[0]?.[0]; expect(eventCall.get()).toBe(specialContent); }); it("should handle stream reader errors gracefully", async () => { const chunks: ChatCompletionChunk[] = [ { id: "test-id", object: "chat.completion.chunk", created: 1234567890, model: "gpt-3.5-turbo", choices: [ { index: 0, delta: { content: "Hello" }, finish_reason: null, }, ], }, ]; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { controller.enqueue(chunks[0]); // Simulate an error in the stream controller.error(new Error("Stream error")); }, }); const eventProcessor = vi.fn(); await expect(reduceStreamingWithDispatch(stream, eventProcessor)) .rejects .toThrow("Stream error"); }); it("should handle completely malformed chunks gracefully", async () => { const malformedChunk = { // Missing required fields object: "chat.completion.chunk", choices: [ { // Missing index delta: { content: "Hello" }, finish_reason: null, }, ], } as any; const stream = new ReadableStream<ChatCompletionChunk>({ start(controller) { controller.enqueue(malformedChunk as ChatCompletionChunk); controller.close(); }, }); const eventProcessor = vi.fn(); // Should not throw, but should handle gracefully const result = await reduceStreamingWithDispatch(stream, eventProcessor); expect(result).toBeDefined(); }); }); });