mirror of
				https://github.com/TriliumNext/Notes.git
				synced 2025-10-31 04:51:31 +08:00 
			
		
		
		
	feat(llm): add tests for streaming
This commit is contained in:
		
							parent
							
								
									c1bcb73337
								
							
						
					
					
						commit
						c6f2124e9d
					
				
							
								
								
									
										498
									
								
								apps/server-e2e/src/llm_streaming.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										498
									
								
								apps/server-e2e/src/llm_streaming.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,498 @@ | ||||
| import { test, expect, type Page } from '@playwright/test'; | ||||
| import type { WebSocket } from 'ws'; | ||||
| 
 | ||||
| interface StreamMessage { | ||||
|     type: string; | ||||
|     chatNoteId?: string; | ||||
|     content?: string; | ||||
|     thinking?: string; | ||||
|     toolExecution?: any; | ||||
|     done?: boolean; | ||||
|     error?: string; | ||||
| } | ||||
| 
 | ||||
| interface ChatSession { | ||||
|     id: string; | ||||
|     title: string; | ||||
|     messages: Array<{ role: string; content: string }>; | ||||
|     createdAt: string; | ||||
| } | ||||
| 
 | ||||
| test.describe('LLM Streaming E2E Tests', () => { | ||||
|     let chatSessionId: string; | ||||
| 
 | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // Navigate to the application
 | ||||
|         await page.goto('/'); | ||||
|          | ||||
|         // Wait for the application to load
 | ||||
|         await page.waitForSelector('[data-testid="app-loaded"]', { timeout: 10000 }); | ||||
|          | ||||
|         // Create a new chat session for testing
 | ||||
|         const response = await page.request.post('/api/llm/chat', { | ||||
|             data: { | ||||
|                 title: 'E2E Streaming Test Chat' | ||||
|             } | ||||
|         }); | ||||
|          | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const chatData: ChatSession = await response.json(); | ||||
|         chatSessionId = chatData.id; | ||||
|     }); | ||||
| 
 | ||||
|     test.afterEach(async ({ page }) => { | ||||
|         // Clean up the chat session
 | ||||
|         if (chatSessionId) { | ||||
|             await page.request.delete(`/api/llm/chat/${chatSessionId}`); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     test('should establish WebSocket connection and receive streaming messages', async ({ page }) => { | ||||
|         // Set up WebSocket message collection
 | ||||
|         const streamMessages: StreamMessage[] = []; | ||||
|          | ||||
|         // Monitor WebSocket messages
 | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|              | ||||
|             // Mock WebSocket to capture messages
 | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                      | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                             } | ||||
|                         } catch (e) { | ||||
|                             // Ignore invalid JSON
 | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         // Navigate to chat interface
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|          | ||||
|         // Wait for chat interface to load
 | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Type a message
 | ||||
|         const messageInput = page.locator('[data-testid="message-input"]'); | ||||
|         await messageInput.fill('Tell me a short story about a robot'); | ||||
|          | ||||
|         // Click send with streaming enabled
 | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for streaming to start
 | ||||
|         await page.waitForFunction(() => { | ||||
|             return (window as any).llmStreamMessages && (window as any).llmStreamMessages.length > 0; | ||||
|         }, { timeout: 10000 }); | ||||
|          | ||||
|         // Wait for streaming to complete (done: true message)
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.some((msg: StreamMessage) => msg.done === true); | ||||
|         }, { timeout: 30000 }); | ||||
|          | ||||
|         // Get all collected stream messages
 | ||||
|         const collectedMessages = await page.evaluate(() => (window as any).llmStreamMessages); | ||||
|          | ||||
|         // Verify we received streaming messages
 | ||||
|         expect(collectedMessages.length).toBeGreaterThan(0); | ||||
|          | ||||
|         // Verify message structure
 | ||||
|         const firstMessage = collectedMessages[0]; | ||||
|         expect(firstMessage.type).toBe('llm-stream'); | ||||
|         expect(firstMessage.chatNoteId).toBe(chatSessionId); | ||||
|          | ||||
|         // Verify we received a completion message
 | ||||
|         const completionMessage = collectedMessages.find((msg: StreamMessage) => msg.done === true); | ||||
|         expect(completionMessage).toBeDefined(); | ||||
|          | ||||
|         // Verify content was streamed
 | ||||
|         const contentMessages = collectedMessages.filter((msg: StreamMessage) => msg.content); | ||||
|         expect(contentMessages.length).toBeGreaterThan(0); | ||||
|     }); | ||||
| 
 | ||||
|     test('should handle streaming with thinking states visible', async ({ page }) => { | ||||
|         const streamMessages: StreamMessage[] = []; | ||||
|          | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Enable thinking display
 | ||||
|         await page.locator('[data-testid="show-thinking-toggle"]').check(); | ||||
|          | ||||
|         // Send a complex message that would trigger thinking
 | ||||
|         await page.locator('[data-testid="message-input"]').fill('Explain quantum computing and then write a haiku about it'); | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for thinking messages
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.some((msg: StreamMessage) => msg.thinking); | ||||
|         }, { timeout: 15000 }); | ||||
|          | ||||
|         const collectedMessages = await page.evaluate(() => (window as any).llmStreamMessages); | ||||
|          | ||||
|         // Verify thinking messages were received
 | ||||
|         const thinkingMessages = collectedMessages.filter((msg: StreamMessage) => msg.thinking); | ||||
|         expect(thinkingMessages.length).toBeGreaterThan(0); | ||||
|          | ||||
|         // Verify thinking content is displayed in UI
 | ||||
|         await expect(page.locator('[data-testid="thinking-display"]')).toBeVisible(); | ||||
|         const thinkingText = await page.locator('[data-testid="thinking-display"]').textContent(); | ||||
|         expect(thinkingText).toBeTruthy(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should handle tool execution during streaming', async ({ page }) => { | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Send a message that would trigger tool usage
 | ||||
|         await page.locator('[data-testid="message-input"]').fill('What is 15 * 37? Use a calculator tool.'); | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for tool execution messages
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.some((msg: StreamMessage) => msg.toolExecution); | ||||
|         }, { timeout: 20000 }); | ||||
|          | ||||
|         const collectedMessages = await page.evaluate(() => (window as any).llmStreamMessages); | ||||
|          | ||||
|         // Verify tool execution messages
 | ||||
|         const toolMessages = collectedMessages.filter((msg: StreamMessage) => msg.toolExecution); | ||||
|         expect(toolMessages.length).toBeGreaterThan(0); | ||||
|          | ||||
|         const toolMessage = toolMessages[0]; | ||||
|         expect(toolMessage.toolExecution.tool).toBeTruthy(); | ||||
|         expect(toolMessage.toolExecution.args).toBeTruthy(); | ||||
|          | ||||
|         // Verify tool execution is displayed in UI
 | ||||
|         await expect(page.locator('[data-testid="tool-execution-display"]')).toBeVisible(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should handle streaming errors gracefully', async ({ page }) => { | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Trigger an error by sending an invalid request or when AI is disabled
 | ||||
|         await page.locator('[data-testid="message-input"]').fill('This should trigger an error'); | ||||
|          | ||||
|         // Mock AI service to be unavailable
 | ||||
|         await page.route('/api/llm/**', route => { | ||||
|             route.fulfill({ | ||||
|                 status: 500, | ||||
|                 body: JSON.stringify({ error: 'AI service unavailable' }) | ||||
|             }); | ||||
|         }); | ||||
|          | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for error message
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.some((msg: StreamMessage) => msg.error); | ||||
|         }, { timeout: 10000 }); | ||||
|          | ||||
|         const collectedMessages = await page.evaluate(() => (window as any).llmStreamMessages); | ||||
|          | ||||
|         // Verify error message was received
 | ||||
|         const errorMessages = collectedMessages.filter((msg: StreamMessage) => msg.error); | ||||
|         expect(errorMessages.length).toBeGreaterThan(0); | ||||
|          | ||||
|         const errorMessage = errorMessages[0]; | ||||
|         expect(errorMessage.error).toBeTruthy(); | ||||
|         expect(errorMessage.done).toBe(true); | ||||
|          | ||||
|         // Verify error is displayed in UI
 | ||||
|         await expect(page.locator('[data-testid="error-display"]')).toBeVisible(); | ||||
|         const errorText = await page.locator('[data-testid="error-display"]').textContent(); | ||||
|         expect(errorText).toContain('error'); | ||||
|     }); | ||||
| 
 | ||||
|     test('should handle rapid consecutive streaming requests', async ({ page }) => { | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Send multiple messages rapidly
 | ||||
|         for (let i = 0; i < 3; i++) { | ||||
|             await page.locator('[data-testid="message-input"]').fill(`Rapid message ${i + 1}`); | ||||
|             await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|              | ||||
|             // Small delay between requests
 | ||||
|             await page.waitForTimeout(100); | ||||
|         } | ||||
|          | ||||
|         // Wait for all responses to complete
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             const doneMessages = messages.filter((msg: StreamMessage) => msg.done === true); | ||||
|             return doneMessages.length >= 3; | ||||
|         }, { timeout: 30000 }); | ||||
|          | ||||
|         const collectedMessages = await page.evaluate(() => (window as any).llmStreamMessages); | ||||
|          | ||||
|         // Verify all requests were processed
 | ||||
|         const uniqueChatIds = new Set(collectedMessages.map((msg: StreamMessage) => msg.chatNoteId)); | ||||
|         expect(uniqueChatIds.size).toBe(1); // All from same chat
 | ||||
|          | ||||
|         const doneMessages = collectedMessages.filter((msg: StreamMessage) => msg.done === true); | ||||
|         expect(doneMessages.length).toBeGreaterThanOrEqual(3); | ||||
|     }); | ||||
| 
 | ||||
|     test('should preserve message order during streaming', async ({ page }) => { | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             window.messageOrder = []; | ||||
|              | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                                 if (data.content) { | ||||
|                                     (window as any).messageOrder.push(data.content); | ||||
|                                 } | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         await page.locator('[data-testid="message-input"]').fill('Count from 1 to 10 with each number in a separate chunk'); | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for streaming to complete
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.some((msg: StreamMessage) => msg.done === true); | ||||
|         }, { timeout: 20000 }); | ||||
|          | ||||
|         const messageOrder = await page.evaluate(() => (window as any).messageOrder); | ||||
|          | ||||
|         // Verify messages arrived in order
 | ||||
