Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/channels/src/adapters/discord/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,36 @@ export class DiscordAdapter implements ChannelAdapter {
return Promise.resolve();
}

/**
* Perform health check using Discord WebSocket ping
*
* @returns Promise that resolves to true if connection is healthy
*/
async healthCheck(): Promise<boolean> {
if (!this.isStarted) {
return false;
}

try {
// Check if client and WebSocket are available
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (!this.client.ws || this.client.ws.status !== 0) {
// Status 0 = READY
return false;
}

// Discord.js WebSocket ping returns the latency in milliseconds
// Negative value or very high latency indicates unhealthy connection
const ping = this.client.ws.ping;
return ping >= 0 && ping < 10000; // Consider healthy if ping < 10s
} catch (error) {
this.logger.warn('Health check failed', {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}

/**
* Clean up session resources (threads, condensed display, tool tracking)
*
Expand Down
38 changes: 24 additions & 14 deletions packages/channels/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,23 +316,33 @@ export class ChannelLifecycle {
return;
}

try {
// Simple health check: adapter should still be defined
// Platform-specific health checks could be added here
if (!this.adapter) {
throw new Error('Adapter not available');
}
// Use async wrapper to handle platform-specific health checks
void (async () => {
try {
// Simple health check: adapter should still be defined
if (!this.adapter) {
throw new Error('Adapter not available');
}

// Reset failure counter on success
if (this.consecutiveFailures > 0) {
this.consecutiveFailures = 0;
if (this.state === 'unhealthy') {
this.state = 'healthy';
// Platform-specific health check if available
if (this.adapter.healthCheck) {
const isHealthy = await this.adapter.healthCheck();
if (!isHealthy) {
throw new Error('Adapter health check failed');
}
}

// Reset failure counter on success
if (this.consecutiveFailures > 0) {
this.consecutiveFailures = 0;
if (this.state === 'unhealthy') {
this.state = 'healthy';
}
}
} catch {
this.handleHealthCheckFailure();
}
} catch {
this.handleHealthCheckFailure();
}
})();
}

/**
Expand Down
142 changes: 142 additions & 0 deletions packages/channels/test/adapters/discord/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,148 @@ describe('setupBotEventListeners()', () => {
});
});

describe('healthCheck()', () => {
let adapter: DiscordAdapter;

beforeEach(() => {
(globalThis as Record<string, unknown>).__mockClientRef = null;
adapter = new DiscordAdapter({ token: 'test-token' });
});

afterEach(() => {
vi.clearAllMocks();
(globalThis as Record<string, unknown>).__mockClientRef = null;
});

it('should return false if adapter not started', async () => {
const result = await adapter.healthCheck();
expect(result).toBe(false);
});

it('should return false if WebSocket not available', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate missing WebSocket
(mockClient as { ws: unknown }).ws = undefined;

const result = await adapter.healthCheck();
expect(result).toBe(false);
});

it('should return false if WebSocket status is not READY', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate non-READY status
(mockClient as { ws: { status: number } }).ws = { status: 1, ping: 50 }; // 1 = CONNECTING

const result = await adapter.healthCheck();
expect(result).toBe(false);
});

it('should return false if ping is negative', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate negative ping
(mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: -1 };

const result = await adapter.healthCheck();
expect(result).toBe(false);
});

it('should return false if ping exceeds threshold', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate very high ping (> 10s)
(mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: 15000 };

const result = await adapter.healthCheck();
expect(result).toBe(false);
});

it('should return true if connection is healthy', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate healthy connection
(mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: 50 };

const result = await adapter.healthCheck();
expect(result).toBe(true);
});

it('should return true for ping at edge of threshold', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate ping just under 10s threshold
(mockClient as { ws: { status: number; ping: number } }).ws = { status: 0, ping: 9999 };

const result = await adapter.healthCheck();
expect(result).toBe(true);
});

it('should return false and log warning on exception', async () => {
const startPromise = adapter.start();
setImmediate(() => {
const client = getMockClient();
client?.emit(Events.ClientReady, client);
});
await startPromise;

const mockClient = getMockClient();

// Simulate error accessing ws.ping
Object.defineProperty(mockClient, 'ws', {
get: () => {
throw new Error('WebSocket error');
},
});

const result = await adapter.healthCheck();
expect(result).toBe(false);
});
});

describe('cleanupSession()', () => {
let adapter: DiscordAdapter;

Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/types/channel-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,15 @@ export interface ChannelAdapter {
* @param channel - Channel identifier (platform-specific)
*/
sendTyping?(channel: string): Promise<void>;

/**
* Perform platform-specific health check (optional)
*
* Used by lifecycle managers to verify the adapter's connection is healthy.
* Platforms that support health checks (e.g., Discord's WebSocket ping)
* should implement this method.
*
* @returns Promise that resolves to true if healthy, false otherwise
*/
healthCheck?(): Promise<boolean>;
}