mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
324
src/main/process-listeners/__tests__/data-listener.test.ts
Normal file
324
src/main/process-listeners/__tests__/data-listener.test.ts
Normal 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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
420
src/main/process-listeners/__tests__/exit-listener.test.ts
Normal file
420
src/main/process-listeners/__tests__/exit-listener.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
402
src/main/process-listeners/__tests__/session-id-listener.test.ts
Normal file
402
src/main/process-listeners/__tests__/session-id-listener.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
431
src/main/process-listeners/__tests__/usage-listener.test.ts
Normal file
431
src/main/process-listeners/__tests__/usage-listener.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user