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
This commit is contained in:
Raza Rauf
2026-01-27 00:21:47 +05:00
committed by Pedram Amini
parent 9ddc95ae7a
commit 944a72cf5a
12 changed files with 1759 additions and 90 deletions

View File

@@ -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
// ============================================================================

View File

@@ -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,
});

View File

@@ -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<typeof vi.fn> };
let mockOutputBuffer: ProcessListenerDependencies['outputBuffer'];
let mockOutputParser: ProcessListenerDependencies['outputParser'];
let mockDebugLog: ProcessListenerDependencies['debugLog'];
let mockPatterns: ProcessListenerDependencies['patterns'];
let eventHandlers: Map<string, (...args: unknown[]) => 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' })
);
});
});
});

View File

@@ -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<typeof setupExitListener>[1];
let eventHandlers: Map<string, (...args: unknown[]) => 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<ProcessListenerDependencies['getAgentDetector']>,
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'
);
});
});
});
});

View File

@@ -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<typeof setupSessionIdListener>[1];
let eventHandlers: Map<string, (...args: unknown[]) => 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);
});
});
});

View File

@@ -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<typeof setupUsageListener>[1];
let eventHandlers: Map<string, (...args: unknown[]) => void>;
const createMockUsageStats = (overrides: Partial<UsageStats> = {}): 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);
});
});
});

View File

@@ -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}`

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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: {

View File

@@ -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);
});
}