refactor: extract agent error handlers and centralize GROUP_CHAT_PREFIX

- Extract agent:clearError and agent:retryAfterError handlers from
  main/index.ts to dedicated handlers/agent-error.ts module
- Add comprehensive test coverage for agent error handlers (29 tests)
- Centralize GROUP_CHAT_PREFIX constant in process-listeners/types.ts
  to eliminate duplication across 4 listener files
- Remove unused ipcMain import from main/index.ts (all IPC handlers
  now registered through handlers module)
This commit is contained in:
Raza Rauf
2026-01-27 00:48:22 +05:00
committed by Pedram Amini
parent 944a72cf5a
commit 90f03fe256
9 changed files with 444 additions and 66 deletions

View File

@@ -0,0 +1,350 @@
/**
* Tests for agent error IPC handlers
*
* Tests the agent:clearError and agent:retryAfterError handlers
* which manage error state transitions and recovery operations.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ipcMain } from 'electron';
// Create hoisted mocks for more reliable mocking
const mocks = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock electron
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
},
}));
// Mock logger
vi.mock('../../../../main/utils/logger', () => ({
logger: mocks.mockLogger,
}));
// Alias for easier access in tests
const mockLogger = mocks.mockLogger;
import { registerAgentErrorHandlers } from '../../../../main/ipc/handlers/agent-error';
describe('Agent Error IPC Handlers', () => {
let handlers: Map<string, Function>;
beforeEach(() => {
vi.clearAllMocks();
handlers = new Map();
// Capture registered handlers
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => {
handlers.set(channel, handler);
});
registerAgentErrorHandlers();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('handler registration', () => {
it('should register agent:clearError handler', () => {
expect(handlers.has('agent:clearError')).toBe(true);
});
it('should register agent:retryAfterError handler', () => {
expect(handlers.has('agent:retryAfterError')).toBe(true);
});
it('should register exactly 2 handlers', () => {
expect(handlers.size).toBe(2);
});
});
describe('agent:clearError', () => {
it('should return success for valid session ID', async () => {
const handler = handlers.get('agent:clearError')!;
const result = await handler({}, 'session-123');
expect(result).toEqual({ success: true });
});
it('should log debug message with session ID', async () => {
const handler = handlers.get('agent:clearError')!;
await handler({}, 'test-session-abc');
expect(mockLogger.debug).toHaveBeenCalledWith(
'Clearing agent error for session',
'AgentError',
{ sessionId: 'test-session-abc' }
);
});
it('should handle empty session ID', async () => {
const handler = handlers.get('agent:clearError')!;
const result = await handler({}, '');
expect(result).toEqual({ success: true });
expect(mockLogger.debug).toHaveBeenCalledWith(
'Clearing agent error for session',
'AgentError',
{ sessionId: '' }
);
});
it('should handle UUID format session ID', async () => {
const handler = handlers.get('agent:clearError')!;
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const result = await handler({}, uuid);
expect(result).toEqual({ success: true });
expect(mockLogger.debug).toHaveBeenCalledWith(
'Clearing agent error for session',
'AgentError',
{ sessionId: uuid }
);
});
it('should handle long session ID', async () => {
const handler = handlers.get('agent:clearError')!;
const longSessionId = 'session-' + 'a'.repeat(500);
const result = await handler({}, longSessionId);
expect(result).toEqual({ success: true });
});
it('should handle special characters in session ID', async () => {
const handler = handlers.get('agent:clearError')!;
const specialId = 'session_with-special.chars:123';
const result = await handler({}, specialId);
expect(result).toEqual({ success: true });
});
it('should handle group chat session ID format', async () => {
const handler = handlers.get('agent:clearError')!;
const groupChatId = 'group-chat-test-123-participant-Agent-abc';
const result = await handler({}, groupChatId);
expect(result).toEqual({ success: true });
expect(mockLogger.debug).toHaveBeenCalledWith(
'Clearing agent error for session',
'AgentError',
{ sessionId: groupChatId }
);
});
});
describe('agent:retryAfterError', () => {
it('should return success without options', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123');
expect(result).toEqual({ success: true });
});
it('should return success with empty options', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123', {});
expect(result).toEqual({ success: true });
});
it('should return success with prompt option', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123', { prompt: 'Retry with this prompt' });
expect(result).toEqual({ success: true });
});
it('should return success with newSession option', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123', { newSession: true });
expect(result).toEqual({ success: true });
});
it('should return success with both options', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123', {
prompt: 'Retry prompt',
newSession: true,
});
expect(result).toEqual({ success: true });
});
it('should log info with session ID and no options', async () => {
const handler = handlers.get('agent:retryAfterError')!;
await handler({}, 'test-session-xyz');
expect(mockLogger.info).toHaveBeenCalledWith('Retrying after agent error', 'AgentError', {
sessionId: 'test-session-xyz',
hasPrompt: false,
newSession: false,
});
});
it('should log info with hasPrompt=true when prompt provided', async () => {
const handler = handlers.get('agent:retryAfterError')!;
await handler({}, 'session-abc', { prompt: 'Some prompt text' });
expect(mockLogger.info).toHaveBeenCalledWith('Retrying after agent error', 'AgentError', {
sessionId: 'session-abc',
hasPrompt: true,
newSession: false,
});
});
it('should log info with newSession=true when specified', async () => {
const handler = handlers.get('agent:retryAfterError')!;
await handler({}, 'session-def', { newSession: true });
expect(mockLogger.info).toHaveBeenCalledWith('Retrying after agent error', 'AgentError', {
sessionId: 'session-def',
hasPrompt: false,
newSession: true,
});
});
it('should handle empty prompt string as no prompt', async () => {
const handler = handlers.get('agent:retryAfterError')!;
await handler({}, 'session-123', { prompt: '' });
expect(mockLogger.info).toHaveBeenCalledWith('Retrying after agent error', 'AgentError', {
sessionId: 'session-123',
hasPrompt: false,
newSession: false,
});
});
it('should handle undefined newSession as false', async () => {
const handler = handlers.get('agent:retryAfterError')!;
await handler({}, 'session-123', { prompt: 'test' });
expect(mockLogger.info).toHaveBeenCalledWith(
'Retrying after agent error',
'AgentError',
expect.objectContaining({ newSession: false })
);
});
it('should handle newSession=false explicitly', async () => {
const handler = handlers.get('agent:retryAfterError')!;
await handler({}, 'session-123', { newSession: false });
expect(mockLogger.info).toHaveBeenCalledWith(
'Retrying after agent error',
'AgentError',
expect.objectContaining({ newSession: false })
);
});
it('should handle very long prompt', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const longPrompt = 'A'.repeat(10000);
const result = await handler({}, 'session-123', { prompt: longPrompt });
expect(result).toEqual({ success: true });
expect(mockLogger.info).toHaveBeenCalledWith(
'Retrying after agent error',
'AgentError',
expect.objectContaining({ hasPrompt: true })
);
});
it('should handle unicode in prompt', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123', { prompt: '请重试这个操作 🔄' });
expect(result).toEqual({ success: true });
expect(mockLogger.info).toHaveBeenCalledWith(
'Retrying after agent error',
'AgentError',
expect.objectContaining({ hasPrompt: true })
);
});
});
describe('handler idempotency', () => {
it('should handle multiple clearError calls for same session', async () => {
const handler = handlers.get('agent:clearError')!;
const result1 = await handler({}, 'session-123');
const result2 = await handler({}, 'session-123');
const result3 = await handler({}, 'session-123');
expect(result1).toEqual({ success: true });
expect(result2).toEqual({ success: true });
expect(result3).toEqual({ success: true });
expect(mockLogger.debug).toHaveBeenCalledTimes(3);
});
it('should handle multiple retryAfterError calls for same session', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result1 = await handler({}, 'session-123', { prompt: 'First retry' });
const result2 = await handler({}, 'session-123', { prompt: 'Second retry' });
expect(result1).toEqual({ success: true });
expect(result2).toEqual({ success: true });
expect(mockLogger.info).toHaveBeenCalledTimes(2);
});
});
describe('concurrent handler calls', () => {
it('should handle concurrent clearError calls for different sessions', async () => {
const handler = handlers.get('agent:clearError')!;
const results = await Promise.all([
handler({}, 'session-1'),
handler({}, 'session-2'),
handler({}, 'session-3'),
]);
expect(results).toEqual([{ success: true }, { success: true }, { success: true }]);
});
it('should handle concurrent retryAfterError calls', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const results = await Promise.all([
handler({}, 'session-1', { prompt: 'Prompt 1' }),
handler({}, 'session-2', { newSession: true }),
handler({}, 'session-3', { prompt: 'Prompt 3', newSession: false }),
]);
expect(results).toEqual([{ success: true }, { success: true }, { success: true }]);
});
});
describe('edge cases', () => {
it('should handle null-ish session ID', async () => {
const handler = handlers.get('agent:clearError')!;
// TypeScript would normally prevent this, but testing runtime behavior
const result = await handler({}, null as unknown as string);
expect(result).toEqual({ success: true });
});
it('should handle undefined options in retryAfterError', async () => {
const handler = handlers.get('agent:retryAfterError')!;
const result = await handler({}, 'session-123', undefined);
expect(result).toEqual({ success: true });
expect(mockLogger.info).toHaveBeenCalledWith('Retrying after agent error', 'AgentError', {
sessionId: 'session-123',
hasPrompt: false,
newSession: false,
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import { app, BrowserWindow } from 'electron';
import path from 'path';
import crypto from 'crypto';
// Sentry is imported dynamically below to avoid module-load-time access to electron.app
@@ -571,43 +571,7 @@ function setupIpcHandlers() {
// Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts
// ==========================================================================
// Agent Error Handling API
// ==========================================================================
// Clear an error state for a session (called after recovery action)
ipcMain.handle('agent:clearError', async (_event, sessionId: string) => {
logger.debug('Clearing agent error for session', 'AgentError', { sessionId });
// Note: The actual error state is managed in the renderer.
// This handler is used to log the clear action and potentially
// perform any main process cleanup needed.
return { success: true };
});
// Retry the last operation after an error (optionally with modified parameters)
ipcMain.handle(
'agent:retryAfterError',
async (
_event,
sessionId: string,
options?: {
prompt?: string;
newSession?: boolean;
}
) => {
logger.info('Retrying after agent error', 'AgentError', {
sessionId,
hasPrompt: !!options?.prompt,
newSession: options?.newSession || false,
});
// Note: The actual retry logic is handled in the renderer, which will:
// 1. Clear the error state
// 2. Optionally start a new session
// 3. Re-send the last command or the provided prompt
// This handler exists for logging and potential future main process coordination.
return { success: true };
}
);
// Agent Error Handling API - extracted to src/main/ipc/handlers/agent-error.ts
// Register notification handlers (extracted to handlers/notifications.ts)
registerNotificationsHandlers();

View File

@@ -0,0 +1,73 @@
/**
* Agent Error Handling IPC Handlers
*
* Handles agent error state management:
* - Clearing error states after recovery
* - Retrying operations after errors
*
* Note: The actual error state is managed in the renderer. These handlers
* provide logging and potential future main process coordination.
*/
import { ipcMain } from 'electron';
import { logger } from '../../utils/logger';
// ==========================================================================
// Types
// ==========================================================================
/**
* Options for retrying after an error
*/
export interface RetryOptions {
/** Optional prompt to use for the retry */
prompt?: string;
/** Whether to start a new session for the retry */
newSession?: boolean;
}
/**
* Response from agent error operations
*/
export interface AgentErrorResponse {
success: boolean;
}
// ==========================================================================
// Handler Registration
// ==========================================================================
/**
* Register all agent error-related IPC handlers
*/
export function registerAgentErrorHandlers(): void {
// Clear an error state for a session (called after recovery action)
ipcMain.handle(
'agent:clearError',
async (_event, sessionId: string): Promise<AgentErrorResponse> => {
logger.debug('Clearing agent error for session', 'AgentError', { sessionId });
// Note: The actual error state is managed in the renderer.
// This handler is used to log the clear action and potentially
// perform any main process cleanup needed.
return { success: true };
}
);
// Retry the last operation after an error (optionally with modified parameters)
ipcMain.handle(
'agent:retryAfterError',
async (_event, sessionId: string, options?: RetryOptions): Promise<AgentErrorResponse> => {
logger.info('Retrying after agent error', 'AgentError', {
sessionId,
hasPrompt: !!options?.prompt,
newSession: options?.newSession || false,
});
// Note: The actual retry logic is handled in the renderer, which will:
// 1. Clear the error state
// 2. Optionally start a new session
// 3. Re-send the last command or the provided prompt
// This handler exists for logging and potential future main process coordination.
return { success: true };
}
);
}