|         expect(messageOrder.length).toBeGreaterThan(0); | ||||
|          | ||||
|         // Verify content appears in UI in correct order
 | ||||
|         const chatContent = await page.locator('[data-testid="chat-messages"]').textContent(); | ||||
|         expect(chatContent).toBeTruthy(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should handle WebSocket disconnection and reconnection', async ({ page }) => { | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             window.connectionEvents = []; | ||||
|              | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                      | ||||
|                     this.addEventListener('open', () => { | ||||
|                         (window as any).connectionEvents.push('open'); | ||||
|                     }); | ||||
|                      | ||||
|                     this.addEventListener('close', () => { | ||||
|                         (window as any).connectionEvents.push('close'); | ||||
|                     }); | ||||
|                      | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Start a streaming request
 | ||||
|         await page.locator('[data-testid="message-input"]').fill('Tell me a long story'); | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for streaming to start
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.length > 0; | ||||
|         }, { timeout: 10000 }); | ||||
|          | ||||
|         // Simulate network disconnection by going offline
 | ||||
|         await page.context().setOffline(true); | ||||
|         await page.waitForTimeout(2000); | ||||
|          | ||||
|         // Reconnect
 | ||||
|         await page.context().setOffline(false); | ||||
|          | ||||
|         // Verify connection events
 | ||||
|         const connectionEvents = await page.evaluate(() => (window as any).connectionEvents); | ||||
|         expect(connectionEvents).toContain('open'); | ||||
|          | ||||
|         // UI should show reconnection status
 | ||||
|         await expect(page.locator('[data-testid="connection-status"]')).toBeVisible(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should display streaming progress indicators', async ({ page }) => { | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         await page.locator('[data-testid="message-input"]').fill('Generate a detailed response'); | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Verify typing indicator appears
 | ||||
|         await expect(page.locator('[data-testid="typing-indicator"]')).toBeVisible(); | ||||
|          | ||||
|         // Verify progress indicators during streaming
 | ||||
|         await expect(page.locator('[data-testid="streaming-progress"]')).toBeVisible(); | ||||
|          | ||||
|         // Wait for streaming to complete
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const isStreamingDone = page.locator('[data-testid="streaming-complete"]').isVisible(); | ||||
|             return isStreamingDone; | ||||
|         }, { timeout: 30000 }); | ||||
|          | ||||
|         // Verify indicators are hidden when done
 | ||||
