diff --git a/src/__tests__/main/performance-optimizations.test.ts b/src/__tests__/main/performance-optimizations.test.ts new file mode 100644 index 00000000..4309633f --- /dev/null +++ b/src/__tests__/main/performance-optimizations.test.ts @@ -0,0 +1,517 @@ +/** + * @file performance-optimizations.test.ts + * @description Unit tests for performance optimizations in the main process. + * + * Tests cover: + * - Group chat session ID regex patterns + * - Buffer operations (O(1) append, correct join) + * - Debug logging conditional behavior + */ + +import { describe, it, expect } from 'vitest'; + +// ============================================================================ +// Regex Pattern Tests +// ============================================================================ +// These patterns are used in hot paths (process data handlers) and are +// pre-compiled at module level for performance. We test them here to ensure +// they match the expected session ID formats. + +describe('Group Chat Session ID Patterns', () => { + // Moderator session patterns + const REGEX_MODERATOR_SESSION = /^group-chat-(.+)-moderator-/; + const REGEX_MODERATOR_SESSION_TIMESTAMP = /^group-chat-(.+)-moderator-\d+$/; + + // Participant session patterns + const REGEX_PARTICIPANT_UUID = + /^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i; + const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/; + const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/; + + // Web broadcast patterns + const REGEX_AI_SUFFIX = /-ai-[^-]+$/; + const REGEX_AI_TAB_ID = /-ai-([^-]+)$/; + + describe('REGEX_MODERATOR_SESSION', () => { + it('should match moderator session IDs', () => { + const sessionId = 'group-chat-test-chat-123-moderator-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat-123'); + }); + + it('should match moderator synthesis session IDs', () => { + const sessionId = 'group-chat-my-chat-moderator-synthesis-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).not.toBeNull(); + expect(match![1]).toBe('my-chat'); + }); + + it('should not match participant session IDs', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).toBeNull(); + }); + + it('should not match regular session IDs', () => { + const sessionId = 'session-123-ai-tab1'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).toBeNull(); + }); + }); + + describe('REGEX_MODERATOR_SESSION_TIMESTAMP', () => { + it('should match moderator session IDs with timestamp suffix', () => { + const sessionId = 'group-chat-test-chat-moderator-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + }); + + it('should not match moderator synthesis session IDs', () => { + const sessionId = 'group-chat-my-chat-moderator-synthesis-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + expect(match).toBeNull(); + }); + + it('should not match session IDs without timestamp', () => { + const sessionId = 'group-chat-test-chat-moderator-'; + const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + expect(match).toBeNull(); + }); + }); + + describe('REGEX_PARTICIPANT_UUID', () => { + it('should match participant session IDs with UUID suffix', () => { + const sessionId = + 'group-chat-test-chat-participant-Agent1-a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + expect(match![2]).toBe('Agent1'); + expect(match![3]).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + }); + + it('should match UUID case-insensitively', () => { + const sessionId = + 'group-chat-test-chat-participant-Agent1-A1B2C3D4-E5F6-7890-ABCD-EF1234567890'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).not.toBeNull(); + }); + + it('should not match session IDs with invalid UUID', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-invalid-uuid'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).toBeNull(); + }); + + it('should handle participant names with hyphens', () => { + const sessionId = + 'group-chat-test-chat-participant-My-Agent-Name-a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).not.toBeNull(); + expect(match![2]).toBe('My-Agent-Name'); + }); + }); + + describe('REGEX_PARTICIPANT_TIMESTAMP', () => { + it('should match participant session IDs with 13-digit timestamp', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-1705678901234'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + expect(match![2]).toBe('Agent1'); + expect(match![3]).toBe('1705678901234'); + }); + + it('should match timestamps with more than 13 digits', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-17056789012345'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).not.toBeNull(); + }); + + it('should not match timestamps with less than 13 digits', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-123456789012'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).toBeNull(); + }); + + it('should handle participant names with hyphens', () => { + const sessionId = 'group-chat-test-chat-participant-My-Agent-1705678901234'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).not.toBeNull(); + expect(match![2]).toBe('My-Agent'); + }); + }); + + describe('REGEX_PARTICIPANT_FALLBACK', () => { + it('should match participant session IDs with simple names', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-anything'; + const match = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + expect(match![2]).toBe('Agent1'); + }); + + it('should stop at hyphen for participant name', () => { + const sessionId = 'group-chat-test-chat-participant-Agent-1-suffix'; + const match = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + expect(match).not.toBeNull(); + expect(match![2]).toBe('Agent'); // Stops at first hyphen after participant name start + }); + }); + + describe('REGEX_AI_SUFFIX', () => { + it('should match session IDs with -ai- suffix', () => { + const sessionId = 'session-123-ai-tab1'; + expect(REGEX_AI_SUFFIX.test(sessionId)).toBe(true); + }); + + it('should remove -ai- suffix correctly', () => { + const sessionId = 'session-123-ai-tab1'; + const baseSessionId = sessionId.replace(REGEX_AI_SUFFIX, ''); + expect(baseSessionId).toBe('session-123'); + }); + + it('should not match session IDs without -ai- suffix', () => { + const sessionId = 'session-123-terminal'; + expect(REGEX_AI_SUFFIX.test(sessionId)).toBe(false); + }); + }); + + describe('REGEX_AI_TAB_ID', () => { + it('should extract tab ID from session ID', () => { + const sessionId = 'session-123-ai-tab1'; + const match = sessionId.match(REGEX_AI_TAB_ID); + expect(match).not.toBeNull(); + expect(match![1]).toBe('tab1'); + }); + + it('should handle complex tab IDs', () => { + const sessionId = 'session-123-ai-abc123xyz'; + const match = sessionId.match(REGEX_AI_TAB_ID); + expect(match).not.toBeNull(); + expect(match![1]).toBe('abc123xyz'); + }); + + it('should return null for non-AI sessions', () => { + const sessionId = 'session-123-terminal'; + const match = sessionId.match(REGEX_AI_TAB_ID); + expect(match).toBeNull(); + }); + }); +}); + +// ============================================================================ +// Buffer Operation Tests +// ============================================================================ +// These tests verify the O(1) buffer implementation works correctly. + +describe('Group Chat Output Buffer', () => { + // Simulate the buffer implementation + type BufferEntry = { chunks: string[]; totalLength: number }; + let buffers: Map; + + function appendToBuffer(sessionId: string, data: string): number { + let buffer = buffers.get(sessionId); + if (!buffer) { + buffer = { chunks: [], totalLength: 0 }; + buffers.set(sessionId, buffer); + } + buffer.chunks.push(data); + buffer.totalLength += data.length; + return buffer.totalLength; + } + + function getBufferedOutput(sessionId: string): string | undefined { + const buffer = buffers.get(sessionId); + if (!buffer || buffer.chunks.length === 0) return undefined; + return buffer.chunks.join(''); + } + + beforeEach(() => { + buffers = new Map(); + }); + + describe('appendToBuffer', () => { + it('should create new buffer entry on first append', () => { + const length = appendToBuffer('session-1', 'hello'); + expect(length).toBe(5); + expect(buffers.has('session-1')).toBe(true); + }); + + it('should append to existing buffer', () => { + appendToBuffer('session-1', 'hello'); + const length = appendToBuffer('session-1', ' world'); + expect(length).toBe(11); // 5 + 6 + }); + + it('should track total length correctly across multiple appends', () => { + appendToBuffer('session-1', 'a'); // 1 + appendToBuffer('session-1', 'bb'); // 3 + appendToBuffer('session-1', 'ccc'); // 6 + const length = appendToBuffer('session-1', 'dddd'); // 10 + expect(length).toBe(10); + }); + + it('should maintain separate buffers for different sessions', () => { + appendToBuffer('session-1', 'hello'); + appendToBuffer('session-2', 'world'); + + expect(buffers.get('session-1')?.totalLength).toBe(5); + expect(buffers.get('session-2')?.totalLength).toBe(5); + }); + + it('should handle empty strings', () => { + appendToBuffer('session-1', 'hello'); + const length = appendToBuffer('session-1', ''); + expect(length).toBe(5); + }); + + it('should handle large data chunks', () => { + const largeData = 'x'.repeat(100000); + const length = appendToBuffer('session-1', largeData); + expect(length).toBe(100000); + }); + }); + + describe('getBufferedOutput', () => { + it('should return undefined for non-existent session', () => { + const output = getBufferedOutput('non-existent'); + expect(output).toBeUndefined(); + }); + + it('should return undefined for empty buffer', () => { + buffers.set('session-1', { chunks: [], totalLength: 0 }); + const output = getBufferedOutput('session-1'); + expect(output).toBeUndefined(); + }); + + it('should join chunks correctly', () => { + appendToBuffer('session-1', 'hello'); + appendToBuffer('session-1', ' '); + appendToBuffer('session-1', 'world'); + + const output = getBufferedOutput('session-1'); + expect(output).toBe('hello world'); + }); + + it('should preserve order of chunks', () => { + appendToBuffer('session-1', '1'); + appendToBuffer('session-1', '2'); + appendToBuffer('session-1', '3'); + + const output = getBufferedOutput('session-1'); + expect(output).toBe('123'); + }); + + it('should handle special characters', () => { + appendToBuffer('session-1', '{"type": "test"}'); + appendToBuffer('session-1', '\n'); + appendToBuffer('session-1', '{"type": "test2"}'); + + const output = getBufferedOutput('session-1'); + expect(output).toBe('{"type": "test"}\n{"type": "test2"}'); + }); + }); + + describe('buffer cleanup', () => { + it('should allow deletion of buffer entries', () => { + appendToBuffer('session-1', 'data'); + buffers.delete('session-1'); + expect(buffers.has('session-1')).toBe(false); + }); + + it('should not affect other buffers on deletion', () => { + appendToBuffer('session-1', 'data1'); + appendToBuffer('session-2', 'data2'); + + buffers.delete('session-1'); + + expect(buffers.has('session-1')).toBe(false); + expect(getBufferedOutput('session-2')).toBe('data2'); + }); + }); + + describe('performance characteristics', () => { + it('should maintain O(1) append by tracking totalLength incrementally', () => { + // This test verifies the implementation doesn't use reduce() + // by checking that totalLength matches sum of chunk lengths + const chunks = ['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5']; + + for (const chunk of chunks) { + appendToBuffer('session-1', chunk); + } + + const buffer = buffers.get('session-1')!; + const actualSum = buffer.chunks.reduce((sum, chunk) => sum + chunk.length, 0); + + expect(buffer.totalLength).toBe(actualSum); + }); + + it('should handle many small appends efficiently', () => { + // Simulate streaming output with many small chunks + const startTime = Date.now(); + + for (let i = 0; i < 10000; i++) { + appendToBuffer('session-1', 'x'); + } + + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms for 10000 appends + // (generous threshold to avoid flaky tests) + expect(elapsed).toBeLessThan(100); + expect(buffers.get('session-1')?.totalLength).toBe(10000); + }); + }); +}); + +// ============================================================================ +// Debug Logging Tests +// ============================================================================ +// These tests verify the conditional debug logging behavior. + +describe('Debug Logging', () => { + // Simulate the debugLog function + function createDebugLog(isEnabled: boolean) { + const logs: string[] = []; + + function debugLog(prefix: string, message: string, ...args: unknown[]): void { + if (isEnabled) { + logs.push(`[${prefix}] ${message}`); + } + } + + return { debugLog, logs }; + } + + it('should log when debug is enabled', () => { + const { debugLog, logs } = createDebugLog(true); + + debugLog('GroupChat:Debug', 'Test message'); + + expect(logs).toHaveLength(1); + expect(logs[0]).toBe('[GroupChat:Debug] Test message'); + }); + + it('should not log when debug is disabled', () => { + const { debugLog, logs } = createDebugLog(false); + + debugLog('GroupChat:Debug', 'Test message'); + + expect(logs).toHaveLength(0); + }); + + it('should format message with prefix correctly', () => { + const { debugLog, logs } = createDebugLog(true); + + debugLog('WebBroadcast', 'Broadcasting to session'); + + expect(logs[0]).toBe('[WebBroadcast] Broadcasting to session'); + }); + + it('should handle multiple log calls', () => { + const { debugLog, logs } = createDebugLog(true); + + debugLog('Test', 'Message 1'); + debugLog('Test', 'Message 2'); + debugLog('Test', 'Message 3'); + + expect(logs).toHaveLength(3); + }); +}); + +// ============================================================================ +// Session ID Parsing Tests +// ============================================================================ +// These tests verify the parseParticipantSessionId logic. + +describe('parseParticipantSessionId', () => { + // Simulate the parsing function + function parseParticipantSessionId( + sessionId: string + ): { groupChatId: string; participantName: string } | null { + if (!sessionId.includes('-participant-')) { + return null; + } + + const REGEX_PARTICIPANT_UUID = + /^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i; + const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/; + const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/; + + const uuidMatch = sessionId.match(REGEX_PARTICIPANT_UUID); + if (uuidMatch) { + return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] }; + } + + const timestampMatch = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + if (timestampMatch) { + return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] }; + } + + const fallbackMatch = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + if (fallbackMatch) { + return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] }; + } + + return null; + } + + it('should return null for non-participant session IDs', () => { + expect(parseParticipantSessionId('session-123')).toBeNull(); + expect(parseParticipantSessionId('group-chat-test-moderator-123')).toBeNull(); + }); + + it('should parse UUID-based participant session IDs', () => { + const result = parseParticipantSessionId( + 'group-chat-my-chat-participant-Claude-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + expect(result).toEqual({ + groupChatId: 'my-chat', + participantName: 'Claude', + }); + }); + + it('should parse timestamp-based participant session IDs', () => { + const result = parseParticipantSessionId('group-chat-my-chat-participant-Claude-1705678901234'); + expect(result).toEqual({ + groupChatId: 'my-chat', + participantName: 'Claude', + }); + }); + + it('should handle participant names with hyphens using UUID format', () => { + const result = parseParticipantSessionId( + 'group-chat-my-chat-participant-Claude-Code-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + expect(result).toEqual({ + groupChatId: 'my-chat', + participantName: 'Claude-Code', + }); + }); + + it('should handle group chat IDs with hyphens', () => { + const result = parseParticipantSessionId( + 'group-chat-my-complex-chat-id-participant-Agent-1705678901234' + ); + expect(result).toEqual({ + groupChatId: 'my-complex-chat-id', + participantName: 'Agent', + }); + }); + + it('should prefer UUID match over timestamp match', () => { + // This session ID could theoretically match both patterns + // but UUID should be tried first + const result = parseParticipantSessionId( + 'group-chat-chat-participant-Agent-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + expect(result).not.toBeNull(); + expect(result?.participantName).toBe('Agent'); + }); +}); + +// Need to import beforeEach for buffer tests +import { beforeEach } from 'vitest'; diff --git a/src/main/index.ts b/src/main/index.ts index 992c9bff..efe0ec37 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -98,7 +98,7 @@ const DEBUG_GROUP_CHAT = process.env.NODE_ENV === 'development' || process.env.DEBUG_GROUP_CHAT === '1'; /** Log debug message only in development mode. Avoids overhead in production. */ - + function debugLog(prefix: string, message: string, ...args: any[]): void { if (DEBUG_GROUP_CHAT) { console.log(`[${prefix}] ${message}`, ...args); @@ -2812,25 +2812,26 @@ function setupIpcHandlers() { // Buffer for group chat output (keyed by sessionId) // We buffer output and only route it on process exit to avoid duplicate messages from streaming chunks // Uses array of chunks for O(1) append performance instead of O(n) string concatenation -const groupChatOutputBuffers = new Map(); +// Tracks totalLength incrementally to avoid O(n) reduce on every append +const groupChatOutputBuffers = new Map(); /** Append data to group chat output buffer. O(1) operation. */ function appendToGroupChatBuffer(sessionId: string, data: string): number { - let chunks = groupChatOutputBuffers.get(sessionId); - if (!chunks) { - chunks = []; - groupChatOutputBuffers.set(sessionId, chunks); + let buffer = groupChatOutputBuffers.get(sessionId); + if (!buffer) { + buffer = { chunks: [], totalLength: 0 }; + groupChatOutputBuffers.set(sessionId, buffer); } - chunks.push(data); - // Return approximate total length for debug logging - return chunks.reduce((sum, chunk) => sum + chunk.length, 0); + buffer.chunks.push(data); + buffer.totalLength += data.length; + return buffer.totalLength; } /** Get buffered output as a single string. Joins chunks on read. */ function getGroupChatBufferedOutput(sessionId: string): string | undefined { - const chunks = groupChatOutputBuffers.get(sessionId); - if (!chunks || chunks.length === 0) return undefined; - return chunks.join(''); + const buffer = groupChatOutputBuffers.get(sessionId); + if (!buffer || buffer.chunks.length === 0) return undefined; + return buffer.chunks.join(''); } /**