View File

@@ -49,6 +49,7 @@ import { registerWebHandlers, WebHandlerDependencies } from './web';
import { registerLeaderboardHandlers, LeaderboardHandlerDependencies } from './leaderboard';
import { registerNotificationsHandlers } from './notifications';
import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphony';
import { registerAgentErrorHandlers } from './agent-error';
import { AgentDetector } from '../../agent-detector';
import { ProcessManager } from '../../process-manager';
import { WebServer } from '../../web-server';
@@ -87,6 +88,7 @@ export { registerLeaderboardHandlers };
export type { LeaderboardHandlerDependencies };
export { registerNotificationsHandlers };
export { registerSymphonyHandlers };
export { registerAgentErrorHandlers };
export type { AgentsHandlerDependencies };
export type { ProcessHandlerDependencies };
export type { PersistenceHandlerDependencies };
@@ -255,6 +257,8 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
app: deps.app,
getMainWindow: deps.getMainWindow,
});
// Register agent error handlers (error state management)
registerAgentErrorHandlers();
// Setup logger event forwarding to renderer
setupLoggerEventForwarding(deps.getMainWindow);
}

View File

@@ -4,7 +4,7 @@
*/
import type { ProcessManager } from '../process-manager';
import type { ProcessListenerDependencies } from './types';
import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types';
/**
* Maximum buffer size per session (10MB).
@@ -12,12 +12,6 @@ import type { ProcessListenerDependencies } from './types';
*/
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.

View File

@@ -5,13 +5,7 @@
*/
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-';
import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types';
/**
* Sets up the exit listener for process termination.

View File

@@ -4,13 +4,7 @@
*/
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-';
import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types';
/**
* Sets up the session-id listener.

View File

@@ -4,6 +4,17 @@
*/
import type { ProcessManager } from '../process-manager';
// ==========================================================================
// Constants
// ==========================================================================
/**
* Prefix for group chat session IDs.
* Used for fast string check before expensive regex matching.
* Session IDs starting with this prefix belong to group chat sessions.
*/
export const GROUP_CHAT_PREFIX = 'group-chat-';
import type { WebServer } from '../web-server';
import type { AgentDetector } from '../agent-detector';
import type { SafeSendFn } from '../utils/safe-send';

View File

@@ -4,13 +4,7 @@
*/
import type { ProcessManager } from '../process-manager';
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-';
import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies, type UsageStats } from './types';
/**
* Sets up the usage listener for token/cost statistics.