|         await expect(page.locator('[data-testid="typing-indicator"]')).not.toBeVisible(); | ||||
|         await expect(page.locator('[data-testid="streaming-progress"]')).not.toBeVisible(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should handle large streaming responses', async ({ page }) => { | ||||
|         await page.addInitScript(() => { | ||||
|             window.llmStreamMessages = []; | ||||
|             window.totalContentLength = 0; | ||||
|              | ||||
|             const originalWebSocket = window.WebSocket; | ||||
|             window.WebSocket = class extends originalWebSocket { | ||||
|                 constructor(url: string | URL, protocols?: string | string[]) { | ||||
|                     super(url, protocols); | ||||
|                     this.addEventListener('message', (event) => { | ||||
|                         try { | ||||
|                             const data = JSON.parse(event.data); | ||||
|                             if (data.type === 'llm-stream') { | ||||
|                                 (window as any).llmStreamMessages.push(data); | ||||
|                                 if (data.content) { | ||||
|                                     (window as any).totalContentLength += data.content.length; | ||||
|                                 } | ||||
|                             } | ||||
|                         } catch (e) {} | ||||
|                     }); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
| 
 | ||||
|         await page.goto(`/chat/${chatSessionId}`); | ||||
|         await page.waitForSelector('[data-testid="chat-interface"]'); | ||||
|          | ||||
|         // Request a large response
 | ||||
|         await page.locator('[data-testid="message-input"]').fill('Write a very detailed, long response about the history of computers, at least 2000 words'); | ||||
|         await page.locator('[data-testid="send-stream-button"]').click(); | ||||
|          | ||||
|         // Wait for large response to complete
 | ||||
|         await page.waitForFunction(() => { | ||||
|             const messages = (window as any).llmStreamMessages || []; | ||||
|             return messages.some((msg: StreamMessage) => msg.done === true); | ||||
|         }, { timeout: 60000 }); | ||||
|          | ||||
|         const totalLength = await page.evaluate(() => (window as any).totalContentLength); | ||||
|         const messages = await page.evaluate(() => (window as any).llmStreamMessages); | ||||
|          | ||||
|         // Verify large content was received
 | ||||
|         expect(totalLength).toBeGreaterThan(1000); // At least 1KB
 | ||||
|         expect(messages.length).toBeGreaterThan(10); // Multiple chunks
 | ||||
|          | ||||
|         // Verify UI can handle large content
 | ||||
|         const chatMessages = await page.locator('[data-testid="chat-messages"]').textContent(); | ||||
|         expect(chatMessages!.length).toBeGreaterThan(1000); | ||||
|     }); | ||||
| }); | ||||
| @ -1,8 +1,9 @@ | ||||
| import { Application } from "express"; | ||||
| import { beforeAll, describe, expect, it, vi, beforeEach } from "vitest"; | ||||
| import { beforeAll, describe, expect, it, vi, beforeEach, afterEach } from "vitest"; | ||||
| import supertest from "supertest"; | ||||
| import config from "../../services/config.js"; | ||||
| import { refreshAuth } from "../../services/auth.js"; | ||||
| import type { WebSocket } from 'ws'; | ||||
| 
 | ||||
| // Mock the CSRF protection middleware to allow tests to pass
 | ||||
| vi.mock("../csrf_protection.js", () => ({ | ||||
| @ -10,6 +11,64 @@ vi.mock("../csrf_protection.js", () => ({ | ||||
|     generateToken: () => "mock-csrf-token" | ||||
| })); | ||||
| 
 | ||||
| // Mock WebSocket service
 | ||||
| vi.mock("../../services/ws.js", () => ({ | ||||
|     default: { | ||||
|         sendMessageToAllClients: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| // Mock log service
 | ||||
| vi.mock("../../services/log.js", () => ({ | ||||
|     default: { | ||||
|         info: vi.fn(), | ||||
|         error: vi.fn(), | ||||
|         warn: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| // Mock chat storage service
 | ||||
| const mockChatStorage = { | ||||
|     createChat: vi.fn(), | ||||
|     getChat: vi.fn(), | ||||
|     updateChat: vi.fn(), | ||||
|     getAllChats: vi.fn(), | ||||
|     deleteChat: vi.fn() | ||||
| }; | ||||
| vi.mock("../../services/llm/storage/chat_storage_service.js", () => ({ | ||||
|     default: mockChatStorage | ||||
| })); | ||||
| 
 | ||||
| // Mock AI service manager
 | ||||
| const mockAiServiceManager = { | ||||
|     getOrCreateAnyService: vi.fn() | ||||
| }; | ||||
| vi.mock("../../services/llm/ai_service_manager.js", () => ({ | ||||
|     default: mockAiServiceManager | ||||
| })); | ||||
| 
 | ||||
| // Mock chat pipeline
 | ||||
| const mockChatPipelineExecute = vi.fn(); | ||||
| const MockChatPipeline = vi.fn().mockImplementation(() => ({ | ||||
|     execute: mockChatPipelineExecute | ||||
| })); | ||||
| vi.mock("../../services/llm/pipeline/chat_pipeline.js", () => ({ | ||||
|     ChatPipeline: MockChatPipeline | ||||
| })); | ||||
| 
 | ||||
| // Mock configuration helpers
 | ||||
| const mockGetSelectedModelConfig = vi.fn(); | ||||
| vi.mock("../../services/llm/config/configuration_helpers.js", () => ({ | ||||
|     getSelectedModelConfig: mockGetSelectedModelConfig | ||||
| })); | ||||
| 
 | ||||
| // Mock options service
 | ||||
| vi.mock("../../services/options.js", () => ({ | ||||
|     default: { | ||||
|         getOptionBool: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| // Session-based login that properly establishes req.session.loggedIn
 | ||||
| async function loginWithSession(app: Application) { | ||||
|     const response = await supertest(app) | ||||
| @ -257,7 +316,30 @@ describe("LLM API Tests", () => { | ||||
|         let testChatId: string; | ||||
| 
 | ||||
|         beforeEach(async () => { | ||||
|             // Reset all mocks
 | ||||
|             vi.clearAllMocks(); | ||||
|              | ||||
|             // Import options service to access mock
 | ||||
|             const options = (await import("../../services/options.js")).default; | ||||
|              | ||||
|             // Setup default mock behaviors
 | ||||
|             options.getOptionBool.mockReturnValue(true); // AI enabled
 | ||||
|             mockAiServiceManager.getOrCreateAnyService.mockResolvedValue({}); | ||||
|             mockGetSelectedModelConfig.mockResolvedValue({ | ||||
|                 model: 'test-model', | ||||
|                 provider: 'test-provider' | ||||
|             }); | ||||
|              | ||||
|             // Create a fresh chat for each test
 | ||||
|             const mockChat = { | ||||
|                 id: 'streaming-test-chat', | ||||
|                 title: 'Streaming Test Chat', | ||||
|                 messages: [], | ||||
|                 createdAt: new Date().toISOString() | ||||
|             }; | ||||
|             mockChatStorage.createChat.mockResolvedValue(mockChat); | ||||
|             mockChatStorage.getChat.mockResolvedValue(mockChat); | ||||
|              | ||||
|             const createResponse = await supertest(app) | ||||
|                 .post("/api/llm/chat") | ||||
|                 .set("Cookie", sessionCookie) | ||||
| @ -269,7 +351,19 @@ describe("LLM API Tests", () => { | ||||
|             testChatId = createResponse.body.id; | ||||
|         }); | ||||
| 
 | ||||
|         afterEach(() => { | ||||
|             vi.clearAllMocks(); | ||||
|         }); | ||||
| 
 | ||||
|         it("should initiate streaming for a chat message", async () => { | ||||
|             // Setup streaming simulation
 | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 const callback = input.streamCallback; | ||||
|                 // Simulate streaming chunks
 | ||||
|                 await callback('Hello', false, {}); | ||||
|                 await callback(' world!', true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
| @ -286,6 +380,31 @@ describe("LLM API Tests", () => { | ||||
|                 success: true, | ||||
|                 message: "Streaming initiated successfully" | ||||
|             }); | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify WebSocket messages were sent
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 thinking: undefined | ||||
|             }); | ||||
|              | ||||
|             // Verify streaming chunks were sent
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 content: 'Hello', | ||||
|                 done: false | ||||
|             }); | ||||
|              | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 content: ' world!', | ||||
|                 done: true | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle empty content for streaming", async () => { | ||||
| @ -338,6 +457,29 @@ describe("LLM API Tests", () => { | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle streaming with note mentions", async () => { | ||||
|             // Mock becca for note content retrieval
 | ||||
|             const mockBecca = { | ||||
|                 getNote: vi.fn().mockReturnValue({ | ||||
|                     noteId: 'root', | ||||
|                     title: 'Root Note', | ||||
|                     getBlob: () => ({ | ||||
|                         getContent: () => 'Root note content for testing' | ||||
|                     }) | ||||
|                 }) | ||||
|             }; | ||||
|             vi.mocked(await import('../../becca/becca.js')).default = mockBecca; | ||||
| 
 | ||||
|             // Setup streaming with mention context
 | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 // Verify mention content is included
 | ||||
|                 expect(input.query).toContain('Tell me about this note'); | ||||
|                 expect(input.query).toContain('Root note content for testing'); | ||||
|                  | ||||
|                 const callback = input.streamCallback; | ||||
|                 await callback('The root note contains', false, {}); | ||||
|                 await callback(' important information.', true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
| @ -358,6 +500,250 @@ describe("LLM API Tests", () => { | ||||
|                 success: true, | ||||
|                 message: "Streaming initiated successfully" | ||||
|             }); | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify thinking message was sent
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 thinking: 'Initializing streaming LLM response...' | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle streaming with thinking states", async () => { | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 const callback = input.streamCallback; | ||||
|                 // Simulate thinking states
 | ||||
|                 await callback('', false, { thinking: 'Analyzing the question...' }); | ||||
|                 await callback('', false, { thinking: 'Formulating response...' }); | ||||
|                 await callback('The answer is', false, {}); | ||||
|                 await callback(' 42.', true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
|                 .send({ | ||||
|                     content: "What is the meaning of life?", | ||||
|                     useAdvancedContext: false, | ||||
|                     showThinking: true | ||||
|                 }); | ||||
| 
 | ||||
|             expect(response.status).toBe(200); | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify thinking messages
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 thinking: 'Analyzing the question...', | ||||
|                 done: false | ||||
|             }); | ||||
|              | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 thinking: 'Formulating response...', | ||||
|                 done: false | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle streaming with tool executions", async () => { | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 const callback = input.streamCallback; | ||||
|                 // Simulate tool execution
 | ||||
|                 await callback('Let me calculate that', false, {}); | ||||
|                 await callback('', false, { | ||||
|                     toolExecution: { | ||||
|                         tool: 'calculator', | ||||
|                         arguments: { expression: '2 + 2' }, | ||||
|                         result: '4', | ||||
|                         toolCallId: 'call_123', | ||||
|                         action: 'execute' | ||||
|                     } | ||||
|                 }); | ||||
|                 await callback('The result is 4', true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
|                 .send({ | ||||
|                     content: "What is 2 + 2?", | ||||
|                     useAdvancedContext: false, | ||||
|                     showThinking: false | ||||
|                 }); | ||||
| 
 | ||||
|             expect(response.status).toBe(200); | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify tool execution message
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 toolExecution: { | ||||
|                     tool: 'calculator', | ||||
|                     args: { expression: '2 + 2' }, | ||||
|                     result: '4', | ||||
|                     toolCallId: 'call_123', | ||||
|                     action: 'execute', | ||||
|                     error: undefined | ||||
|                 }, | ||||
|                 done: false | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle streaming errors gracefully", async () => { | ||||
|             mockChatPipelineExecute.mockRejectedValue(new Error('Pipeline error')); | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
|                 .send({ | ||||
|                     content: "This will fail", | ||||
|                     useAdvancedContext: false, | ||||
|                     showThinking: false | ||||
|                 }); | ||||
| 
 | ||||
|             expect(response.status).toBe(200); // Still returns 200
 | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify error message was sent via WebSocket
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 error: 'Error during streaming: Pipeline error', | ||||
|                 done: true | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle AI disabled state", async () => { | ||||
|             // Import options service to access mock
 | ||||
|             const options = (await import("../../services/options.js")).default; | ||||
|             options.getOptionBool.mockReturnValue(false); // AI disabled
 | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
|                 .send({ | ||||
|                     content: "Hello AI", | ||||
|                     useAdvancedContext: false, | ||||
|                     showThinking: false | ||||
|                 }); | ||||
| 
 | ||||
|             expect(response.status).toBe(200); | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify error message about AI being disabled
 | ||||
|             expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ | ||||
|                 type: 'llm-stream', | ||||
|                 chatNoteId: testChatId, | ||||
|                 error: 'Error during streaming: AI features are disabled. Please enable them in the settings.', | ||||
|                 done: true | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it("should save chat messages after streaming completion", async () => { | ||||
|             const completeResponse = 'This is the complete response'; | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 const callback = input.streamCallback; | ||||
|                 await callback(completeResponse, true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
|                 .send({ | ||||
|                     content: "Save this response", | ||||
|                     useAdvancedContext: false, | ||||
|                     showThinking: false | ||||
|                 }); | ||||
| 
 | ||||
|             // Wait for async operations
 | ||||
|             await new Promise(resolve => setTimeout(resolve, 100)); | ||||
| 
 | ||||
|             // Verify chat was updated with the complete response
 | ||||
|             expect(mockChatStorage.updateChat).toHaveBeenCalledWith( | ||||
|                 testChatId, | ||||
|                 expect.arrayContaining([ | ||||
|                     { role: 'assistant', content: completeResponse } | ||||
|                 ]), | ||||
|                 'Streaming Test Chat' | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle rapid consecutive streaming requests", async () => { | ||||
|             let callCount = 0; | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 callCount++; | ||||
|                 const callback = input.streamCallback; | ||||
|                 await callback(`Response ${callCount}`, true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             // Send multiple requests rapidly
 | ||||
|             const promises = Array.from({ length: 3 }, (_, i) =>  | ||||
|                 supertest(app) | ||||
|                     .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                     .set("Cookie", sessionCookie) | ||||
|                     .send({ | ||||
|                         content: `Request ${i + 1}`, | ||||
|                         useAdvancedContext: false, | ||||
|                         showThinking: false | ||||
|                     }) | ||||
|             ); | ||||
| 
 | ||||
|             const responses = await Promise.all(promises); | ||||
|              | ||||
|             // All should succeed
 | ||||
|             responses.forEach(response => { | ||||
|                 expect(response.status).toBe(200); | ||||
|                 expect(response.body.success).toBe(true); | ||||
|             }); | ||||
| 
 | ||||
|             // Verify all were processed
 | ||||
|             expect(mockChatPipelineExecute).toHaveBeenCalledTimes(3); | ||||
|         }); | ||||
| 
 | ||||
|         it("should handle large streaming responses", async () => { | ||||
|             const largeContent = 'x'.repeat(10000); // 10KB of content
 | ||||
|             mockChatPipelineExecute.mockImplementation(async (input) => { | ||||
|                 const callback = input.streamCallback; | ||||
|                 // Simulate chunked delivery of large content
 | ||||
|                 for (let i = 0; i < 10; i++) { | ||||
|                     await callback(largeContent.slice(i * 1000, (i + 1) * 1000), false, {}); | ||||
|                 } | ||||
|                 await callback('', true, {}); | ||||
|             }); | ||||
| 
 | ||||
|             const response = await supertest(app) | ||||
|                 .post(`/api/llm/chat/${testChatId}/messages/stream`) | ||||
|                 .set("Cookie", sessionCookie) | ||||
|                 .send({ | ||||
|                     content: "Generate large response", | ||||
|                     useAdvancedContext: false, | ||||
|                     showThinking: false | ||||
|                 }); | ||||
| 
 | ||||
|             expect(response.status).toBe(200); | ||||
|              | ||||
|             // Import ws service to access mock
 | ||||
|             const ws = (await import("../../services/ws.js")).default; | ||||
|              | ||||
|             // Verify multiple chunks were sent
 | ||||
|             const streamCalls = ws.sendMessageToAllClients.mock.calls.filter( | ||||
|                 call => call[0].type === 'llm-stream' && call[0].content | ||||
|             ); | ||||
|             expect(streamCalls.length).toBeGreaterThan(5); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
| @ -537,11 +537,11 @@ async function handleStreamingProcess( | ||||
|         } | ||||
| 
 | ||||
|         // Get AI service
 | ||||
|         const aiServiceManager = await import('../ai_service_manager.js'); | ||||
|         const aiServiceManager = await import('../../services/llm/ai_service_manager.js'); | ||||
|         await aiServiceManager.default.getOrCreateAnyService(); | ||||
| 
 | ||||
|         // Use the chat pipeline directly for streaming
 | ||||
|         const { ChatPipeline } = await import('../pipeline/chat_pipeline.js'); | ||||
|         const { ChatPipeline } = await import('../../services/llm/pipeline/chat_pipeline.js'); | ||||
|         const pipeline = new ChatPipeline({ | ||||
|             enableStreaming: true, | ||||
|             enableMetrics: true, | ||||
| @ -549,7 +549,7 @@ async function handleStreamingProcess( | ||||
|         }); | ||||
| 
 | ||||
|         // Get selected model
 | ||||
|         const { getSelectedModelConfig } = await import('../config/configuration_helpers.js'); | ||||
|         const { getSelectedModelConfig } = await import('../../services/llm/config/configuration_helpers.js'); | ||||
|         const modelConfig = await getSelectedModelConfig(); | ||||
|          | ||||
|         if (!modelConfig) { | ||||
|  | ||||
| @ -0,0 +1,573 @@ | ||||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||||
| import { processProviderStream, StreamProcessor } from '../stream_handler.js'; | ||||
| import type { ProviderStreamOptions } from '../stream_handler.js'; | ||||
| 
 | ||||
| // Mock log service
 | ||||
| vi.mock('../../log.js', () => ({ | ||||
|     default: { | ||||
|         info: vi.fn(), | ||||
|         error: vi.fn(), | ||||
|         warn: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| describe('Provider Streaming Integration Tests', () => { | ||||
|     let mockProviderOptions: ProviderStreamOptions; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         vi.clearAllMocks(); | ||||
|         mockProviderOptions = { | ||||
|             providerName: 'TestProvider', | ||||
|             modelName: 'test-model-v1' | ||||
|         }; | ||||
|     }); | ||||
| 
 | ||||
|     describe('OpenAI-like Provider Integration', () => { | ||||
|         it('should handle OpenAI streaming format', async () => { | ||||
|             // Simulate OpenAI streaming chunks
 | ||||
|             const openAIChunks = [ | ||||
|                 { | ||||
|                     choices: [{ delta: { content: 'Hello' } }], | ||||
|                     model: 'gpt-3.5-turbo' | ||||
|                 }, | ||||
|                 { | ||||
|                     choices: [{ delta: { content: ' world' } }], | ||||
|                     model: 'gpt-3.5-turbo' | ||||
|                 }, | ||||
|                 { | ||||
|                     choices: [{ delta: { content: '!' } }], | ||||
|                     model: 'gpt-3.5-turbo' | ||||
|                 }, | ||||
|                 { | ||||
|                     choices: [{ finish_reason: 'stop' }], | ||||
|                     model: 'gpt-3.5-turbo', | ||||
|                     usage: { | ||||
|                         prompt_tokens: 10, | ||||
|                         completion_tokens: 3, | ||||
|                         total_tokens: 13 | ||||
|                     }, | ||||
|                     done: true | ||||
|                 } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of openAIChunks) { | ||||
|                         yield chunk; | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const receivedChunks: any[] = []; | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 { ...mockProviderOptions, providerName: 'OpenAI' }, | ||||
|                 (text, done, chunk) => { | ||||
|                     receivedChunks.push({ text, done, chunk }); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('Hello world!'); | ||||
|             expect(result.chunkCount).toBe(4); | ||||
|             expect(receivedChunks.length).toBeGreaterThan(0); | ||||
|              | ||||
|             // Verify callback received content chunks
 | ||||
|             const contentChunks = receivedChunks.filter(c => c.text); | ||||
|             expect(contentChunks.length).toBe(3); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle OpenAI tool calls', async () => { | ||||
|             const openAIWithTools = [ | ||||
|                 { | ||||
|                     choices: [{ delta: { content: 'Let me calculate that' } }] | ||||
|                 }, | ||||
|                 { | ||||
|                     choices: [{ | ||||
|                         delta: { | ||||
|                             tool_calls: [{ | ||||
|                                 id: 'call_123', | ||||
|                                 type: 'function', | ||||
|                                 function: { | ||||
|                                     name: 'calculator', | ||||
|                                     arguments: '{"expression": "2+2"}' | ||||
|                                 } | ||||
|                             }] | ||||
|                         } | ||||
|                     }] | ||||
|                 }, | ||||
|                 { | ||||
|                     choices: [{ delta: { content: 'The answer is 4' } }] | ||||
|                 }, | ||||
|                 { done: true } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of openAIWithTools) { | ||||
|                         yield chunk; | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 { ...mockProviderOptions, providerName: 'OpenAI' } | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('Let me calculate thatThe answer is 4'); | ||||
|             expect(result.toolCalls.length).toBe(1); | ||||
|             expect(result.toolCalls[0].function.name).toBe('calculator'); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Ollama Provider Integration', () => { | ||||
|         it('should handle Ollama streaming format', async () => { | ||||
|             const ollamaChunks = [ | ||||
|                 { | ||||
|                     model: 'llama2', | ||||
|                     message: { content: 'The weather' }, | ||||
|                     done: false | ||||
|                 }, | ||||
|                 { | ||||
|                     model: 'llama2', | ||||
|                     message: { content: ' today is' }, | ||||
|                     done: false | ||||
|                 }, | ||||
|                 { | ||||
|                     model: 'llama2', | ||||
|                     message: { content: ' sunny.' }, | ||||
|                     done: false | ||||
|                 }, | ||||
|                 { | ||||
|                     model: 'llama2', | ||||
|                     message: { content: '' }, | ||||
|                     done: true, | ||||
|                     prompt_eval_count: 15, | ||||
|                     eval_count: 8, | ||||
|                     total_duration: 12345678 | ||||
|                 } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of ollamaChunks) { | ||||
|                         yield chunk; | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const receivedChunks: any[] = []; | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 { ...mockProviderOptions, providerName: 'Ollama' }, | ||||
|                 (text, done, chunk) => { | ||||
|                     receivedChunks.push({ text, done, chunk }); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('The weather today is sunny.'); | ||||
|             expect(result.chunkCount).toBe(4); | ||||
|              | ||||
|             // Verify final chunk has usage stats
 | ||||
|             expect(result.finalChunk.prompt_eval_count).toBe(15); | ||||
|             expect(result.finalChunk.eval_count).toBe(8); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle Ollama empty responses', async () => { | ||||
|             const ollamaEmpty = [ | ||||
|                 { | ||||
|                     model: 'llama2', | ||||
|                     message: { content: '' }, | ||||
|                     done: true, | ||||
|                     prompt_eval_count: 5, | ||||
|                     eval_count: 0 | ||||
|                 } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of ollamaEmpty) { | ||||
|                         yield chunk; | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 { ...mockProviderOptions, providerName: 'Ollama' } | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe(''); | ||||
|             expect(result.chunkCount).toBe(1); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Anthropic Provider Integration', () => { | ||||
|         it('should handle Anthropic streaming format', async () => { | ||||
|             const anthropicChunks = [ | ||||
|                 { | ||||
|                     type: 'message_start', | ||||
|                     message: { | ||||
|                         id: 'msg_123', | ||||
|                         type: 'message', | ||||
|                         role: 'assistant', | ||||
|                         content: [] | ||||
|                     } | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'content_block_start', | ||||
|                     index: 0, | ||||
|                     content_block: { type: 'text', text: '' } | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'content_block_delta', | ||||
|                     index: 0, | ||||
|                     delta: { type: 'text_delta', text: 'Hello' } | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'content_block_delta', | ||||
|                     index: 0, | ||||
|                     delta: { type: 'text_delta', text: ' from' } | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'content_block_delta', | ||||
|                     index: 0, | ||||
|                     delta: { type: 'text_delta', text: ' Claude!' } | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'content_block_stop', | ||||
|                     index: 0 | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'message_delta', | ||||
|                     delta: { stop_reason: 'end_turn' }, | ||||
|                     usage: { output_tokens: 3 } | ||||
|                 }, | ||||
|                 { | ||||
|                     type: 'message_stop', | ||||
|                     done: true | ||||
|                 } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of anthropicChunks) { | ||||
|                         // Anthropic format needs conversion to our standard format
 | ||||
|                         if (chunk.type === 'content_block_delta') { | ||||
|                             yield { | ||||
|                                 message: { content: chunk.delta.text }, | ||||
|                                 done: false | ||||
|                             }; | ||||
|                         } else if (chunk.type === 'message_stop') { | ||||
|                             yield { done: true }; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 { ...mockProviderOptions, providerName: 'Anthropic' } | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('Hello from Claude!'); | ||||
|             expect(result.chunkCount).toBe(4); // 3 content chunks + 1 done
 | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle Anthropic thinking blocks', async () => { | ||||
|             const anthropicWithThinking = [ | ||||
|                 { | ||||
|                     message: { content: '', thinking: 'Let me think about this...' }, | ||||
|                     done: false | ||||
|                 }, | ||||
|                 { | ||||
|                     message: { content: '', thinking: 'I need to consider multiple factors' }, | ||||
|                     done: false | ||||
|                 }, | ||||
|                 { | ||||
|                     message: { content: 'Based on my analysis' }, | ||||
|                     done: false | ||||
|                 }, | ||||
|                 { | ||||
|                     message: { content: ', the answer is 42.' }, | ||||
|                     done: true | ||||
|                 } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of anthropicWithThinking) { | ||||
|                         yield chunk; | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const receivedChunks: any[] = []; | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 { ...mockProviderOptions, providerName: 'Anthropic' }, | ||||
|                 (text, done, chunk) => { | ||||
|                     receivedChunks.push({ text, done, chunk }); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('Based on my analysis, the answer is 42.'); | ||||
|              | ||||
|             // Verify thinking states were captured
 | ||||
|             const thinkingChunks = receivedChunks.filter(c => c.chunk?.thinking); | ||||
|             expect(thinkingChunks.length).toBe(2); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Error Scenarios Integration', () => { | ||||
|         it('should handle provider connection timeouts', async () => { | ||||
|             const timeoutIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     yield { message: { content: 'Starting...' } }; | ||||
|                     // Simulate timeout
 | ||||
|                     await new Promise((_, reject) =>  | ||||
|                         setTimeout(() => reject(new Error('Request timeout')), 100) | ||||
|                     ); | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             await expect(processProviderStream( | ||||
|                 timeoutIterator, | ||||
|                 mockProviderOptions | ||||
|             )).rejects.toThrow('Request timeout'); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle malformed provider responses', async () => { | ||||
|             const malformedIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     yield null; // Invalid chunk
 | ||||
|                     yield undefined; // Invalid chunk
 | ||||
|                     yield { invalidFormat: true }; // No standard fields
 | ||||
|                     yield { done: true }; | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const result = await processProviderStream( | ||||
|                 malformedIterator, | ||||
|                 mockProviderOptions | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe(''); | ||||
|             expect(result.chunkCount).toBe(4); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle provider rate limiting', async () => { | ||||
|             const rateLimitIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     yield { message: { content: 'Starting request' } }; | ||||
|                     throw new Error('Rate limit exceeded. Please try again later.'); | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             await expect(processProviderStream( | ||||
|                 rateLimitIterator, | ||||
|                 mockProviderOptions | ||||
|             )).rejects.toThrow('Rate limit exceeded'); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle network interruptions', async () => { | ||||
|             const networkErrorIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     yield { message: { content: 'Partial' } }; | ||||
|                     yield { message: { content: ' response' } }; | ||||
|                     throw new Error('Network error: Connection reset'); | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             await expect(processProviderStream( | ||||
|                 networkErrorIterator, | ||||
|                 mockProviderOptions | ||||
|             )).rejects.toThrow('Network error'); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Performance and Scalability', () => { | ||||
|         it('should handle high-frequency chunk delivery', async () => { | ||||
|             const highFrequencyChunks = Array.from({ length: 1000 }, (_, i) => ({ | ||||
|                 message: { content: `chunk${i}` }, | ||||
|                 done: i === 999 | ||||
|             })); | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of highFrequencyChunks) { | ||||
|                         yield chunk; | ||||
|                         // No delay - rapid fire
 | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const startTime = Date.now(); | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 mockProviderOptions | ||||
|             ); | ||||
|             const endTime = Date.now(); | ||||
| 
 | ||||
|             expect(result.chunkCount).toBe(1000); | ||||
|             expect(result.completeText).toContain('chunk999'); | ||||
|             expect(endTime - startTime).toBeLessThan(5000); // Should complete in under 5s
 | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle large individual chunks', async () => { | ||||
|             const largeContent = 'x'.repeat(100000); // 100KB chunk
 | ||||
|             const largeChunks = [ | ||||
|                 { message: { content: largeContent }, done: false }, | ||||
|                 { message: { content: ' end' }, done: true } | ||||
|             ]; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (const chunk of largeChunks) { | ||||
|                         yield chunk; | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 mockProviderOptions | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText.length).toBe(100004); // 100KB + ' end'
 | ||||
|             expect(result.chunkCount).toBe(2); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle concurrent streaming sessions', async () => { | ||||
|             const createMockIterator = (sessionId: number) => ({ | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (let i = 0; i < 10; i++) { | ||||
|                         yield { | ||||
|                             message: { content: `Session${sessionId}-Chunk${i}` }, | ||||
|                             done: i === 9 | ||||
|                         }; | ||||
|                         await new Promise(resolve => setTimeout(resolve, 10)); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             // Start 5 concurrent streaming sessions
 | ||||
|             const promises = Array.from({ length: 5 }, (_, i) => | ||||
|                 processProviderStream( | ||||
|                     createMockIterator(i), | ||||
|                     { ...mockProviderOptions, providerName: `Provider${i}` } | ||||
|                 ) | ||||
|             ); | ||||
| 
 | ||||
|             const results = await Promise.all(promises); | ||||
| 
 | ||||
|             // Verify all sessions completed successfully
 | ||||
|             results.forEach((result, i) => { | ||||
|                 expect(result.chunkCount).toBe(10); | ||||
|                 expect(result.completeText).toContain(`Session${i}`); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Memory Management', () => { | ||||
|         it('should not leak memory during long streaming sessions', async () => { | ||||
|             const longSessionIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     for (let i = 0; i < 10000; i++) { | ||||
|                         yield { | ||||
|                             message: { content: `Chunk ${i} with some additional content to increase memory usage` }, | ||||
|                             done: i === 9999 | ||||
|                         }; | ||||
|                          | ||||
|                         // Periodic yield to event loop
 | ||||
|                         if (i % 100 === 0) { | ||||
|                             await new Promise(resolve => setImmediate(resolve)); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const initialMemory = process.memoryUsage(); | ||||
|              | ||||
|             const result = await processProviderStream( | ||||
|                 longSessionIterator, | ||||
|                 mockProviderOptions | ||||
|             ); | ||||
| 
 | ||||
|             const finalMemory = process.memoryUsage(); | ||||
|              | ||||
|             expect(result.chunkCount).toBe(10000); | ||||
|              | ||||
|             // Memory increase should be reasonable (less than 50MB)
 | ||||
|             const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed; | ||||
|             expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); | ||||
|         }); | ||||
| 
 | ||||
|         it('should clean up resources on stream completion', async () => { | ||||
|             const resourceTracker = { | ||||
|                 resources: new Set<string>(), | ||||
|                 allocate(id: string) { this.resources.add(id); }, | ||||
|                 cleanup(id: string) { this.resources.delete(id); } | ||||
|             }; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     resourceTracker.allocate('stream-1'); | ||||
|                     try { | ||||
|                         yield { message: { content: 'Hello' } }; | ||||
|                         yield { message: { content: 'World' } }; | ||||
|                         yield { done: true }; | ||||
|                     } finally { | ||||
|                         resourceTracker.cleanup('stream-1'); | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 mockProviderOptions | ||||
|             ); | ||||
| 
 | ||||
|             expect(resourceTracker.resources.size).toBe(0); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Provider-Specific Configurations', () => { | ||||
|         it('should handle provider-specific options', async () => { | ||||
|             const configuredOptions: ProviderStreamOptions = { | ||||
|                 providerName: 'CustomProvider', | ||||
|                 modelName: 'custom-model', | ||||
|                 apiConfig: { | ||||
|                     temperature: 0.7, | ||||
|                     maxTokens: 1000, | ||||
|                     customParameter: 'test-value' | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const mockIterator = { | ||||
|                 async *[Symbol.asyncIterator]() { | ||||
|                     yield { message: { content: 'Configured response' }, done: true }; | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const result = await processProviderStream( | ||||
|                 mockIterator, | ||||
|                 configuredOptions | ||||
|             ); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('Configured response'); | ||||
|         }); | ||||
| 
 | ||||
|         it('should validate provider compatibility', async () => { | ||||
|             const unsupportedIterator = { | ||||
|                 // Missing Symbol.asyncIterator
 | ||||
|                 next() { return { value: null, done: true }; } | ||||
|             }; | ||||
| 
 | ||||
|             await expect(processProviderStream( | ||||
|                 unsupportedIterator as any, | ||||
|                 mockProviderOptions | ||||
|             )).rejects.toThrow('Invalid stream iterator'); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										602
									
								
								apps/server/src/services/llm/providers/stream_handler.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										602
									
								
								apps/server/src/services/llm/providers/stream_handler.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,602 @@ | ||||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||||
| import { StreamProcessor, createStreamHandler, processProviderStream, extractStreamStats, performProviderHealthCheck } from './stream_handler.js'; | ||||
| import type { StreamProcessingOptions, StreamChunk, ProviderStreamOptions } from './stream_handler.js'; | ||||
| 
 | ||||
| // Mock the log module
 | ||||
| vi.mock('../../log.js', () => ({ | ||||
|     default: { | ||||
|         info: vi.fn(), | ||||
|         error: vi.fn(), | ||||
|         warn: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| describe('StreamProcessor', () => { | ||||
|     let mockCallback: ReturnType<typeof vi.fn>; | ||||
|     let mockOptions: StreamProcessingOptions; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         mockCallback = vi.fn(); | ||||
|         mockOptions = { | ||||
|             streamCallback: mockCallback, | ||||
|             providerName: 'TestProvider', | ||||
|             modelName: 'test-model' | ||||
|         }; | ||||
|         vi.clearAllMocks(); | ||||
|     }); | ||||
| 
 | ||||
|     describe('processChunk', () => { | ||||
|         it('should process a chunk with content', async () => { | ||||
|             const chunk = { | ||||
|                 message: { content: 'Hello' }, | ||||
|                 done: false | ||||
|             }; | ||||
|             const result = await StreamProcessor.processChunk(chunk, '', 1, mockOptions); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('Hello'); | ||||
|             expect(result.logged).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle chunks without content', async () => { | ||||
|             const chunk = { done: false }; | ||||
|             const result = await StreamProcessor.processChunk(chunk, 'existing', 2, mockOptions); | ||||
| 
 | ||||
|             expect(result.completeText).toBe('existing'); | ||||
|             expect(result.logged).toBe(false); | ||||
|         }); | ||||
| 
 | ||||
|         it('should log every 10th chunk', async () => { | ||||
|             const chunk = { message: { content: 'test' } }; | ||||
|             const result = await StreamProcessor.processChunk(chunk, '', 10, mockOptions); | ||||
| 
 | ||||
|             expect(result.logged).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it('should log final chunks with done flag', async () => { | ||||
|             const chunk = { done: true }; | ||||
|             const result = await StreamProcessor.processChunk(chunk, 'complete', 5, mockOptions); | ||||
| 
 | ||||
|             expect(result.logged).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it('should accumulate text correctly', async () => { | ||||
|             const chunk1 = { message: { content: 'Hello ' } }; | ||||
|             const chunk2 = { message: { content: 'World' } }; | ||||
| 
 | ||||
|             const result1 = await StreamProcessor.processChunk(chunk1, '', 1, mockOptions); | ||||
|             const result2 = await StreamProcessor.processChunk(chunk2, result1.completeText, 2, mockOptions); | ||||
| 
 | ||||
|             expect(result2.completeText).toBe('Hello World'); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('sendChunkToCallback', () => { | ||||
|         it('should call callback with content', async () => { | ||||
|             await StreamProcessor.sendChunkToCallback(mockCallback, 'test content', false, {}, 1); | ||||
| 
 | ||||
|             expect(mockCallback).toHaveBeenCalledWith('test content', false, {}); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle async callbacks', async () => { | ||||
|             const asyncCallback = vi.fn().mockResolvedValue(undefined); | ||||
|             await StreamProcessor.sendChunkToCallback(asyncCallback, 'async test', true, { done: true }, 5); | ||||
| 
 | ||||
|             expect(asyncCallback).toHaveBeenCalledWith('async test', true, { done: true }); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle callback errors gracefully', async () => { | ||||
|             const errorCallback = vi.fn().mockRejectedValue(new Error('Callback error')); | ||||
|              | ||||
|             // Should not throw
 | ||||
|             await expect(StreamProcessor.sendChunkToCallback(errorCallback, 'test', false, {}, 1)) | ||||
|                 .resolves.toBeUndefined(); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle empty content', async () => { | ||||
|             await StreamProcessor.sendChunkToCallback(mockCallback, '', true, { done: true }, 10); | ||||
| 
 | ||||
|             expect(mockCallback).toHaveBeenCalledWith('', true, { done: true }); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle null content by converting to empty string', async () => { | ||||
|             await StreamProcessor.sendChunkToCallback(mockCallback, null as any, false, {}, 1); | ||||
| 
 | ||||
|             expect(mockCallback).toHaveBeenCalledWith('', false, {}); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('sendFinalCallback', () => { | ||||
|         it('should send final callback with complete text', async () => { | ||||
|             await StreamProcessor.sendFinalCallback(mockCallback, 'Complete text'); | ||||
| 
 | ||||
|             expect(mockCallback).toHaveBeenCalledWith('Complete text', true, { done: true, complete: true }); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle empty complete text', async () => { | ||||
|             await StreamProcessor.sendFinalCallback(mockCallback, ''); | ||||
| 
 | ||||
|             expect(mockCallback).toHaveBeenCalledWith('', true, { done: true, complete: true }); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle async final callbacks', async () => { | ||||
|             const asyncCallback = vi.fn().mockResolvedValue(undefined); | ||||
|             await StreamProcessor.sendFinalCallback(asyncCallback, 'Final'); | ||||
| 
 | ||||
|             expect(asyncCallback).toHaveBeenCalledWith('Final', true, { done: true, complete: true }); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle final callback errors gracefully', async () => { | ||||
|             const errorCallback = vi.fn().mockRejectedValue(new Error('Final callback error')); | ||||
|              | ||||
|             await expect(StreamProcessor.sendFinalCallback(errorCallback, 'test')) | ||||
|                 .resolves.toBeUndefined(); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('extractToolCalls', () => { | ||||
|         it('should extract tool calls from chunk', () => { | ||||
|             const chunk = { | ||||
|                 message: { | ||||
|                     tool_calls: [ | ||||
|                         { id: '1', function: { name: 'test_tool', arguments: '{}' } } | ||||
|                     ] | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             const toolCalls = StreamProcessor.extractToolCalls(chunk); | ||||
|             expect(toolCalls).toHaveLength(1); | ||||
|             expect(toolCalls[0].function.name).toBe('test_tool'); | ||||
|         }); | ||||
| 
 | ||||
|         it('should return empty array when no tool calls', () => { | ||||
|             const chunk = { message: { content: 'Just text' } }; | ||||
|             const toolCalls = StreamProcessor.extractToolCalls(chunk); | ||||
|             expect(toolCalls).toEqual([]); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle missing message property', () => { | ||||
|             const chunk = {}; | ||||
|             const toolCalls = StreamProcessor.extractToolCalls(chunk); | ||||
|             expect(toolCalls).toEqual([]); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle non-array tool_calls', () => { | ||||
|             const chunk = { message: { tool_calls: 'not-an-array' } }; | ||||
|             const toolCalls = StreamProcessor.extractToolCalls(chunk); | ||||
|             expect(toolCalls).toEqual([]); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('createFinalResponse', () => { | ||||
|         it('should create a complete response object', () => { | ||||
|             const response = StreamProcessor.createFinalResponse( | ||||
|                 'Complete text', | ||||
|                 'test-model', | ||||
|                 'TestProvider', | ||||
|                 [{ id: '1', function: { name: 'tool1' } }], | ||||
|                 { promptTokens: 10, completionTokens: 20 } | ||||
|             ); | ||||
| 
 | ||||
|             expect(response).toEqual({ | ||||
|                 text: 'Complete text', | ||||
|                 model: 'test-model', | ||||
|                 provider: 'TestProvider', | ||||
|                 tool_calls: [{ id: '1', function: { name: 'tool1' } }], | ||||
|                 usage: { promptTokens: 10, completionTokens: 20 } | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle empty parameters', () => { | ||||
|             const response = StreamProcessor.createFinalResponse('', '', '', []); | ||||
| 
 | ||||
|             expect(response).toEqual({ | ||||
|                 text: '', | ||||
|                 model: '', | ||||
|                 provider: '', | ||||
|                 tool_calls: [], | ||||
|                 usage: {} | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| describe('createStreamHandler', () => { | ||||
|     it('should create a working stream handler', async () => { | ||||
|         const mockProcessFn = vi.fn().mockImplementation(async (callback) => { | ||||
|             await callback({ text: 'chunk1', done: false }); | ||||
|             await callback({ text: 'chunk2', done: true }); | ||||
|             return 'complete'; | ||||
|         }); | ||||
| 
 | ||||
|         const handler = createStreamHandler( | ||||
|             { providerName: 'test', modelName: 'model' }, | ||||
|             mockProcessFn | ||||
|         ); | ||||
| 
 | ||||
|         const chunks: StreamChunk[] = []; | ||||
|         const result = await handler(async (chunk) => { | ||||
|             chunks.push(chunk); | ||||
|         }); | ||||
| 
 | ||||
|         expect(result).toBe('complete'); | ||||
|         expect(chunks).toHaveLength(3); // 2 from processFn + 1 final
 | ||||
|         expect(chunks[2]).toEqual({ text: '', done: true }); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle errors in processor function', async () => { | ||||
|         const mockProcessFn = vi.fn().mockRejectedValue(new Error('Process error')); | ||||
| 
 | ||||
|         const handler = createStreamHandler( | ||||
|             { providerName: 'test', modelName: 'model' }, | ||||
|             mockProcessFn | ||||
|         ); | ||||
| 
 | ||||
|         await expect(handler(vi.fn())).rejects.toThrow('Process error'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should ensure final chunk even on error after some chunks', async () => { | ||||
|         const chunks: StreamChunk[] = []; | ||||
|         const mockProcessFn = vi.fn().mockImplementation(async (callback) => { | ||||
|             await callback({ text: 'chunk1', done: false }); | ||||
|             throw new Error('Mid-stream error'); | ||||
|         }); | ||||
| 
 | ||||
|         const handler = createStreamHandler( | ||||
|             { providerName: 'test', modelName: 'model' }, | ||||
|             mockProcessFn | ||||
|         ); | ||||
| 
 | ||||
|         try { | ||||
|             await handler(async (chunk) => { | ||||
|                 chunks.push(chunk); | ||||
|             }); | ||||
|         } catch (e) { | ||||
|             // Expected error
 | ||||
|         } | ||||
| 
 | ||||
|         // Should have received the chunk before error and final done chunk
 | ||||
|         expect(chunks.length).toBeGreaterThanOrEqual(2); | ||||
|         expect(chunks[chunks.length - 1]).toEqual({ text: '', done: true }); | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| describe('processProviderStream', () => { | ||||
|     let mockStreamIterator: AsyncIterable<any>; | ||||
|     let mockCallback: ReturnType<typeof vi.fn>; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         mockCallback = vi.fn(); | ||||
|     }); | ||||
| 
 | ||||
|     it('should process a complete stream', async () => { | ||||
|         const chunks = [ | ||||
|             { message: { content: 'Hello ' } }, | ||||
|             { message: { content: 'World' } }, | ||||
|             { done: true } | ||||
|         ]; | ||||
| 
 | ||||
|         mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' }, | ||||
|             mockCallback | ||||
|         ); | ||||
| 
 | ||||
|         expect(result.completeText).toBe('Hello World'); | ||||
|         expect(result.chunkCount).toBe(3); | ||||
|         expect(mockCallback).toHaveBeenCalledTimes(3); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle tool calls in stream', async () => { | ||||
|         const chunks = [ | ||||
|             { message: { content: 'Using tool...' } }, | ||||
|             {  | ||||
|                 message: {  | ||||
|                     tool_calls: [ | ||||
|                         { id: 'call_1', function: { name: 'calculator', arguments: '{"x": 5}' } } | ||||
|                     ] | ||||
|                 } | ||||
|             }, | ||||
|             { done: true } | ||||
|         ]; | ||||
| 
 | ||||
|         mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' } | ||||
|         ); | ||||
| 
 | ||||
|         expect(result.toolCalls).toHaveLength(1); | ||||
|         expect(result.toolCalls[0].function.name).toBe('calculator'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty stream', async () => { | ||||
|         mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 // Empty stream
 | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' }, | ||||
|             mockCallback | ||||
|         ); | ||||
| 
 | ||||
|         expect(result.completeText).toBe(''); | ||||
|         expect(result.chunkCount).toBe(0); | ||||
|         // Should still send final callback
 | ||||
|         expect(mockCallback).toHaveBeenCalledWith('', true, expect.any(Object)); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle stream errors', async () => { | ||||
|         mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 yield { message: { content: 'Start' } }; | ||||
|                 throw new Error('Stream error'); | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         await expect(processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' } | ||||
|         )).rejects.toThrow('Stream error'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle invalid stream iterator', async () => { | ||||
|         const invalidIterator = {} as any; | ||||
| 
 | ||||
|         await expect(processProviderStream( | ||||
|             invalidIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' } | ||||
|         )).rejects.toThrow('Invalid stream iterator'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle different chunk content formats', async () => { | ||||
|         const chunks = [ | ||||
|             { content: 'Direct content' }, | ||||
|             { choices: [{ delta: { content: 'OpenAI format' } }] }, | ||||
|             { message: { content: 'Standard format' } } | ||||
|         ]; | ||||
| 
 | ||||
|         mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' }, | ||||
|             mockCallback | ||||
|         ); | ||||
| 
 | ||||
|         expect(mockCallback).toHaveBeenCalledTimes(4); // 3 chunks + final
 | ||||
|     }); | ||||
| 
 | ||||
|     it('should send final callback when last chunk has no done flag', async () => { | ||||
|         const chunks = [ | ||||
|             { message: { content: 'Hello' } }, | ||||
|             { message: { content: 'World' } } | ||||
|             // No done flag
 | ||||
|         ]; | ||||
| 
 | ||||
|         mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' }, | ||||
|             mockCallback | ||||
|         ); | ||||
| 
 | ||||
|         // Should have explicit final callback
 | ||||
|         const lastCall = mockCallback.mock.calls[mockCallback.mock.calls.length - 1]; | ||||
|         expect(lastCall[1]).toBe(true); // done flag
 | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| describe('extractStreamStats', () => { | ||||
|     it('should extract Ollama format stats', () => { | ||||
|         const chunk = { | ||||
|             prompt_eval_count: 10, | ||||
|             eval_count: 20 | ||||
|         }; | ||||
| 
 | ||||
|         const stats = extractStreamStats(chunk, 'Ollama'); | ||||
|         expect(stats).toEqual({ | ||||
|             promptTokens: 10, | ||||
|             completionTokens: 20, | ||||
|             totalTokens: 30 | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('should extract OpenAI format stats', () => { | ||||
|         const chunk = { | ||||
|             usage: { | ||||
|                 prompt_tokens: 15, | ||||
|                 completion_tokens: 25, | ||||
|                 total_tokens: 40 | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const stats = extractStreamStats(chunk, 'OpenAI'); | ||||
|         expect(stats).toEqual({ | ||||
|             promptTokens: 15, | ||||
|             completionTokens: 25, | ||||
|             totalTokens: 40 | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle missing stats', () => { | ||||
|         const chunk = { message: { content: 'No stats here' } }; | ||||
| 
 | ||||
|         const stats = extractStreamStats(chunk, 'Test'); | ||||
|         expect(stats).toEqual({ | ||||
|             promptTokens: 0, | ||||
|             completionTokens: 0, | ||||
|             totalTokens: 0 | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle null chunk', () => { | ||||
|         const stats = extractStreamStats(null, 'Test'); | ||||
|         expect(stats).toEqual({ | ||||
|             promptTokens: 0, | ||||
|             completionTokens: 0, | ||||
|             totalTokens: 0 | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle partial Ollama stats', () => { | ||||
|         const chunk = { | ||||
|             prompt_eval_count: 10 | ||||
|             // Missing eval_count
 | ||||
|         }; | ||||
| 
 | ||||
|         const stats = extractStreamStats(chunk, 'Ollama'); | ||||
|         expect(stats).toEqual({ | ||||
|             promptTokens: 10, | ||||
|             completionTokens: 0, | ||||
|             totalTokens: 10 | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| describe('performProviderHealthCheck', () => { | ||||
|     it('should return true on successful health check', async () => { | ||||
|         const mockCheckFn = vi.fn().mockResolvedValue({ status: 'ok' }); | ||||
| 
 | ||||
|         const result = await performProviderHealthCheck(mockCheckFn, 'TestProvider'); | ||||
|         expect(result).toBe(true); | ||||
|         expect(mockCheckFn).toHaveBeenCalled(); | ||||
|     }); | ||||
| 
 | ||||
|     it('should throw error on failed health check', async () => { | ||||
|         const mockCheckFn = vi.fn().mockRejectedValue(new Error('Connection refused')); | ||||
| 
 | ||||
|         await expect(performProviderHealthCheck(mockCheckFn, 'TestProvider')) | ||||
|             .rejects.toThrow('Unable to connect to TestProvider server: Connection refused'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle non-Error rejections', async () => { | ||||
|         const mockCheckFn = vi.fn().mockRejectedValue('String error'); | ||||
| 
 | ||||
|         await expect(performProviderHealthCheck(mockCheckFn, 'TestProvider')) | ||||
|             .rejects.toThrow('Unable to connect to TestProvider server: String error'); | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| describe('Streaming edge cases and concurrency', () => { | ||||
|     it('should handle rapid chunk delivery', async () => { | ||||
|         const chunks = Array.from({ length: 100 }, (_, i) => ({ | ||||
|             message: { content: `chunk${i}` } | ||||
|         })); | ||||
| 
 | ||||
|         const mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const receivedChunks: any[] = []; | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' }, | ||||
|             async (text, done, chunk) => { | ||||
|                 receivedChunks.push({ text, done }); | ||||
|                 // Simulate some processing delay
 | ||||
|                 await new Promise(resolve => setTimeout(resolve, 1)); | ||||
|             } | ||||
|         ); | ||||
| 
 | ||||
|         expect(result.chunkCount).toBe(100); | ||||
|         expect(result.completeText).toContain('chunk99'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle callback throwing errors', async () => { | ||||
|         const chunks = [ | ||||
|             { message: { content: 'chunk1' } }, | ||||
|             { message: { content: 'chunk2' } }, | ||||
|             { done: true } | ||||
|         ]; | ||||
| 
 | ||||
|         const mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         let callCount = 0; | ||||
|         const errorCallback = vi.fn().mockImplementation(() => { | ||||
|             callCount++; | ||||
|             if (callCount === 2) { | ||||
|                 throw new Error('Callback error'); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         // Should not throw, errors in callbacks are caught
 | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' }, | ||||
|             errorCallback | ||||
|         ); | ||||
| 
 | ||||
|         expect(result.completeText).toBe('chunk1chunk2'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle mixed content and tool calls', async () => { | ||||
|         const chunks = [ | ||||
|             { message: { content: 'Let me calculate that...' } }, | ||||
|             {  | ||||
|                 message: {  | ||||
|                     content: '', | ||||
|                     tool_calls: [{ id: '1', function: { name: 'calc' } }] | ||||
|                 } | ||||
|             }, | ||||
|             { message: { content: 'The answer is 42.' } }, | ||||
|             { done: true } | ||||
|         ]; | ||||
| 
 | ||||
|         const mockStreamIterator = { | ||||
|             async *[Symbol.asyncIterator]() { | ||||
|                 for (const chunk of chunks) { | ||||
|                     yield chunk; | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         const result = await processProviderStream( | ||||
|             mockStreamIterator, | ||||
|             { providerName: 'Test', modelName: 'test-model' } | ||||
|         ); | ||||
| 
 | ||||
|         expect(result.completeText).toBe('Let me calculate that...The answer is 42.'); | ||||
|         expect(result.toolCalls).toHaveLength(1); | ||||
|     }); | ||||
| }); | ||||
| @ -383,12 +383,14 @@ export function extractStreamStats(finalChunk: any | null, providerName: string) | ||||
|         return { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; | ||||
|     } | ||||
| 
 | ||||
|     // Ollama format
 | ||||
|     if (finalChunk.prompt_eval_count !== undefined && finalChunk.eval_count !== undefined) { | ||||
|     // Ollama format - handle partial stats where some fields might be missing
 | ||||
|     if (finalChunk.prompt_eval_count !== undefined || finalChunk.eval_count !== undefined) { | ||||
|         const promptTokens = finalChunk.prompt_eval_count || 0; | ||||
|         const completionTokens = finalChunk.eval_count || 0; | ||||
|         return { | ||||
|             promptTokens: finalChunk.prompt_eval_count || 0, | ||||
|             completionTokens: finalChunk.eval_count || 0, | ||||
|             totalTokens: (finalChunk.prompt_eval_count || 0) + (finalChunk.eval_count || 0) | ||||
|             promptTokens, | ||||
|             completionTokens, | ||||
|             totalTokens: promptTokens + completionTokens | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										516
									
								
								apps/server/src/services/ws.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										516
									
								
								apps/server/src/services/ws.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,516 @@ | ||||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||||
| import { Server as WebSocketServer } from 'ws'; | ||||
| import type { WebSocket } from 'ws'; | ||||
| 
 | ||||
| // Mock dependencies
 | ||||
| vi.mock('./log.js', () => ({ | ||||
|     default: { | ||||
|         info: vi.fn(), | ||||
|         error: vi.fn(), | ||||
|         warn: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| vi.mock('./sync_mutex.js', () => ({ | ||||
|     default: { | ||||
|         doExclusively: vi.fn().mockImplementation((fn) => fn()) | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| vi.mock('./sql.js', () => ({ | ||||
|     getManyRows: vi.fn(), | ||||
|     getValue: vi.fn(), | ||||
|     getRow: vi.fn() | ||||
| })); | ||||
| 
 | ||||
| vi.mock('../becca/becca.js', () => ({ | ||||
|     default: { | ||||
|         getAttribute: vi.fn(), | ||||
|         getBranch: vi.fn(), | ||||
|         getNote: vi.fn(), | ||||
|         getOption: vi.fn() | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| vi.mock('./protected_session.js', () => ({ | ||||
|     default: { | ||||
|         decryptString: vi.fn((str) => str) | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| vi.mock('./cls.js', () => ({ | ||||
|     getAndClearEntityChangeIds: vi.fn().mockReturnValue([]) | ||||
| })); | ||||
| 
 | ||||
| // Mock WebSocket server
 | ||||
| const mockWebSocketServer = { | ||||
|     clients: new Set<WebSocket>(), | ||||
|     on: vi.fn(), | ||||
|     close: vi.fn() | ||||
| }; | ||||
| 
 | ||||
| vi.mock('ws', () => ({ | ||||
|     Server: vi.fn().mockImplementation(() => mockWebSocketServer), | ||||
|     WebSocket: { | ||||
|         OPEN: 1, | ||||
|         CLOSED: 3, | ||||
|         CONNECTING: 0, | ||||
|         CLOSING: 2 | ||||
|     } | ||||
| })); | ||||
| 
 | ||||
| describe('WebSocket Service', () => { | ||||
|     let wsService: any; | ||||
|     let mockWebSocket: Partial<WebSocket>; | ||||
|     let log: any; | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|         vi.clearAllMocks(); | ||||
|          | ||||
|         // Create mock WebSocket
 | ||||
|         mockWebSocket = { | ||||
|             readyState: 1, // WebSocket.OPEN
 | ||||
|             send: vi.fn(), | ||||
|             close: vi.fn(), | ||||
|             on: vi.fn(), | ||||
|             ping: vi.fn() | ||||
|         }; | ||||
| 
 | ||||
|         // Clear clients set
 | ||||
|         mockWebSocketServer.clients.clear(); | ||||
|         mockWebSocketServer.clients.add(mockWebSocket as WebSocket); | ||||
| 
 | ||||
|         // Get mocked log
 | ||||
|         log = (await import('./log.js')).default; | ||||
| 
 | ||||
|         // Import service after mocks are set up
 | ||||
|         wsService = (await import('./ws.js')).default; | ||||
|          | ||||
|         // Initialize the WebSocket server in the service
 | ||||
|         // This simulates calling the init function with a mock HTTP server and session parser
 | ||||
|         const mockHttpServer = {} as any; | ||||
|         const mockSessionParser = vi.fn((req, params, cb) => cb()); | ||||
|         wsService.init(mockHttpServer, mockSessionParser); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|         vi.clearAllMocks(); | ||||
|         mockWebSocketServer.clients.clear(); | ||||
|     }); | ||||
| 
 | ||||
|     describe('LLM Stream Message Broadcasting', () => { | ||||
|         it('should send basic LLM stream message to all clients', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-chat-123', | ||||
|                 content: 'Hello world', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringContaining('Sending LLM stream message: chatNoteId=test-chat-123') | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should send LLM stream message with thinking state', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-chat-456', | ||||
|                 thinking: 'Processing your request...', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching(/thinking=true/) | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should send LLM stream message with tool execution', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-chat-789', | ||||
|                 toolExecution: { | ||||
|                     tool: 'calculator', | ||||
|                     args: { expression: '2+2' }, | ||||
|                     result: '4', | ||||
|                     toolCallId: 'call_123' | ||||
|                 }, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching(/toolExecution=true/) | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should send final LLM stream message with done flag', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-chat-final', | ||||
|                 content: 'Final response', | ||||
|                 done: true | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching(/done=true/) | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle error in LLM stream message', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-chat-error', | ||||
|                 error: 'AI service not available', | ||||
|                 done: true | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|         }); | ||||
| 
 | ||||
|         it('should log client count for LLM stream messages', () => { | ||||
|             // Add multiple mock clients
 | ||||
|             const mockClient2 = { readyState: 1, send: vi.fn() }; | ||||
|             const mockClient3 = { readyState: 1, send: vi.fn() }; | ||||
|             mockWebSocketServer.clients.add(mockClient2 as WebSocket); | ||||
|             mockWebSocketServer.clients.add(mockClient3 as WebSocket); | ||||
| 
 | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-multi-client', | ||||
|                 content: 'Message to all', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringContaining('Sent LLM stream message to 3 clients') | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle closed WebSocket connections gracefully', () => { | ||||
|             // Set WebSocket to closed state
 | ||||
|             mockWebSocket.readyState = 3; // WebSocket.CLOSED
 | ||||
| 
 | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-closed-connection', | ||||
|                 content: 'This should not be sent', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).not.toHaveBeenCalled(); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringContaining('Sent LLM stream message to 0 clients') | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle mixed open and closed connections', () => { | ||||
|             // Add a closed connection
 | ||||
|             const closedSocket = { readyState: 3, send: vi.fn() }; | ||||
|             mockWebSocketServer.clients.add(closedSocket as WebSocket); | ||||
| 
 | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-mixed-connections', | ||||
|                 content: 'Mixed connection test', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(closedSocket.send).not.toHaveBeenCalled(); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringContaining('Sent LLM stream message to 1 clients') | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('LLM Stream Message Content Verification', () => { | ||||
|         it('should handle empty content in stream message', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-empty-content', | ||||
|                 content: '', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching(/content=false/) | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle large content in stream message', () => { | ||||
|             const largeContent = 'x'.repeat(10000); | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-large-content', | ||||
|                 content: largeContent, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching(/content=true/) | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle unicode content in stream message', () => { | ||||
|             const unicodeContent = '你好 🌍 こんにちは مرحبا'; | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-unicode-content', | ||||
|                 content: unicodeContent, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             const sentData = JSON.parse((mockWebSocket.send as any).mock.calls[0][0]); | ||||
|             expect(sentData.content).toBe(unicodeContent); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle complex tool execution data', () => { | ||||
|             const complexToolExecution = { | ||||
|                 tool: 'data_analyzer', | ||||
|                 args: { | ||||
|                     dataset: { | ||||
|                         rows: 1000, | ||||
|                         columns: ['name', 'age', 'email'], | ||||
|                         filters: { active: true } | ||||
|                     }, | ||||
|                     operations: ['filter', 'group', 'aggregate'] | ||||
|                 }, | ||||
|                 result: { | ||||
|                     summary: 'Analysis complete', | ||||
|                     data: { filtered: 850, grouped: 10 } | ||||
|                 }, | ||||
|                 toolCallId: 'call_complex_analysis' | ||||
|             }; | ||||
| 
 | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-complex-tool', | ||||
|                 toolExecution: complexToolExecution, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             const sentData = JSON.parse((mockWebSocket.send as any).mock.calls[0][0]); | ||||
|             expect(sentData.toolExecution).toEqual(complexToolExecution); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Non-LLM Message Handling', () => { | ||||
|         it('should send regular messages without special LLM logging', () => { | ||||
|             const message = { | ||||
|                 type: 'frontend-update' as const, | ||||
|                 data: { test: 'data' } | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             expect(log.info).not.toHaveBeenCalledWith( | ||||
|                 expect.stringContaining('LLM stream message') | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle sync-failed messages quietly', () => { | ||||
|             const message = { | ||||
|                 type: 'sync-failed' as const, | ||||
|                 lastSyncedPush: 123 | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             // sync-failed messages should not generate regular logs
 | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle api-log-messages quietly', () => { | ||||
|             const message = { | ||||
|                 type: 'api-log-messages' as const, | ||||
|                 logs: ['log1', 'log2'] | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message)); | ||||
|             // api-log-messages should not generate regular logs
 | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('WebSocket Connection Management', () => { | ||||
|         it('should handle WebSocket send errors gracefully', () => { | ||||
|             // Mock send to throw an error
 | ||||
|             (mockWebSocket.send as any).mockImplementation(() => { | ||||
|                 throw new Error('Connection closed'); | ||||
|             }); | ||||
| 
 | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'test-send-error', | ||||
|                 content: 'This will fail to send', | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             // Should not throw
 | ||||
|             expect(() => wsService.sendMessageToAllClients(message)).not.toThrow(); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle multiple concurrent stream messages', async () => { | ||||
|             const promises = Array.from({ length: 10 }, (_, i) => { | ||||
|                 const message = { | ||||
|                     type: 'llm-stream' as const, | ||||
|                     chatNoteId: `concurrent-test-${i}`, | ||||
|                     content: `Message ${i}`, | ||||
|                     done: false | ||||
|                 }; | ||||
|                 return Promise.resolve(wsService.sendMessageToAllClients(message)); | ||||
|             }); | ||||
| 
 | ||||
|             await Promise.all(promises); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledTimes(10); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle rapid message bursts', () => { | ||||
|             for (let i = 0; i < 100; i++) { | ||||
|                 const message = { | ||||
|                     type: 'llm-stream' as const, | ||||
|                     chatNoteId: 'burst-test', | ||||
|                     content: `Burst ${i}`, | ||||
|                     done: i === 99 | ||||
|                 }; | ||||
|                 wsService.sendMessageToAllClients(message); | ||||
|             } | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalledTimes(100); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Message Serialization', () => { | ||||
|         it('should handle circular reference objects', () => { | ||||
|             const circularObj: any = { name: 'test' }; | ||||
|             circularObj.self = circularObj; | ||||
| 
 | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'circular-test', | ||||
|                 toolExecution: { | ||||
|                     tool: 'test', | ||||
|                     args: circularObj, | ||||
|                     result: 'success' | ||||
|                 }, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             // Should handle serialization error gracefully
 | ||||
|             expect(() => wsService.sendMessageToAllClients(message)).not.toThrow(); | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle undefined and null values in messages', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'null-undefined-test', | ||||
|                 content: undefined, | ||||
|                 thinking: null, | ||||
|                 toolExecution: undefined, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(mockWebSocket.send).toHaveBeenCalled(); | ||||
|             const sentData = JSON.parse((mockWebSocket.send as any).mock.calls[0][0]); | ||||
|             expect(sentData.thinking).toBeNull(); | ||||
|             expect(sentData.content).toBeUndefined(); | ||||
|         }); | ||||
| 
 | ||||
|         it('should preserve message structure integrity', () => { | ||||
|             const originalMessage = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'integrity-test', | ||||
|                 content: 'Test content', | ||||
|                 thinking: 'Test thinking', | ||||
|                 toolExecution: { | ||||
|                     tool: 'test_tool', | ||||
|                     args: { param1: 'value1' }, | ||||
|                     result: 'success' | ||||
|                 }, | ||||
|                 done: true | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(originalMessage); | ||||
| 
 | ||||
|             const sentData = JSON.parse((mockWebSocket.send as any).mock.calls[0][0]); | ||||
|             expect(sentData).toEqual(originalMessage); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('Logging Verification', () => { | ||||
|         it('should log message details correctly', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'log-verification-test', | ||||
|                 content: 'Test content', | ||||
|                 thinking: 'Test thinking', | ||||
|                 toolExecution: { tool: 'test' }, | ||||
|                 done: true | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching( | ||||
|                     /chatNoteId=log-verification-test.*content=true.*thinking=true.*toolExecution=true.*done=true/ | ||||
|                 ) | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('should log boolean flags correctly for empty values', () => { | ||||
|             const message = { | ||||
|                 type: 'llm-stream' as const, | ||||
|                 chatNoteId: 'empty-values-test', | ||||
|                 content: '', | ||||
|                 thinking: undefined, | ||||
|                 toolExecution: null, | ||||
|                 done: false | ||||
|             }; | ||||
| 
 | ||||
|             wsService.sendMessageToAllClients(message); | ||||
| 
 | ||||
|             expect(log.info).toHaveBeenCalledWith( | ||||
|                 expect.stringMatching( | ||||
|                     /content=false.*thinking=false.*toolExecution=false.*done=false/ | ||||
|                 ) | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 perf3ct
						perf3ct