feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
412
dexto/packages/core/src/agent/DextoAgent.lifecycle.test.ts
Normal file
412
dexto/packages/core/src/agent/DextoAgent.lifecycle.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import { DextoAgent } from './DextoAgent.js';
|
||||
import type { AgentConfig, ValidatedAgentConfig } from './schemas.js';
|
||||
import { AgentConfigSchema } from './schemas.js';
|
||||
import type { AgentServices } from '../utils/service-initializer.js';
|
||||
import { DextoRuntimeError } from '../errors/DextoRuntimeError.js';
|
||||
import { ErrorScope, ErrorType } from '../errors/types.js';
|
||||
import { AgentErrorCode } from './error-codes.js';
|
||||
|
||||
// Mock the createAgentServices function
|
||||
vi.mock('../utils/service-initializer.js', () => ({
|
||||
createAgentServices: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createAgentServices } from '../utils/service-initializer.js';
|
||||
const mockCreateAgentServices = vi.mocked(createAgentServices);
|
||||
|
||||
describe('DextoAgent Lifecycle Management', () => {
|
||||
let mockConfig: AgentConfig;
|
||||
let mockValidatedConfig: ValidatedAgentConfig;
|
||||
let mockServices: AgentServices;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
systemPrompt: 'You are a helpful assistant',
|
||||
llm: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
apiKey: 'test-key',
|
||||
maxIterations: 50,
|
||||
maxInputTokens: 128000,
|
||||
},
|
||||
mcpServers: {},
|
||||
sessions: {
|
||||
maxSessions: 10,
|
||||
sessionTTL: 3600,
|
||||
},
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
};
|
||||
|
||||
// Create the validated config that DextoAgent actually uses
|
||||
mockValidatedConfig = AgentConfigSchema.parse(mockConfig);
|
||||
|
||||
mockServices = {
|
||||
mcpManager: {
|
||||
disconnectAll: vi.fn(),
|
||||
initializeFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
} as any,
|
||||
toolManager: {
|
||||
setAgent: vi.fn(),
|
||||
setPromptManager: vi.fn(),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
} as any,
|
||||
systemPromptManager: {} as any,
|
||||
agentEventBus: {
|
||||
on: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
} as any,
|
||||
stateManager: {
|
||||
getRuntimeConfig: vi.fn().mockReturnValue({
|
||||
llm: mockValidatedConfig.llm,
|
||||
mcpServers: {},
|
||||
storage: {
|
||||
cache: { type: 'in-memory' },
|
||||
database: { type: 'in-memory' },
|
||||
},
|
||||
sessions: {
|
||||
maxSessions: 10,
|
||||
sessionTTL: 3600,
|
||||
},
|
||||
}),
|
||||
getLLMConfig: vi.fn().mockReturnValue(mockValidatedConfig.llm),
|
||||
} as any,
|
||||
sessionManager: {
|
||||
cleanup: vi.fn(),
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue({ id: 'test-session' }),
|
||||
} as any,
|
||||
searchService: {} as any,
|
||||
storageManager: {
|
||||
disconnect: vi.fn(),
|
||||
getDatabase: vi.fn().mockReturnValue({}),
|
||||
getCache: vi.fn().mockReturnValue({}),
|
||||
getBlobStore: vi.fn().mockReturnValue({}),
|
||||
} as any,
|
||||
resourceManager: {} as any,
|
||||
approvalManager: {
|
||||
requestToolConfirmation: vi.fn(),
|
||||
requestElicitation: vi.fn(),
|
||||
cancelApproval: vi.fn(),
|
||||
cancelAllApprovals: vi.fn(),
|
||||
hasHandler: vi.fn().mockReturnValue(false),
|
||||
} as any,
|
||||
memoryManager: {} as any,
|
||||
pluginManager: {
|
||||
cleanup: vi.fn(),
|
||||
} as any,
|
||||
};
|
||||
|
||||
mockCreateAgentServices.mockResolvedValue(mockServices);
|
||||
|
||||
// Set up default behaviors for mock functions that will be overridden in tests
|
||||
(mockServices.sessionManager.cleanup as any).mockResolvedValue(undefined);
|
||||
(mockServices.mcpManager.disconnectAll as any).mockResolvedValue(undefined);
|
||||
(mockServices.storageManager!.disconnect as any).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('Constructor Patterns', () => {
|
||||
test('should create agent with config (new pattern)', () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
expect(agent.isStarted()).toBe(false);
|
||||
expect(agent.isStopped()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start() Method', () => {
|
||||
test('should start successfully with valid config', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
await agent.start();
|
||||
|
||||
expect(agent.isStarted()).toBe(true);
|
||||
expect(agent.isStopped()).toBe(false);
|
||||
expect(mockCreateAgentServices).toHaveBeenCalledWith(
|
||||
mockValidatedConfig,
|
||||
undefined,
|
||||
expect.anything(), // logger instance
|
||||
expect.anything() // eventBus instance
|
||||
);
|
||||
});
|
||||
|
||||
test('should start with per-server connection modes in config', async () => {
|
||||
const configWithServerModes = {
|
||||
...mockConfig,
|
||||
mcpServers: {
|
||||
filesystem: {
|
||||
type: 'stdio' as const,
|
||||
command: 'npx',
|
||||
args: ['@modelcontextprotocol/server-filesystem', '.'],
|
||||
env: {},
|
||||
timeout: 30000,
|
||||
connectionMode: 'strict' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
const agent = new DextoAgent(configWithServerModes);
|
||||
|
||||
await agent.start();
|
||||
|
||||
const validatedConfigWithServerModes = AgentConfigSchema.parse(configWithServerModes);
|
||||
expect(mockCreateAgentServices).toHaveBeenCalledWith(
|
||||
validatedConfigWithServerModes,
|
||||
undefined,
|
||||
expect.anything(), // logger instance
|
||||
expect.anything() // eventBus instance
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when starting twice', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
await agent.start();
|
||||
|
||||
await expect(agent.start()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: AgentErrorCode.ALREADY_STARTED,
|
||||
scope: ErrorScope.AGENT,
|
||||
type: ErrorType.USER,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle start failure gracefully', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
mockCreateAgentServices.mockRejectedValue(new Error('Service initialization failed'));
|
||||
|
||||
await expect(agent.start()).rejects.toThrow('Service initialization failed');
|
||||
expect(agent.isStarted()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop() Method', () => {
|
||||
test('should stop successfully after start', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
await agent.start();
|
||||
|
||||
await agent.stop();
|
||||
|
||||
expect(agent.isStarted()).toBe(false);
|
||||
expect(agent.isStopped()).toBe(true);
|
||||
expect(mockServices.sessionManager.cleanup).toHaveBeenCalled();
|
||||
expect(mockServices.mcpManager.disconnectAll).toHaveBeenCalled();
|
||||
expect(mockServices.storageManager!.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should throw error when stopping before start', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
await expect(agent.stop()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: AgentErrorCode.NOT_STARTED,
|
||||
scope: ErrorScope.AGENT,
|
||||
type: ErrorType.USER,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should warn when stopping twice but not throw', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
|
||||
// Second stop should not throw but should warn
|
||||
await expect(agent.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle partial cleanup failures gracefully', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
await agent.start();
|
||||
|
||||
// Make session cleanup fail
|
||||
(mockServices.sessionManager.cleanup as any).mockRejectedValue(
|
||||
new Error('Session cleanup failed')
|
||||
);
|
||||
|
||||
// Should not throw, but should still mark as stopped
|
||||
await expect(agent.stop()).resolves.toBeUndefined();
|
||||
expect(agent.isStopped()).toBe(true);
|
||||
|
||||
// Should still try to clean other services
|
||||
expect(mockServices.mcpManager.disconnectAll).toHaveBeenCalled();
|
||||
expect(mockServices.storageManager!.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method Access Control', () => {
|
||||
const testMethods = [
|
||||
{ name: 'run', args: ['test message'] },
|
||||
{ name: 'createSession', args: [] },
|
||||
{ name: 'getSession', args: ['session-id'] },
|
||||
{ name: 'listSessions', args: [] },
|
||||
{ name: 'deleteSession', args: ['session-id'] },
|
||||
{ name: 'resetConversation', args: [] },
|
||||
{ name: 'getCurrentLLMConfig', args: [] },
|
||||
{ name: 'switchLLM', args: [{ model: 'gpt-5' }] },
|
||||
{ name: 'addMcpServer', args: ['test', { type: 'stdio', command: 'test' }] },
|
||||
{ name: 'getAllMcpTools', args: [] },
|
||||
];
|
||||
|
||||
test.each(testMethods)('$name should throw before start()', async ({ name, args }) => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
let thrownError: DextoRuntimeError | undefined;
|
||||
try {
|
||||
const method = agent[name as keyof DextoAgent] as Function;
|
||||
await method.apply(agent, args);
|
||||
} catch (error) {
|
||||
thrownError = error as DextoRuntimeError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeDefined();
|
||||
expect(thrownError).toMatchObject({
|
||||
code: AgentErrorCode.NOT_STARTED,
|
||||
scope: ErrorScope.AGENT,
|
||||
type: ErrorType.USER,
|
||||
});
|
||||
});
|
||||
|
||||
test.each(testMethods)('$name should throw after stop()', async ({ name, args }) => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
|
||||
let thrownError: DextoRuntimeError | undefined;
|
||||
try {
|
||||
const method = agent[name as keyof DextoAgent] as Function;
|
||||
await method.apply(agent, args);
|
||||
} catch (error) {
|
||||
thrownError = error as DextoRuntimeError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeDefined();
|
||||
expect(thrownError).toMatchObject({
|
||||
code: AgentErrorCode.STOPPED,
|
||||
scope: ErrorScope.AGENT,
|
||||
type: ErrorType.USER,
|
||||
});
|
||||
});
|
||||
|
||||
test('isStarted and isStopped should work without start() (read-only)', () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
expect(() => agent.isStarted()).not.toThrow();
|
||||
expect(() => agent.isStopped()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Auto-Approve Tools Cleanup (Memory Leak Fix)', () => {
|
||||
test('endSession should call clearSessionAutoApproveTools', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
// Add clearSessionAutoApproveTools mock to toolManager
|
||||
mockServices.toolManager.clearSessionAutoApproveTools = vi.fn();
|
||||
mockServices.sessionManager.endSession = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await agent.start();
|
||||
|
||||
await agent.endSession('test-session-123');
|
||||
|
||||
expect(mockServices.toolManager.clearSessionAutoApproveTools).toHaveBeenCalledWith(
|
||||
'test-session-123'
|
||||
);
|
||||
expect(mockServices.sessionManager.endSession).toHaveBeenCalledWith('test-session-123');
|
||||
});
|
||||
|
||||
test('deleteSession should call clearSessionAutoApproveTools', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
// Add clearSessionAutoApproveTools mock to toolManager
|
||||
mockServices.toolManager.clearSessionAutoApproveTools = vi.fn();
|
||||
mockServices.sessionManager.deleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await agent.start();
|
||||
|
||||
await agent.deleteSession('test-session-456');
|
||||
|
||||
expect(mockServices.toolManager.clearSessionAutoApproveTools).toHaveBeenCalledWith(
|
||||
'test-session-456'
|
||||
);
|
||||
expect(mockServices.sessionManager.deleteSession).toHaveBeenCalledWith(
|
||||
'test-session-456'
|
||||
);
|
||||
});
|
||||
|
||||
test('clearSessionAutoApproveTools should be called before session cleanup', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockServices.toolManager.clearSessionAutoApproveTools = vi.fn(() => {
|
||||
callOrder.push('clearSessionAutoApproveTools');
|
||||
});
|
||||
mockServices.sessionManager.endSession = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('endSession');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await agent.start();
|
||||
await agent.endSession('test-session');
|
||||
|
||||
expect(callOrder).toEqual(['clearSessionAutoApproveTools', 'endSession']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
test('should handle complete lifecycle without errors', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
|
||||
// Initial state
|
||||
expect(agent.isStarted()).toBe(false);
|
||||
expect(agent.isStopped()).toBe(false);
|
||||
|
||||
// Start
|
||||
await agent.start();
|
||||
expect(agent.isStarted()).toBe(true);
|
||||
expect(agent.isStopped()).toBe(false);
|
||||
|
||||
// Use agent (mock a successful operation)
|
||||
expect(agent.getCurrentLLMConfig()).toBeDefined();
|
||||
|
||||
// Stop
|
||||
await agent.stop();
|
||||
expect(agent.isStarted()).toBe(false);
|
||||
expect(agent.isStopped()).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle resource cleanup in correct order', async () => {
|
||||
const agent = new DextoAgent(mockConfig);
|
||||
await agent.start();
|
||||
|
||||
const cleanupOrder: string[] = [];
|
||||
|
||||
(mockServices.sessionManager.cleanup as any).mockImplementation(() => {
|
||||
cleanupOrder.push('sessions');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
(mockServices.mcpManager.disconnectAll as any).mockImplementation(() => {
|
||||
cleanupOrder.push('clients');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
(mockServices.storageManager!.disconnect as any).mockImplementation(() => {
|
||||
cleanupOrder.push('storage');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await agent.stop();
|
||||
|
||||
expect(cleanupOrder).toEqual(['sessions', 'clients', 'storage']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user