From 944a72cf5ab2a9a2a6a88a3b3d14722c145f10a3 Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Tue, 27 Jan 2026 00:21:47 +0500 Subject: [PATCH] fix: address PR review feedback and add DB caching optimization PR Review Fixes: - Fix race condition in exit-listener by moving markAndMaybeSynthesize to explicit code paths instead of finally() block - Add buffer size limits (MAX_BUFFER_SIZE 10MB) with warning logs - Add REGEX_BATCH_SESSION and REGEX_SYNOPSIS_SESSION for proper filtering - Fix type safety using canonical ToolExecution and UsageStats imports - Fix usage-listener indentation bug where safeSend was inside wrong block Performance Optimizations: - Add GROUP_CHAT_PREFIX constant for fast string prefix checks - Skip expensive regex matching for non-group-chat sessions - Eliminate redundant loadGroupChat calls by using updateParticipant return value directly (DB caching) - Add MSG_ID_RANDOM_LENGTH constant for web broadcast deduplication Test Coverage: - Add 4 new test files (exit, data, usage, session-id listeners) - Total 93 tests covering edge cases, DB caching, and performance - Verify exact participants data flow from updateParticipant - Test optional emitter handling and empty participants arrays --- src/main/constants.ts | 5 + src/main/index.ts | 4 + .../__tests__/data-listener.test.ts | 324 +++++++++++++ .../__tests__/exit-listener.test.ts | 420 +++++++++++++++++ .../__tests__/session-id-listener.test.ts | 402 ++++++++++++++++ .../__tests__/usage-listener.test.ts | 431 ++++++++++++++++++ src/main/process-listeners/data-listener.ts | 59 ++- src/main/process-listeners/exit-listener.ts | 33 +- .../process-listeners/forwarding-listeners.ts | 11 +- .../process-listeners/session-id-listener.ts | 25 +- src/main/process-listeners/types.ts | 4 + src/main/process-listeners/usage-listener.ts | 131 +++--- 12 files changed, 1759 insertions(+), 90 deletions(-) create mode 100644 src/main/process-listeners/__tests__/data-listener.test.ts create mode 100644 src/main/process-listeners/__tests__/exit-listener.test.ts create mode 100644 src/main/process-listeners/__tests__/session-id-listener.test.ts create mode 100644 src/main/process-listeners/__tests__/usage-listener.test.ts diff --git a/src/main/constants.ts b/src/main/constants.ts index cda32029..f5006fd5 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -26,6 +26,11 @@ export const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)- export const REGEX_AI_SUFFIX = /-ai-[^-]+$/; export const REGEX_AI_TAB_ID = /-ai-([^-]+)$/; +// Auto Run session ID patterns (batch and synopsis operations) +// Format: {sessionId}-batch-{timestamp} or {sessionId}-synopsis-{timestamp} +export const REGEX_BATCH_SESSION = /-batch-\d+$/; +export const REGEX_SYNOPSIS_SESSION = /-synopsis-\d+$/; + // ============================================================================ // Buffer Size Limits // ============================================================================ diff --git a/src/main/index.ts b/src/main/index.ts index 34294657..796ef3f1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -77,6 +77,8 @@ import { REGEX_MODERATOR_SESSION_TIMESTAMP, REGEX_AI_SUFFIX, REGEX_AI_TAB_ID, + REGEX_BATCH_SESSION, + REGEX_SYNOPSIS_SESSION, debugLog, } from './constants'; // initAutoUpdater is now used by window-manager.ts (Phase 4 refactoring) @@ -673,6 +675,8 @@ function setupProcessListeners() { REGEX_MODERATOR_SESSION_TIMESTAMP, REGEX_AI_SUFFIX, REGEX_AI_TAB_ID, + REGEX_BATCH_SESSION, + REGEX_SYNOPSIS_SESSION, }, logger, }); diff --git a/src/main/process-listeners/__tests__/data-listener.test.ts b/src/main/process-listeners/__tests__/data-listener.test.ts new file mode 100644 index 00000000..d3950d89 --- /dev/null +++ b/src/main/process-listeners/__tests__/data-listener.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for data listener. + * Handles process output data including group chat buffering and web broadcasting. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupDataListener } from '../data-listener'; +import type { ProcessManager } from '../../process-manager'; +import type { SafeSendFn } from '../../utils/safe-send'; +import type { ProcessListenerDependencies } from '../types'; + +describe('Data Listener', () => { + let mockProcessManager: ProcessManager; + let mockSafeSend: SafeSendFn; + let mockGetWebServer: ProcessListenerDependencies['getWebServer']; + let mockWebServer: { broadcastToSessionClients: ReturnType }; + let mockOutputBuffer: ProcessListenerDependencies['outputBuffer']; + let mockOutputParser: ProcessListenerDependencies['outputParser']; + let mockDebugLog: ProcessListenerDependencies['debugLog']; + let mockPatterns: ProcessListenerDependencies['patterns']; + let eventHandlers: Map void>; + + beforeEach(() => { + vi.clearAllMocks(); + eventHandlers = new Map(); + + mockSafeSend = vi.fn(); + mockWebServer = { + broadcastToSessionClients: vi.fn(), + }; + mockGetWebServer = vi.fn().mockReturnValue(mockWebServer); + mockOutputBuffer = { + appendToGroupChatBuffer: vi.fn().mockReturnValue(100), + getGroupChatBufferedOutput: vi.fn().mockReturnValue('test output'), + clearGroupChatBuffer: vi.fn(), + }; + mockOutputParser = { + extractTextFromStreamJson: vi.fn().mockReturnValue('parsed response'), + parseParticipantSessionId: vi.fn().mockReturnValue(null), + }; + mockDebugLog = vi.fn(); + mockPatterns = { + REGEX_MODERATOR_SESSION: /^group-chat-(.+)-moderator-/, + REGEX_MODERATOR_SESSION_TIMESTAMP: /^group-chat-(.+)-moderator-\d+$/, + REGEX_AI_SUFFIX: /-ai-[^-]+$/, + REGEX_AI_TAB_ID: /-ai-([^-]+)$/, + REGEX_BATCH_SESSION: /-batch-\d+$/, + REGEX_SYNOPSIS_SESSION: /-synopsis-\d+$/, + }; + + mockProcessManager = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + } as unknown as ProcessManager; + }); + + const setupListener = () => { + setupDataListener(mockProcessManager, { + safeSend: mockSafeSend, + getWebServer: mockGetWebServer, + outputBuffer: mockOutputBuffer, + outputParser: mockOutputParser, + debugLog: mockDebugLog, + patterns: mockPatterns, + }); + }; + + describe('Event Registration', () => { + it('should register the data event listener', () => { + setupListener(); + expect(mockProcessManager.on).toHaveBeenCalledWith('data', expect.any(Function)); + }); + }); + + describe('Regular Process Data', () => { + it('should forward data to renderer for non-group-chat sessions', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('regular-session-123', 'test output'); + + expect(mockSafeSend).toHaveBeenCalledWith( + 'process:data', + 'regular-session-123', + 'test output' + ); + }); + + it('should broadcast to web clients for AI sessions', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-ai-tab1', 'test output'); + + expect(mockWebServer.broadcastToSessionClients).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ + type: 'session_output', + sessionId: 'session-123', + tabId: 'tab1', + data: 'test output', + source: 'ai', + }) + ); + }); + + it('should extract base session ID correctly', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('my-session-ai-mytab', 'test output'); + + expect(mockWebServer.broadcastToSessionClients).toHaveBeenCalledWith( + 'my-session', + expect.objectContaining({ + sessionId: 'my-session', + tabId: 'mytab', + }) + ); + }); + }); + + describe('Moderator Output Buffering', () => { + it('should buffer moderator output instead of forwarding', () => { + setupListener(); + const handler = eventHandlers.get('data'); + const sessionId = 'group-chat-test-chat-123-moderator-abc123'; + + handler?.(sessionId, 'moderator output'); + + expect(mockOutputBuffer.appendToGroupChatBuffer).toHaveBeenCalledWith( + sessionId, + 'moderator output' + ); + expect(mockSafeSend).not.toHaveBeenCalled(); + }); + + it('should extract group chat ID from moderator session', () => { + setupListener(); + const handler = eventHandlers.get('data'); + const sessionId = 'group-chat-my-chat-id-moderator-12345'; + + handler?.(sessionId, 'test'); + + expect(mockDebugLog).toHaveBeenCalledWith( + 'GroupChat:Debug', + expect.stringContaining('my-chat-id') + ); + }); + + it('should warn when buffer size exceeds limit', () => { + mockOutputBuffer.appendToGroupChatBuffer = vi.fn().mockReturnValue(15 * 1024 * 1024); // 15MB + setupListener(); + const handler = eventHandlers.get('data'); + const sessionId = 'group-chat-test-chat-123-moderator-abc123'; + + handler?.(sessionId, 'large output'); + + expect(mockDebugLog).toHaveBeenCalledWith( + 'GroupChat:Debug', + expect.stringContaining('WARNING: Buffer size') + ); + }); + }); + + describe('Participant Output Buffering', () => { + beforeEach(() => { + mockOutputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ + groupChatId: 'test-chat-123', + participantName: 'TestAgent', + }); + }); + + it('should buffer participant output instead of forwarding', () => { + setupListener(); + const handler = eventHandlers.get('data'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 'participant output'); + + expect(mockOutputBuffer.appendToGroupChatBuffer).toHaveBeenCalledWith( + sessionId, + 'participant output' + ); + expect(mockSafeSend).not.toHaveBeenCalled(); + }); + + it('should warn when participant buffer size exceeds limit', () => { + mockOutputBuffer.appendToGroupChatBuffer = vi.fn().mockReturnValue(15 * 1024 * 1024); // 15MB + setupListener(); + const handler = eventHandlers.get('data'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 'large output'); + + expect(mockDebugLog).toHaveBeenCalledWith( + 'GroupChat:Debug', + expect.stringContaining('WARNING: Buffer size') + ); + }); + }); + + describe('Web Broadcast Filtering', () => { + it('should skip PTY terminal output', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-terminal', 'terminal output'); + + expect(mockWebServer.broadcastToSessionClients).not.toHaveBeenCalled(); + // But should still forward to renderer + expect(mockSafeSend).toHaveBeenCalledWith( + 'process:data', + 'session-123-terminal', + 'terminal output' + ); + }); + + it('should skip batch session output using regex pattern', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-batch-1234567890', 'batch output'); + + expect(mockWebServer.broadcastToSessionClients).not.toHaveBeenCalled(); + }); + + it('should skip synopsis session output using regex pattern', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-synopsis-1234567890', 'synopsis output'); + + expect(mockWebServer.broadcastToSessionClients).not.toHaveBeenCalled(); + }); + + it('should NOT skip sessions with "batch" in UUID (false positive prevention)', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + // Session ID with "batch" in the UUID but not matching the pattern -batch-{digits} + handler?.('session-batch-uuid-ai-tab1', 'output'); + + // Should broadcast because it doesn't match the -batch-\d+$ pattern + expect(mockWebServer.broadcastToSessionClients).toHaveBeenCalled(); + }); + + it('should broadcast when no web server is available', () => { + mockGetWebServer = vi.fn().mockReturnValue(null); + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-ai-tab1', 'test output'); + + // Should still forward to renderer + expect(mockSafeSend).toHaveBeenCalledWith( + 'process:data', + 'session-123-ai-tab1', + 'test output' + ); + // But not broadcast (no web server) + expect(mockWebServer.broadcastToSessionClients).not.toHaveBeenCalled(); + }); + }); + + describe('Message ID Generation', () => { + it('should generate unique message IDs for broadcasts', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-ai-tab1', 'output 1'); + handler?.('session-123-ai-tab1', 'output 2'); + + const calls = mockWebServer.broadcastToSessionClients.mock.calls; + const msgId1 = calls[0][1].msgId; + const msgId2 = calls[1][1].msgId; + + expect(msgId1).toBeDefined(); + expect(msgId2).toBeDefined(); + expect(msgId1).not.toBe(msgId2); + }); + + it('should include timestamp in message ID', () => { + const beforeTime = Date.now(); + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-ai-tab1', 'test output'); + + const msgId = mockWebServer.broadcastToSessionClients.mock.calls[0][1].msgId; + const timestamp = parseInt(msgId.split('-')[0], 10); + + expect(timestamp).toBeGreaterThanOrEqual(beforeTime); + expect(timestamp).toBeLessThanOrEqual(Date.now()); + }); + }); + + describe('Source Detection', () => { + it('should identify AI source from session ID', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123-ai-tab1', 'ai output'); + + expect(mockWebServer.broadcastToSessionClients).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ source: 'ai' }) + ); + }); + + it('should identify terminal source for non-AI sessions', () => { + setupListener(); + const handler = eventHandlers.get('data'); + + handler?.('session-123', 'terminal output'); + + expect(mockWebServer.broadcastToSessionClients).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ source: 'terminal' }) + ); + }); + }); +}); diff --git a/src/main/process-listeners/__tests__/exit-listener.test.ts b/src/main/process-listeners/__tests__/exit-listener.test.ts new file mode 100644 index 00000000..793e1233 --- /dev/null +++ b/src/main/process-listeners/__tests__/exit-listener.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for exit listener. + * Handles process exit events including group chat moderator/participant exits. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupExitListener } from '../exit-listener'; +import type { ProcessManager } from '../../process-manager'; +import type { ProcessListenerDependencies } from '../types'; + +describe('Exit Listener', () => { + let mockProcessManager: ProcessManager; + let mockDeps: Parameters[1]; + let eventHandlers: Map void>; + + // Create a minimal mock group chat + const createMockGroupChat = () => ({ + id: 'test-chat-123', + name: 'Test Chat', + moderatorAgentId: 'claude-code', + moderatorSessionId: 'group-chat-test-chat-123-moderator', + participants: [ + { + name: 'TestAgent', + agentId: 'claude-code', + sessionId: 'group-chat-test-chat-123-participant-TestAgent-abc123', + addedAt: Date.now(), + }, + ], + createdAt: Date.now(), + updatedAt: Date.now(), + logPath: '/tmp/test-chat.log', + imagesDir: '/tmp/test-chat-images', + }); + + beforeEach(() => { + vi.clearAllMocks(); + eventHandlers = new Map(); + + mockProcessManager = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + } as unknown as ProcessManager; + + mockDeps = { + safeSend: vi.fn(), + powerManager: { + addBlockReason: vi.fn(), + removeBlockReason: vi.fn(), + }, + groupChatEmitters: { + emitStateChange: vi.fn(), + emitParticipantState: vi.fn(), + emitParticipantsChanged: vi.fn(), + emitModeratorUsage: vi.fn(), + }, + groupChatRouter: { + routeModeratorResponse: vi.fn().mockResolvedValue(undefined), + routeAgentResponse: vi.fn().mockResolvedValue(undefined), + markParticipantResponded: vi.fn().mockResolvedValue(undefined), + spawnModeratorSynthesis: vi.fn().mockResolvedValue(undefined), + getGroupChatReadOnlyState: vi.fn().mockReturnValue(false), + respawnParticipantWithRecovery: vi.fn().mockResolvedValue(undefined), + }, + groupChatStorage: { + loadGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), + updateGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), + updateParticipant: vi.fn().mockResolvedValue(createMockGroupChat()), + }, + sessionRecovery: { + needsSessionRecovery: vi.fn().mockReturnValue(false), + initiateSessionRecovery: vi.fn().mockResolvedValue(true), + }, + outputBuffer: { + appendToGroupChatBuffer: vi.fn().mockReturnValue(100), + getGroupChatBufferedOutput: vi.fn().mockReturnValue('{"type":"text","text":"test output"}'), + clearGroupChatBuffer: vi.fn(), + }, + outputParser: { + extractTextFromStreamJson: vi.fn().mockReturnValue('parsed response'), + parseParticipantSessionId: vi.fn().mockReturnValue(null), + }, + getProcessManager: () => mockProcessManager, + getAgentDetector: () => + ({ + detectAgents: vi.fn(), + }) as unknown as ReturnType, + getWebServer: () => null, + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + debugLog: vi.fn(), + patterns: { + REGEX_MODERATOR_SESSION: /^group-chat-(.+)-moderator-/, + REGEX_MODERATOR_SESSION_TIMESTAMP: /^group-chat-(.+)-moderator-\d+$/, + REGEX_AI_SUFFIX: /-ai-[^-]+$/, + REGEX_AI_TAB_ID: /-ai-([^-]+)$/, + REGEX_BATCH_SESSION: /-batch-\d+$/, + REGEX_SYNOPSIS_SESSION: /-synopsis-\d+$/, + }, + }; + }); + + const setupListener = () => { + setupExitListener(mockProcessManager, mockDeps); + }; + + describe('Event Registration', () => { + it('should register the exit event listener', () => { + setupListener(); + expect(mockProcessManager.on).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + }); + + describe('Regular Process Exit', () => { + it('should forward exit event to renderer for non-group-chat sessions', () => { + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockDeps.safeSend).toHaveBeenCalledWith('process:exit', 'regular-session-123', 0); + }); + + it('should remove power block for non-group-chat sessions', () => { + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockDeps.powerManager.removeBlockReason).toHaveBeenCalledWith( + 'session:regular-session-123' + ); + }); + }); + + describe('Participant Exit', () => { + beforeEach(() => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ + groupChatId: 'test-chat-123', + participantName: 'TestAgent', + }); + }); + + it('should parse and route participant response on exit', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.routeAgentResponse).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent', + 'parsed response', + expect.anything() + ); + }); + }); + + it('should mark participant as responded after successful routing', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.markParticipantResponded).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent' + ); + }); + }); + + it('should clear output buffer after processing', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.outputBuffer.clearGroupChatBuffer).toHaveBeenCalledWith(sessionId); + }); + }); + + it('should not route when buffered output is empty', async () => { + mockDeps.outputBuffer.getGroupChatBufferedOutput = vi.fn().mockReturnValue(''); + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + // Give async operations time to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDeps.groupChatRouter.routeAgentResponse).not.toHaveBeenCalled(); + }); + + it('should not route when parsed text is empty', async () => { + mockDeps.outputParser.extractTextFromStreamJson = vi.fn().mockReturnValue(' '); + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + // Give async operations time to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDeps.groupChatRouter.routeAgentResponse).not.toHaveBeenCalled(); + }); + }); + + describe('Session Recovery', () => { + beforeEach(() => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ + groupChatId: 'test-chat-123', + participantName: 'TestAgent', + }); + mockDeps.sessionRecovery.needsSessionRecovery = vi.fn().mockReturnValue(true); + }); + + it('should initiate session recovery when needed', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.sessionRecovery.initiateSessionRecovery).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent' + ); + }); + }); + + it('should respawn participant after recovery initiation', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.respawnParticipantWithRecovery).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent', + expect.anything(), + expect.anything() + ); + }); + }); + + it('should clear buffer before initiating recovery', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.outputBuffer.clearGroupChatBuffer).toHaveBeenCalledWith(sessionId); + }); + }); + + it('should not mark participant as responded when recovery succeeds', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 50)); + + // When recovery succeeds, markParticipantResponded should NOT be called + // because the recovery spawn will handle that + expect(mockDeps.groupChatRouter.markParticipantResponded).not.toHaveBeenCalled(); + }); + + it('should mark participant as responded when recovery fails', async () => { + mockDeps.groupChatRouter.respawnParticipantWithRecovery = vi + .fn() + .mockRejectedValue(new Error('Recovery failed')); + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.markParticipantResponded).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent' + ); + }); + }); + }); + + describe('Moderator Exit', () => { + it('should route moderator response on exit', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-moderator-1234567890'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.routeModeratorResponse).toHaveBeenCalledWith( + 'test-chat-123', + 'parsed response', + expect.anything(), + expect.anything(), + false + ); + }); + }); + + it('should clear moderator buffer after processing', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-moderator-1234567890'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.outputBuffer.clearGroupChatBuffer).toHaveBeenCalledWith(sessionId); + }); + }); + + it('should handle synthesis sessions correctly', async () => { + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-moderator-synthesis-1234567890'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.routeModeratorResponse).toHaveBeenCalled(); + }); + }); + }); + + describe('Error Handling', () => { + beforeEach(() => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ + groupChatId: 'test-chat-123', + participantName: 'TestAgent', + }); + }); + + it('should log error when routing fails', async () => { + mockDeps.groupChatRouter.routeAgentResponse = vi + .fn() + .mockRejectedValue(new Error('Route failed')); + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + it('should attempt fallback parsing when primary parsing fails', async () => { + // First call throws, second call (fallback) succeeds + mockDeps.outputParser.extractTextFromStreamJson = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('Parse error'); + }) + .mockReturnValueOnce('fallback parsed response'); + + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + // Should have been called twice: once with agentType, once without (fallback) + expect(mockDeps.outputParser.extractTextFromStreamJson).toHaveBeenCalledTimes(2); + }); + }); + + it('should still mark participant as responded after routing error', async () => { + mockDeps.groupChatRouter.routeAgentResponse = vi + .fn() + .mockRejectedValue(new Error('Route failed')); + mockDeps.outputParser.extractTextFromStreamJson = vi + .fn() + .mockReturnValueOnce('parsed response') + .mockReturnValueOnce('fallback response'); + + setupListener(); + const handler = eventHandlers.get('exit'); + const sessionId = 'group-chat-test-chat-123-participant-TestAgent-abc123'; + + handler?.(sessionId, 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.markParticipantResponded).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent' + ); + }); + }); + }); +}); diff --git a/src/main/process-listeners/__tests__/session-id-listener.test.ts b/src/main/process-listeners/__tests__/session-id-listener.test.ts new file mode 100644 index 00000000..c0f6773c --- /dev/null +++ b/src/main/process-listeners/__tests__/session-id-listener.test.ts @@ -0,0 +1,402 @@ +/** + * Tests for session ID listener. + * Handles agent session ID storage for conversation resume. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupSessionIdListener } from '../session-id-listener'; +import type { ProcessManager } from '../../process-manager'; + +describe('Session ID Listener', () => { + let mockProcessManager: ProcessManager; + let mockDeps: Parameters[1]; + let eventHandlers: Map void>; + + // Create a minimal mock group chat + const createMockGroupChat = () => ({ + id: 'test-chat-123', + name: 'Test Chat', + moderatorAgentId: 'claude-code', + moderatorSessionId: 'group-chat-test-chat-123-moderator', + participants: [ + { + name: 'TestAgent', + agentId: 'claude-code', + sessionId: 'group-chat-test-chat-123-participant-TestAgent-abc123', + addedAt: Date.now(), + }, + ], + createdAt: Date.now(), + updatedAt: Date.now(), + logPath: '/tmp/test-chat.log', + imagesDir: '/tmp/test-chat-images', + }); + + beforeEach(() => { + vi.clearAllMocks(); + eventHandlers = new Map(); + + mockProcessManager = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + } as unknown as ProcessManager; + + mockDeps = { + safeSend: vi.fn(), + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + groupChatEmitters: { + emitParticipantsChanged: vi.fn(), + emitModeratorSessionIdChanged: vi.fn(), + }, + groupChatStorage: { + loadGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), + updateGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), + updateParticipant: vi.fn().mockResolvedValue(createMockGroupChat()), + }, + outputParser: { + extractTextFromStreamJson: vi.fn().mockReturnValue('parsed response'), + parseParticipantSessionId: vi.fn().mockReturnValue(null), + }, + patterns: { + REGEX_MODERATOR_SESSION: /^group-chat-(.+)-moderator-/, + REGEX_MODERATOR_SESSION_TIMESTAMP: /^group-chat-(.+)-moderator-\d+$/, + REGEX_AI_SUFFIX: /-ai-[^-]+$/, + REGEX_AI_TAB_ID: /-ai-([^-]+)$/, + REGEX_BATCH_SESSION: /-batch-\d+$/, + REGEX_SYNOPSIS_SESSION: /-synopsis-\d+$/, + }, + }; + }); + + const setupListener = () => { + setupSessionIdListener(mockProcessManager, mockDeps); + }; + + describe('Event Registration', () => { + it('should register the session-id event listener', () => { + setupListener(); + expect(mockProcessManager.on).toHaveBeenCalledWith('session-id', expect.any(Function)); + }); + }); + + describe('Regular Process Session ID', () => { + it('should forward session ID to renderer', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('regular-session-123', 'agent-session-abc'); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:session-id', + 'regular-session-123', + 'agent-session-abc' + ); + }); + }); + + describe('Participant Session ID Storage', () => { + beforeEach(() => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ + groupChatId: 'test-chat-123', + participantName: 'TestAgent', + }); + }); + + it('should store agent session ID for participant', async () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent', + { agentSessionId: 'agent-session-xyz' } + ); + }); + }); + + it('should emit participants changed after storage', async () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalledWith( + 'test-chat-123', + expect.any(Array) + ); + }); + }); + + it('should use updateParticipant return value instead of loading chat again (DB caching)', async () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalled(); + }); + + // Verify we didn't make a redundant loadGroupChat call + // The code should use the return value from updateParticipant directly + expect(mockDeps.groupChatStorage.loadGroupChat).not.toHaveBeenCalled(); + }); + + it('should pass exact participants from updateParticipant return value', async () => { + const specificParticipants = [ + { name: 'Agent1', agentId: 'claude-code', sessionId: 'session-1', addedAt: 1000 }, + { name: 'Agent2', agentId: 'codex', sessionId: 'session-2', addedAt: 2000 }, + ]; + mockDeps.groupChatStorage.updateParticipant = vi.fn().mockResolvedValue({ + ...createMockGroupChat(), + participants: specificParticipants, + }); + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalledWith( + 'test-chat-123', + specificParticipants + ); + }); + }); + + it('should handle empty participants array from updateParticipant', async () => { + mockDeps.groupChatStorage.updateParticipant = vi.fn().mockResolvedValue({ + ...createMockGroupChat(), + participants: [], + }); + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalledWith( + 'test-chat-123', + [] + ); + }); + }); + + it('should handle undefined emitParticipantsChanged gracefully (optional chaining)', async () => { + mockDeps.groupChatEmitters.emitParticipantsChanged = undefined; + setupListener(); + const handler = eventHandlers.get('session-id'); + + // Should not throw + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalled(); + }); + // No error should be logged for the optional emitter + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + + it('should log error when storage fails', async () => { + mockDeps.groupChatStorage.updateParticipant = vi + .fn() + .mockRejectedValue(new Error('DB error')); + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.logger.error).toHaveBeenCalledWith( + '[GroupChat] Failed to update participant agentSessionId', + 'ProcessListener', + expect.objectContaining({ + error: 'Error: DB error', + participant: 'TestAgent', + }) + ); + }); + }); + + it('should still forward to renderer after storage', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', 'agent-session-xyz'); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:session-id', + 'group-chat-test-chat-123-participant-TestAgent-abc123', + 'agent-session-xyz' + ); + }); + }); + + describe('Moderator Session ID Storage', () => { + it('should store agent session ID for moderator', async () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-moderator-1234567890', 'moderator-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateGroupChat).toHaveBeenCalledWith('test-chat-123', { + moderatorAgentSessionId: 'moderator-session-xyz', + }); + }); + }); + + it('should emit moderator session ID changed after storage', async () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-moderator-1234567890', 'moderator-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitModeratorSessionIdChanged).toHaveBeenCalledWith( + 'test-chat-123', + 'moderator-session-xyz' + ); + }); + }); + + it('should log error when moderator storage fails', async () => { + mockDeps.groupChatStorage.updateGroupChat = vi.fn().mockRejectedValue(new Error('DB error')); + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-moderator-1234567890', 'moderator-session-xyz'); + + await vi.waitFor(() => { + expect(mockDeps.logger.error).toHaveBeenCalledWith( + '[GroupChat] Failed to update moderator agent session ID', + 'ProcessListener', + expect.objectContaining({ + error: 'Error: DB error', + groupChatId: 'test-chat-123', + }) + ); + }); + }); + + it('should still forward to renderer for moderator sessions', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('group-chat-test-chat-123-moderator-1234567890', 'moderator-session-xyz'); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:session-id', + 'group-chat-test-chat-123-moderator-1234567890', + 'moderator-session-xyz' + ); + }); + + it('should NOT store for synthesis moderator sessions (different pattern)', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + // Synthesis session ID doesn't match REGEX_MODERATOR_SESSION_TIMESTAMP + // because it has 'synthesis' in it: group-chat-xxx-moderator-synthesis-timestamp + handler?.('group-chat-test-chat-123-moderator-synthesis-1234567890', 'synthesis-session-xyz'); + + // Should NOT call updateGroupChat for synthesis sessions (doesn't match timestamp pattern) + expect(mockDeps.groupChatStorage.updateGroupChat).not.toHaveBeenCalled(); + }); + }); + + describe('Session ID Format Handling', () => { + it('should handle empty agent session ID', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('regular-session-123', ''); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:session-id', + 'regular-session-123', + '' + ); + }); + + it('should handle UUID format session IDs', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + handler?.('regular-session-123', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:session-id', + 'regular-session-123', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + }); + + it('should handle long session IDs', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + const longSessionId = 'a'.repeat(500); + + handler?.('regular-session-123', longSessionId); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:session-id', + 'regular-session-123', + longSessionId + ); + }); + }); + + describe('Performance Optimization', () => { + it('should skip participant parsing for non-group-chat sessions (prefix check)', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + // Regular session ID doesn't start with 'group-chat-' + handler?.('regular-session-123', 'agent-session-abc'); + + // parseParticipantSessionId should NOT be called for non-group-chat sessions + expect(mockDeps.outputParser.parseParticipantSessionId).not.toHaveBeenCalled(); + }); + + it('should only parse participant session ID for group-chat sessions', () => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue(null); + setupListener(); + const handler = eventHandlers.get('session-id'); + + // Group chat session ID starts with 'group-chat-' + handler?.('group-chat-test-123-participant-Agent-abc', 'agent-session-xyz'); + + // parseParticipantSessionId SHOULD be called for group-chat sessions + expect(mockDeps.outputParser.parseParticipantSessionId).toHaveBeenCalledWith( + 'group-chat-test-123-participant-Agent-abc' + ); + }); + + it('should skip moderator regex for non-group-chat sessions', () => { + setupListener(); + const handler = eventHandlers.get('session-id'); + + // Process many non-group-chat sessions - should be fast since regex is skipped + for (let i = 0; i < 100; i++) { + handler?.(`regular-session-${i}`, `agent-session-${i}`); + } + + // Neither storage method should be called for regular sessions + expect(mockDeps.groupChatStorage.updateParticipant).not.toHaveBeenCalled(); + expect(mockDeps.groupChatStorage.updateGroupChat).not.toHaveBeenCalled(); + // But all should still forward to renderer + expect(mockDeps.safeSend).toHaveBeenCalledTimes(100); + }); + }); +}); diff --git a/src/main/process-listeners/__tests__/usage-listener.test.ts b/src/main/process-listeners/__tests__/usage-listener.test.ts new file mode 100644 index 00000000..16c52d61 --- /dev/null +++ b/src/main/process-listeners/__tests__/usage-listener.test.ts @@ -0,0 +1,431 @@ +/** + * Tests for usage listener. + * Handles token/cost statistics from AI responses. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupUsageListener } from '../usage-listener'; +import type { ProcessManager } from '../../process-manager'; +import type { UsageStats } from '../types'; + +describe('Usage Listener', () => { + let mockProcessManager: ProcessManager; + let mockDeps: Parameters[1]; + let eventHandlers: Map void>; + + const createMockUsageStats = (overrides: Partial = {}): UsageStats => ({ + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + totalCostUsd: 0.05, + contextWindow: 100000, + ...overrides, + }); + + // Create a minimal mock group chat + const createMockGroupChat = () => ({ + id: 'test-chat-123', + name: 'Test Chat', + moderatorAgentId: 'claude-code', + moderatorSessionId: 'group-chat-test-chat-123-moderator', + participants: [ + { + name: 'TestAgent', + agentId: 'claude-code', + sessionId: 'group-chat-test-chat-123-participant-TestAgent-abc123', + addedAt: Date.now(), + }, + ], + createdAt: Date.now(), + updatedAt: Date.now(), + logPath: '/tmp/test-chat.log', + imagesDir: '/tmp/test-chat-images', + }); + + beforeEach(() => { + vi.clearAllMocks(); + eventHandlers = new Map(); + + mockProcessManager = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + } as unknown as ProcessManager; + + mockDeps = { + safeSend: vi.fn(), + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + groupChatEmitters: { + emitParticipantsChanged: vi.fn(), + emitModeratorUsage: vi.fn(), + }, + groupChatStorage: { + loadGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), + updateGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), + updateParticipant: vi.fn().mockResolvedValue(createMockGroupChat()), + }, + outputParser: { + extractTextFromStreamJson: vi.fn().mockReturnValue('parsed response'), + parseParticipantSessionId: vi.fn().mockReturnValue(null), + }, + usageAggregator: { + calculateContextTokens: vi.fn().mockReturnValue(1800), + }, + patterns: { + REGEX_MODERATOR_SESSION: /^group-chat-(.+)-moderator-/, + REGEX_MODERATOR_SESSION_TIMESTAMP: /^group-chat-(.+)-moderator-\d+$/, + REGEX_AI_SUFFIX: /-ai-[^-]+$/, + REGEX_AI_TAB_ID: /-ai-([^-]+)$/, + REGEX_BATCH_SESSION: /-batch-\d+$/, + REGEX_SYNOPSIS_SESSION: /-synopsis-\d+$/, + }, + }; + }); + + const setupListener = () => { + setupUsageListener(mockProcessManager, mockDeps); + }; + + describe('Event Registration', () => { + it('should register the usage event listener', () => { + setupListener(); + expect(mockProcessManager.on).toHaveBeenCalledWith('usage', expect.any(Function)); + }); + }); + + describe('Regular Process Usage', () => { + it('should forward usage stats to renderer', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('regular-session-123', usageStats); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:usage', + 'regular-session-123', + usageStats + ); + }); + }); + + describe('Participant Usage', () => { + beforeEach(() => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ + groupChatId: 'test-chat-123', + participantName: 'TestAgent', + }); + }); + + it('should update participant with usage stats', async () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent', + expect.objectContaining({ + contextUsage: expect.any(Number), + tokenCount: 1800, + totalCost: 0.05, + }) + ); + }); + }); + + it('should calculate context usage percentage correctly', async () => { + mockDeps.usageAggregator.calculateContextTokens = vi.fn().mockReturnValue(50000); // 50% of 100000 + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats({ contextWindow: 100000 }); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent', + expect.objectContaining({ + contextUsage: 50, + }) + ); + }); + }); + + it('should handle zero context window gracefully', async () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats({ contextWindow: 0 }); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalledWith( + 'test-chat-123', + 'TestAgent', + expect.objectContaining({ + contextUsage: 0, + }) + ); + }); + }); + + it('should emit participants changed after update', async () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalledWith( + 'test-chat-123', + expect.any(Array) + ); + }); + }); + + it('should use updateParticipant return value instead of loading chat again (DB caching)', async () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalled(); + }); + + // Verify we didn't make a redundant loadGroupChat call + // The code should use the return value from updateParticipant directly + expect(mockDeps.groupChatStorage.loadGroupChat).not.toHaveBeenCalled(); + }); + + it('should pass exact participants from updateParticipant return value', async () => { + const specificParticipants = [ + { name: 'Agent1', agentId: 'claude-code', sessionId: 'session-1', addedAt: 1000 }, + { name: 'Agent2', agentId: 'codex', sessionId: 'session-2', addedAt: 2000 }, + ]; + mockDeps.groupChatStorage.updateParticipant = vi.fn().mockResolvedValue({ + ...createMockGroupChat(), + participants: specificParticipants, + }); + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalledWith( + 'test-chat-123', + specificParticipants + ); + }); + }); + + it('should handle empty participants array from updateParticipant', async () => { + mockDeps.groupChatStorage.updateParticipant = vi.fn().mockResolvedValue({ + ...createMockGroupChat(), + participants: [], + }); + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatEmitters.emitParticipantsChanged).toHaveBeenCalledWith( + 'test-chat-123', + [] + ); + }); + }); + + it('should handle undefined emitParticipantsChanged gracefully (optional chaining)', async () => { + mockDeps.groupChatEmitters.emitParticipantsChanged = undefined; + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + // Should not throw + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalled(); + }); + // No error should be logged for the optional emitter + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + + it('should log error when participant update fails', async () => { + mockDeps.groupChatStorage.updateParticipant = vi + .fn() + .mockRejectedValue(new Error('DB error')); + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + await vi.waitFor(() => { + expect(mockDeps.logger.error).toHaveBeenCalledWith( + '[GroupChat] Failed to update participant usage', + 'ProcessListener', + expect.objectContaining({ + error: 'Error: DB error', + participant: 'TestAgent', + }) + ); + }); + }); + + it('should still forward to renderer for participant usage', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:usage', + 'group-chat-test-chat-123-participant-TestAgent-abc123', + usageStats + ); + }); + }); + + describe('Moderator Usage', () => { + it('should emit moderator usage for moderator sessions', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-moderator-1234567890', usageStats); + + expect(mockDeps.groupChatEmitters.emitModeratorUsage).toHaveBeenCalledWith( + 'test-chat-123', + expect.objectContaining({ + contextUsage: expect.any(Number), + totalCost: 0.05, + tokenCount: 1800, + }) + ); + }); + + it('should calculate moderator context usage correctly', () => { + mockDeps.usageAggregator.calculateContextTokens = vi.fn().mockReturnValue(25000); // 25% of 100000 + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats({ contextWindow: 100000 }); + + handler?.('group-chat-test-chat-123-moderator-1234567890', usageStats); + + expect(mockDeps.groupChatEmitters.emitModeratorUsage).toHaveBeenCalledWith( + 'test-chat-123', + expect.objectContaining({ + contextUsage: 25, + }) + ); + }); + + it('should still forward to renderer for moderator usage', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-moderator-1234567890', usageStats); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:usage', + 'group-chat-test-chat-123-moderator-1234567890', + usageStats + ); + }); + + it('should handle synthesis moderator sessions', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + handler?.('group-chat-test-chat-123-moderator-synthesis-1234567890', usageStats); + + expect(mockDeps.groupChatEmitters.emitModeratorUsage).toHaveBeenCalledWith( + 'test-chat-123', + expect.any(Object) + ); + }); + }); + + describe('Usage with Reasoning Tokens', () => { + it('should handle usage stats with reasoning tokens', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats({ reasoningTokens: 1000 }); + + handler?.('regular-session-123', usageStats); + + expect(mockDeps.safeSend).toHaveBeenCalledWith( + 'process:usage', + 'regular-session-123', + expect.objectContaining({ reasoningTokens: 1000 }) + ); + }); + }); + + describe('Performance Optimization', () => { + it('should skip participant parsing for non-group-chat sessions (prefix check)', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + // Regular session ID doesn't start with 'group-chat-' + handler?.('regular-session-123', usageStats); + + // parseParticipantSessionId should NOT be called for non-group-chat sessions + expect(mockDeps.outputParser.parseParticipantSessionId).not.toHaveBeenCalled(); + }); + + it('should only parse participant session ID for group-chat sessions', () => { + mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue(null); + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + // Group chat session ID starts with 'group-chat-' + handler?.('group-chat-test-123-participant-Agent-abc', usageStats); + + // parseParticipantSessionId SHOULD be called for group-chat sessions + expect(mockDeps.outputParser.parseParticipantSessionId).toHaveBeenCalledWith( + 'group-chat-test-123-participant-Agent-abc' + ); + }); + + it('should skip moderator regex for non-group-chat sessions', () => { + setupListener(); + const handler = eventHandlers.get('usage'); + const usageStats = createMockUsageStats(); + + // Process many non-group-chat sessions - should be fast since regex is skipped + for (let i = 0; i < 100; i++) { + handler?.(`regular-session-${i}`, usageStats); + } + + // Moderator usage should NOT be emitted for any regular sessions + expect(mockDeps.groupChatEmitters.emitModeratorUsage).not.toHaveBeenCalled(); + // But all should still forward to renderer + expect(mockDeps.safeSend).toHaveBeenCalledTimes(100); + }); + }); +}); diff --git a/src/main/process-listeners/data-listener.ts b/src/main/process-listeners/data-listener.ts index 71b2570d..642f73c6 100644 --- a/src/main/process-listeners/data-listener.ts +++ b/src/main/process-listeners/data-listener.ts @@ -6,6 +6,24 @@ import type { ProcessManager } from '../process-manager'; import type { ProcessListenerDependencies } from './types'; +/** + * Maximum buffer size per session (10MB). + * Prevents unbounded memory growth from long-running or misbehaving processes. + */ +const MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +/** + * Prefix for group chat session IDs. + * Used for fast string check before expensive regex matching. + */ +const GROUP_CHAT_PREFIX = 'group-chat-'; + +/** + * Length of random suffix in message IDs (9 characters of base36). + * Combined with timestamp provides uniqueness for web broadcast deduplication. + */ +const MSG_ID_RANDOM_LENGTH = 9; + /** * Sets up the data listener for process output. * Handles: @@ -21,12 +39,22 @@ export function setupDataListener( > ): void { const { safeSend, getWebServer, outputBuffer, outputParser, debugLog, patterns } = deps; - const { REGEX_MODERATOR_SESSION, REGEX_AI_SUFFIX, REGEX_AI_TAB_ID } = patterns; + const { + REGEX_MODERATOR_SESSION, + REGEX_AI_SUFFIX, + REGEX_AI_TAB_ID, + REGEX_BATCH_SESSION, + REGEX_SYNOPSIS_SESSION, + } = patterns; processManager.on('data', (sessionId: string, data: string) => { + // Fast path: skip regex for non-group-chat sessions (performance optimization) + // Most sessions don't start with 'group-chat-', so this avoids expensive regex matching + const isGroupChatSession = sessionId.startsWith(GROUP_CHAT_PREFIX); + // Handle group chat moderator output - buffer it // Session ID format: group-chat-{groupChatId}-moderator-{uuid} or group-chat-{groupChatId}-moderator-synthesis-{uuid} - const moderatorMatch = sessionId.match(REGEX_MODERATOR_SESSION); + const moderatorMatch = isGroupChatSession ? sessionId.match(REGEX_MODERATOR_SESSION) : null; if (moderatorMatch) { const groupChatId = moderatorMatch[1]; debugLog('GroupChat:Debug', `MODERATOR DATA received for chat ${groupChatId}`); @@ -35,12 +63,22 @@ export function setupDataListener( // Buffer the output - will be routed on process exit const totalLength = outputBuffer.appendToGroupChatBuffer(sessionId, data); debugLog('GroupChat:Debug', `Buffered total: ${totalLength} chars`); + // Warn if buffer is growing too large (potential memory leak) + if (totalLength > MAX_BUFFER_SIZE) { + debugLog( + 'GroupChat:Debug', + `WARNING: Buffer size ${totalLength} exceeds ${MAX_BUFFER_SIZE} bytes for moderator session ${sessionId}` + ); + } return; // Don't send to regular process:data handler } // Handle group chat participant output - buffer it // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} - const participantInfo = outputParser.parseParticipantSessionId(sessionId); + // Only parse if it's a group chat session (performance optimization) + const participantInfo = isGroupChatSession + ? outputParser.parseParticipantSessionId(sessionId) + : null; if (participantInfo) { debugLog('GroupChat:Debug', 'PARTICIPANT DATA received'); debugLog( @@ -52,6 +90,13 @@ export function setupDataListener( // Buffer the output - will be routed on process exit const totalLength = outputBuffer.appendToGroupChatBuffer(sessionId, data); debugLog('GroupChat:Debug', `Buffered total: ${totalLength} chars`); + // Warn if buffer is growing too large (potential memory leak) + if (totalLength > MAX_BUFFER_SIZE) { + debugLog( + 'GroupChat:Debug', + `WARNING: Buffer size ${totalLength} exceeds ${MAX_BUFFER_SIZE} bytes for participant ${participantInfo.participantName}` + ); + } return; // Don't send to regular process:data handler } @@ -70,7 +115,8 @@ export function setupDataListener( // Don't broadcast background batch/synopsis output to web clients // These are internal Auto Run operations that should only appear in history, not as chat messages - if (sessionId.includes('-batch-') || sessionId.includes('-synopsis-')) { + // Use proper regex patterns to avoid false positives from UUIDs containing "batch" or "synopsis" + if (REGEX_BATCH_SESSION.test(sessionId) || REGEX_SYNOPSIS_SESSION.test(sessionId)) { debugLog('WebBroadcast', `SKIPPING batch/synopsis output for web: session=${sessionId}`); return; } @@ -83,7 +129,10 @@ export function setupDataListener( const tabIdMatch = sessionId.match(REGEX_AI_TAB_ID); const tabId = tabIdMatch ? tabIdMatch[1] : undefined; - const msgId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // Generate unique message ID: timestamp + random suffix for deduplication + const msgId = `${Date.now()}-${Math.random() + .toString(36) + .substring(2, 2 + MSG_ID_RANDOM_LENGTH)}`; debugLog( 'WebBroadcast', `Broadcasting session_output: msgId=${msgId}, session=${baseSessionId}, tabId=${tabId || 'none'}, source=${isAiOutput ? 'ai' : 'terminal'}, dataLen=${data.length}` diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts index 2f20b54f..8c0ea4c1 100644 --- a/src/main/process-listeners/exit-listener.ts +++ b/src/main/process-listeners/exit-listener.ts @@ -7,6 +7,12 @@ import type { ProcessManager } from '../process-manager'; import type { ProcessListenerDependencies } from './types'; +/** + * Prefix for group chat session IDs. + * Used for fast string check before expensive regex matching. + */ +const GROUP_CHAT_PREFIX = 'group-chat-'; + /** * Sets up the exit listener for process termination. * Handles: @@ -59,13 +65,17 @@ export function setupExitListener( // This allows system sleep when no AI sessions are active powerManager.removeBlockReason(`session:${sessionId}`); + // Fast path: skip regex for non-group-chat sessions (performance optimization) + // Most sessions don't start with 'group-chat-', so this avoids expensive regex matching + const isGroupChatSession = sessionId.startsWith(GROUP_CHAT_PREFIX); + // Handle group chat moderator exit - route buffered output and set state back to idle // Session ID format: group-chat-{groupChatId}-moderator-{uuid} // This handles BOTH initial moderator responses AND synthesis responses. // The routeModeratorResponse function will check for @mentions: // - If @mentions present: route to agents (continue conversation) // - If no @mentions: final response to user (conversation complete for this turn) - const moderatorMatch = sessionId.match(REGEX_MODERATOR_SESSION); + const moderatorMatch = isGroupChatSession ? sessionId.match(REGEX_MODERATOR_SESSION) : null; if (moderatorMatch) { const groupChatId = moderatorMatch[1]; debugLog('GroupChat:Debug', ` ========== MODERATOR PROCESS EXIT ==========`); @@ -189,7 +199,10 @@ export function setupExitListener( // Handle group chat participant exit - route buffered output and update participant state // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} - const participantExitInfo = outputParser.parseParticipantSessionId(sessionId); + // Only parse if it's a group chat session (performance optimization) + const participantExitInfo = isGroupChatSession + ? outputParser.parseParticipantSessionId(sessionId) + : null; if (participantExitInfo) { const { groupChatId, participantName } = participantExitInfo; debugLog('GroupChat:Debug', ` ========== PARTICIPANT PROCESS EXIT ==========`); @@ -342,8 +355,12 @@ export function setupExitListener( 'GroupChat:Debug', ` Successfully routed agent response from ${participantName}` ); + // Mark participant AFTER routing completes successfully + markAndMaybeSynthesize(); } else { debugLog('GroupChat:Debug', ` WARNING: Parsed text is empty for ${participantName}!`); + // No response to route, mark participant as done + markAndMaybeSynthesize(); } } catch (err) { debugLog('GroupChat:Debug', ` ERROR loading chat for participant:`, err); @@ -362,6 +379,11 @@ export function setupExitListener( parsedText, pm ?? undefined ); + // Mark participant AFTER routing completes successfully + markAndMaybeSynthesize(); + } else { + // No response to route, mark participant as done + markAndMaybeSynthesize(); } } catch (routeErr) { debugLog('GroupChat:Debug', ` ERROR routing agent response (fallback):`, routeErr); @@ -369,13 +391,16 @@ export function setupExitListener( error: String(routeErr), participant: participantName, }); + // Mark participant as done even after error (can't retry) + markAndMaybeSynthesize(); } } })().finally(() => { outputBuffer.clearGroupChatBuffer(sessionId); debugLog('GroupChat:Debug', ` Cleared output buffer for participant session`); - // Mark participant and trigger synthesis AFTER logging is complete - markAndMaybeSynthesize(); + // Note: markAndMaybeSynthesize() is called explicitly in each code path above + // to ensure proper sequencing - NOT in finally() which would cause race conditions + // with session recovery (where we DON'T want to mark until recovery completes) }); } else { debugLog( diff --git a/src/main/process-listeners/forwarding-listeners.ts b/src/main/process-listeners/forwarding-listeners.ts index 3ec6bb4c..90121b8b 100644 --- a/src/main/process-listeners/forwarding-listeners.ts +++ b/src/main/process-listeners/forwarding-listeners.ts @@ -4,7 +4,7 @@ */ import type { ProcessManager } from '../process-manager'; -import type { ProcessListenerDependencies } from './types'; +import type { ProcessListenerDependencies, ToolExecution } from './types'; /** * Sets up simple forwarding listeners that pass events directly to renderer. @@ -29,12 +29,9 @@ export function setupForwardingListeners( }); // Handle tool execution events (OpenCode, Codex) - processManager.on( - 'tool-execution', - (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => { - safeSend('process:tool-execution', sessionId, toolEvent); - } - ); + processManager.on('tool-execution', (sessionId: string, toolEvent: ToolExecution) => { + safeSend('process:tool-execution', sessionId, toolEvent); + }); // Handle stderr separately from runCommand (for clean command execution) processManager.on('stderr', (sessionId: string, data: string) => { diff --git a/src/main/process-listeners/session-id-listener.ts b/src/main/process-listeners/session-id-listener.ts index 7d70e394..67245e46 100644 --- a/src/main/process-listeners/session-id-listener.ts +++ b/src/main/process-listeners/session-id-listener.ts @@ -6,6 +6,12 @@ import type { ProcessManager } from '../process-manager'; import type { ProcessListenerDependencies } from './types'; +/** + * Prefix for group chat session IDs. + * Used for fast string check before expensive regex matching. + */ +const GROUP_CHAT_PREFIX = 'group-chat-'; + /** * Sets up the session-id listener. * Handles: @@ -24,20 +30,23 @@ export function setupSessionIdListener( const { REGEX_MODERATOR_SESSION_TIMESTAMP } = patterns; processManager.on('session-id', (sessionId: string, agentSessionId: string) => { + // Fast path: skip regex for non-group-chat sessions (performance optimization) + const isGroupChatSession = sessionId.startsWith(GROUP_CHAT_PREFIX); + // Handle group chat participant session ID - store the agent's session ID // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} - const participantSessionInfo = outputParser.parseParticipantSessionId(sessionId); + const participantSessionInfo = isGroupChatSession + ? outputParser.parseParticipantSessionId(sessionId) + : null; if (participantSessionInfo) { const { groupChatId, participantName } = participantSessionInfo; // Update the participant with the agent's session ID groupChatStorage .updateParticipant(groupChatId, participantName, { agentSessionId }) - .then(async () => { + .then((updatedChat) => { // Emit participants changed so UI updates with the new session ID - const chat = await groupChatStorage.loadGroupChat(groupChatId); - if (chat) { - groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants); - } + // Note: updateParticipant returns the updated chat, avoiding extra DB read + groupChatEmitters.emitParticipantsChanged?.(groupChatId, updatedChat.participants); }) .catch((err) => { logger.error( @@ -51,7 +60,9 @@ export function setupSessionIdListener( // Handle group chat moderator session ID - store the real agent session ID // Session ID format: group-chat-{groupChatId}-moderator-{timestamp} - const moderatorMatch = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + const moderatorMatch = isGroupChatSession + ? sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP) + : null; if (moderatorMatch) { const groupChatId = moderatorMatch[1]; // Update the group chat with the moderator's real agent session ID diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts index b230d739..dd0b7256 100644 --- a/src/main/process-listeners/types.ts +++ b/src/main/process-listeners/types.ts @@ -139,6 +139,10 @@ export interface ProcessListenerDependencies { REGEX_MODERATOR_SESSION_TIMESTAMP: RegExp; REGEX_AI_SUFFIX: RegExp; REGEX_AI_TAB_ID: RegExp; + /** Matches batch session IDs: {id}-batch-{timestamp} */ + REGEX_BATCH_SESSION: RegExp; + /** Matches synopsis session IDs: {id}-synopsis-{timestamp} */ + REGEX_SYNOPSIS_SESSION: RegExp; }; /** Logger instance */ logger: { diff --git a/src/main/process-listeners/usage-listener.ts b/src/main/process-listeners/usage-listener.ts index 1330bd42..9fe32fec 100644 --- a/src/main/process-listeners/usage-listener.ts +++ b/src/main/process-listeners/usage-listener.ts @@ -4,7 +4,13 @@ */ import type { ProcessManager } from '../process-manager'; -import type { ProcessListenerDependencies } from './types'; +import type { ProcessListenerDependencies, UsageStats } from './types'; + +/** + * Prefix for group chat session IDs. + * Used for fast string check before expensive regex matching. + */ +const GROUP_CHAT_PREFIX = 'group-chat-'; /** * Sets up the usage listener for token/cost statistics. @@ -38,77 +44,68 @@ export function setupUsageListener( const { REGEX_MODERATOR_SESSION } = patterns; // Handle usage statistics from AI responses - processManager.on( - 'usage', - ( - sessionId: string, - usageStats: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini) - } - ) => { - // Handle group chat participant usage - update participant stats - const participantUsageInfo = outputParser.parseParticipantSessionId(sessionId); - if (participantUsageInfo) { - const { groupChatId, participantName } = participantUsageInfo; + processManager.on('usage', (sessionId: string, usageStats: UsageStats) => { + // Fast path: skip regex for non-group-chat sessions (performance optimization) + const isGroupChatSession = sessionId.startsWith(GROUP_CHAT_PREFIX); - // Calculate context usage percentage using agent-specific logic - // Note: For group chat, we don't have agent type here, defaults to Claude behavior - const totalContextTokens = usageAggregator.calculateContextTokens(usageStats); - const contextUsage = - usageStats.contextWindow > 0 - ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) - : 0; + // Handle group chat participant usage - update participant stats + const participantUsageInfo = isGroupChatSession + ? outputParser.parseParticipantSessionId(sessionId) + : null; + if (participantUsageInfo) { + const { groupChatId, participantName } = participantUsageInfo; - // Update participant with usage stats - groupChatStorage - .updateParticipant(groupChatId, participantName, { - contextUsage, - tokenCount: totalContextTokens, - totalCost: usageStats.totalCostUsd, - }) - .then(async () => { - // Emit participants changed so UI updates - const chat = await groupChatStorage.loadGroupChat(groupChatId); - if (chat) { - groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants); - } - }) - .catch((err) => { - logger.error('[GroupChat] Failed to update participant usage', 'ProcessListener', { - error: String(err), - participant: participantName, - }); - }); - // Still send to renderer for consistency - } + // Calculate context usage percentage using agent-specific logic + // Note: For group chat, we don't have agent type here, defaults to Claude behavior + const totalContextTokens = usageAggregator.calculateContextTokens(usageStats); + const contextUsage = + usageStats.contextWindow > 0 + ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) + : 0; - // Handle group chat moderator usage - emit for UI - const moderatorUsageMatch = sessionId.match(REGEX_MODERATOR_SESSION); - if (moderatorUsageMatch) { - const groupChatId = moderatorUsageMatch[1]; - // Calculate context usage percentage using agent-specific logic - // Note: Moderator is typically Claude, defaults to Claude behavior - const totalContextTokens = usageAggregator.calculateContextTokens(usageStats); - const contextUsage = - usageStats.contextWindow > 0 - ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) - : 0; - - // Emit moderator usage for the moderator card - groupChatEmitters.emitModeratorUsage?.(groupChatId, { + // Update participant with usage stats + groupChatStorage + .updateParticipant(groupChatId, participantName, { contextUsage, - totalCost: usageStats.totalCostUsd, tokenCount: totalContextTokens, + totalCost: usageStats.totalCostUsd, + }) + .then((updatedChat) => { + // Emit participants changed so UI updates + // Note: updateParticipant returns the updated chat, avoiding extra DB read + groupChatEmitters.emitParticipantsChanged?.(groupChatId, updatedChat.participants); + }) + .catch((err) => { + logger.error('[GroupChat] Failed to update participant usage', 'ProcessListener', { + error: String(err), + participant: participantName, + }); }); - } - - safeSend('process:usage', sessionId, usageStats); + // Still send to renderer for consistency } - ); + + // Handle group chat moderator usage - emit for UI + const moderatorUsageMatch = isGroupChatSession + ? sessionId.match(REGEX_MODERATOR_SESSION) + : null; + if (moderatorUsageMatch) { + const groupChatId = moderatorUsageMatch[1]; + // Calculate context usage percentage using agent-specific logic + // Note: Moderator is typically Claude, defaults to Claude behavior + const totalContextTokens = usageAggregator.calculateContextTokens(usageStats); + const contextUsage = + usageStats.contextWindow > 0 + ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) + : 0; + + // Emit moderator usage for the moderator card + groupChatEmitters.emitModeratorUsage?.(groupChatId, { + contextUsage, + totalCost: usageStats.totalCostUsd, + tokenCount: totalContextTokens, + }); + } + + safeSend('process:usage', sessionId, usageStats); + }); }