mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Implement Phase 7.2 - Model discovery for OpenCode
Add model discovery functionality to AgentDetector: - Added discoverModels(agentId, forceRefresh?) method to discover available models - Added clearModelCache(agentId?) method to clear cached model results - Implements 5-minute cache TTL to avoid repeated CLI calls - OpenCode: runs 'opencode models' and parses one model per line output - Added IPC handler 'agents:getModels' for renderer access - Updated preload.ts with window.maestro.agents.getModels() API - Added comprehensive unit tests (13 new tests covering discovery, caching, cache clearing, error handling)
This commit is contained in:
@@ -846,4 +846,276 @@ describe('agent-detector', () => {
|
||||
process.env.PATH = originalPath;
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverModels', () => {
|
||||
beforeEach(async () => {
|
||||
// Setup: opencode is available
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
const binaryName = args[0];
|
||||
if (binaryName === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (binaryName === 'bash') {
|
||||
return { stdout: '/bin/bash\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
// For model discovery command
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return {
|
||||
stdout: 'opencode/gpt-5-nano\nopencode/grok-code\nollama/qwen3:8b\n',
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: 'not found', exitCode: 1 };
|
||||
});
|
||||
|
||||
// Pre-detect agents so they're cached
|
||||
await detector.detectAgents();
|
||||
});
|
||||
|
||||
it('should return empty array for agents that do not support model selection', async () => {
|
||||
// Setup: claude-code is available but does not support model selection
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
const binaryName = args[0];
|
||||
if (binaryName === 'claude') {
|
||||
return { stdout: '/usr/bin/claude\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (binaryName === 'bash') {
|
||||
return { stdout: '/bin/bash\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: 'not found', exitCode: 1 };
|
||||
});
|
||||
|
||||
detector.clearCache();
|
||||
await detector.detectAgents();
|
||||
|
||||
const models = await detector.discoverModels('claude-code');
|
||||
expect(models).toEqual([]);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('does not support model selection'),
|
||||
'AgentDetector'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array for unavailable agents', async () => {
|
||||
const models = await detector.discoverModels('openai-codex');
|
||||
expect(models).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not available'),
|
||||
'AgentDetector'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown agents', async () => {
|
||||
const models = await detector.discoverModels('unknown-agent');
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should discover models for OpenCode', async () => {
|
||||
const models = await detector.discoverModels('opencode');
|
||||
expect(models).toEqual(['opencode/gpt-5-nano', 'opencode/grok-code', 'ollama/qwen3:8b']);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Discovered 3 models'),
|
||||
'AgentDetector',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should cache model discovery results', async () => {
|
||||
// First call
|
||||
const models1 = await detector.discoverModels('opencode');
|
||||
|
||||
// Clear mocks to track new calls
|
||||
mockExecFileNoThrow.mockClear();
|
||||
|
||||
// Second call should use cache
|
||||
const models2 = await detector.discoverModels('opencode');
|
||||
|
||||
expect(models1).toEqual(models2);
|
||||
// No new model discovery calls should have been made
|
||||
expect(mockExecFileNoThrow).not.toHaveBeenCalledWith(
|
||||
'/usr/bin/opencode',
|
||||
['models'],
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Returning cached models'),
|
||||
'AgentDetector'
|
||||
);
|
||||
});
|
||||
|
||||
it('should bypass cache when forceRefresh is true', async () => {
|
||||
// First call to populate cache
|
||||
await detector.discoverModels('opencode');
|
||||
|
||||
// Clear mocks
|
||||
mockExecFileNoThrow.mockClear();
|
||||
|
||||
// Force refresh
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return {
|
||||
stdout: 'new-model/fresh\n',
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
});
|
||||
|
||||
const models = await detector.discoverModels('opencode', true);
|
||||
|
||||
expect(models).toEqual(['new-model/fresh']);
|
||||
expect(mockExecFileNoThrow).toHaveBeenCalledWith(
|
||||
'/usr/bin/opencode',
|
||||
['models'],
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle model discovery command failure', async () => {
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return { stdout: '', stderr: 'command failed', exitCode: 1 };
|
||||
}
|
||||
if (args[0] === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
});
|
||||
|
||||
detector.clearCache();
|
||||
detector.clearModelCache();
|
||||
await detector.detectAgents();
|
||||
|
||||
const models = await detector.discoverModels('opencode');
|
||||
|
||||
expect(models).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Model discovery failed'),
|
||||
'AgentDetector',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty model list', async () => {
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (args[0] === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
});
|
||||
|
||||
detector.clearCache();
|
||||
detector.clearModelCache();
|
||||
await detector.detectAgents();
|
||||
|
||||
const models = await detector.discoverModels('opencode');
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out empty lines from model output', async () => {
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return {
|
||||
stdout: '\n \nmodel1\n\nmodel2\n \n',
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
if (args[0] === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
});
|
||||
|
||||
detector.clearCache();
|
||||
detector.clearModelCache();
|
||||
await detector.detectAgents();
|
||||
|
||||
const models = await detector.discoverModels('opencode');
|
||||
|
||||
expect(models).toEqual(['model1', 'model2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearModelCache', () => {
|
||||
beforeEach(async () => {
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
const binaryName = args[0];
|
||||
if (binaryName === 'opencode') {
|
||||
return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return {
|
||||
stdout: 'model1\nmodel2\n',
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: 'not found', exitCode: 1 };
|
||||
});
|
||||
|
||||
await detector.detectAgents();
|
||||
});
|
||||
|
||||
it('should clear cache for a specific agent', async () => {
|
||||
// Populate cache
|
||||
await detector.discoverModels('opencode');
|
||||
|
||||
// Clear cache for opencode
|
||||
detector.clearModelCache('opencode');
|
||||
|
||||
// Clear mocks to track new calls
|
||||
mockExecFileNoThrow.mockClear();
|
||||
|
||||
// Next call should re-fetch
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return { stdout: 'new-model\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
});
|
||||
|
||||
const models = await detector.discoverModels('opencode');
|
||||
|
||||
expect(models).toEqual(['new-model']);
|
||||
expect(mockExecFileNoThrow).toHaveBeenCalledWith(
|
||||
'/usr/bin/opencode',
|
||||
['models'],
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear all model caches when called without agentId', async () => {
|
||||
// Populate cache
|
||||
await detector.discoverModels('opencode');
|
||||
|
||||
// Clear all caches
|
||||
detector.clearModelCache();
|
||||
|
||||
// Clear mocks
|
||||
mockExecFileNoThrow.mockClear();
|
||||
|
||||
// Verify cache is empty (next call should re-fetch)
|
||||
mockExecFileNoThrow.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === '/usr/bin/opencode' && args[0] === 'models') {
|
||||
return { stdout: 'refreshed-model\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
});
|
||||
|
||||
const models = await detector.discoverModels('opencode');
|
||||
|
||||
expect(models).toEqual(['refreshed-model']);
|
||||
expect(mockExecFileNoThrow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,10 @@ export class AgentDetector {
|
||||
private cachedAgents: AgentConfig[] | null = null;
|
||||
private detectionInProgress: Promise<AgentConfig[]> | null = null;
|
||||
private customPaths: Record<string, string> = {};
|
||||
// Cache for model discovery results: agentId -> { models, timestamp }
|
||||
private modelCache: Map<string, { models: string[]; timestamp: number }> = new Map();
|
||||
// Cache TTL: 5 minutes (model lists don't change frequently)
|
||||
private readonly MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Set custom paths for agents (from user configuration)
|
||||
@@ -323,5 +327,96 @@ export class AgentDetector {
|
||||
clearCache(): void {
|
||||
this.cachedAgents = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the model cache for a specific agent or all agents
|
||||
*/
|
||||
clearModelCache(agentId?: string): void {
|
||||
if (agentId) {
|
||||
this.modelCache.delete(agentId);
|
||||
} else {
|
||||
this.modelCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available models for an agent that supports model selection.
|
||||
* Returns cached results if available and not expired.
|
||||
*
|
||||
* @param agentId - The agent identifier (e.g., 'opencode')
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh model list
|
||||
* @returns Array of model names, or empty array if agent doesn't support model discovery
|
||||
*/
|
||||
async discoverModels(agentId: string, forceRefresh = false): Promise<string[]> {
|
||||
const agent = await this.getAgent(agentId);
|
||||
|
||||
if (!agent || !agent.available) {
|
||||
logger.warn(`Cannot discover models: agent ${agentId} not available`, 'AgentDetector');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if agent supports model selection
|
||||
if (!agent.capabilities.supportsModelSelection) {
|
||||
logger.debug(`Agent ${agentId} does not support model selection`, 'AgentDetector');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check cache unless force refresh
|
||||
if (!forceRefresh) {
|
||||
const cached = this.modelCache.get(agentId);
|
||||
if (cached && (Date.now() - cached.timestamp) < this.MODEL_CACHE_TTL_MS) {
|
||||
logger.debug(`Returning cached models for ${agentId}`, 'AgentDetector');
|
||||
return cached.models;
|
||||
}
|
||||
}
|
||||
|
||||
// Run agent-specific model discovery command
|
||||
const models = await this.runModelDiscovery(agentId, agent);
|
||||
|
||||
// Cache the results
|
||||
this.modelCache.set(agentId, { models, timestamp: Date.now() });
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the agent-specific model discovery command.
|
||||
* Each agent may have a different way to list available models.
|
||||
*/
|
||||
private async runModelDiscovery(agentId: string, agent: AgentConfig): Promise<string[]> {
|
||||
const env = this.getExpandedEnv();
|
||||
const command = agent.path || agent.command;
|
||||
|
||||
// Agent-specific model discovery commands
|
||||
switch (agentId) {
|
||||
case 'opencode': {
|
||||
// OpenCode: `opencode models` returns one model per line
|
||||
const result = await execFileNoThrow(command, ['models'], undefined, env);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.warn(
|
||||
`Model discovery failed for ${agentId}: exit code ${result.exitCode}`,
|
||||
'AgentDetector',
|
||||
{ stderr: result.stderr }
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse output: one model per line (e.g., "opencode/gpt-5-nano", "ollama/gpt-oss:latest")
|
||||
const models = result.stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
logger.info(`Discovered ${models.length} models for ${agentId}`, 'AgentDetector', { models });
|
||||
return models;
|
||||
}
|
||||
|
||||
default:
|
||||
// For agents without model discovery implemented, return empty array
|
||||
logger.debug(`No model discovery implemented for ${agentId}`, 'AgentDetector');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,4 +259,15 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
|
||||
return customPaths;
|
||||
})
|
||||
);
|
||||
|
||||
// Discover available models for an agent that supports model selection
|
||||
ipcMain.handle(
|
||||
'agents:getModels',
|
||||
withIpcErrorLogging(handlerOpts('getModels'), async (agentId: string, forceRefresh?: boolean) => {
|
||||
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
|
||||
logger.info(`Discovering models for agent: ${agentId}`, LOG_CONTEXT, { forceRefresh });
|
||||
const models = await agentDetector.discoverModels(agentId, forceRefresh ?? false);
|
||||
return models;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,6 +386,9 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
getCustomPath: (agentId: string) =>
|
||||
ipcRenderer.invoke('agents:getCustomPath', agentId),
|
||||
getAllCustomPaths: () => ipcRenderer.invoke('agents:getAllCustomPaths'),
|
||||
// Discover available models for agents that support model selection (e.g., OpenCode with Ollama)
|
||||
getModels: (agentId: string, forceRefresh?: boolean) =>
|
||||
ipcRenderer.invoke('agents:getModels', agentId, forceRefresh) as Promise<string[]>,
|
||||
},
|
||||
|
||||
// Dialog API
|
||||
@@ -1006,6 +1009,7 @@ export interface MaestroAPI {
|
||||
setCustomPath: (agentId: string, customPath: string | null) => Promise<boolean>;
|
||||
getCustomPath: (agentId: string) => Promise<string | null>;
|
||||
getAllCustomPaths: () => Promise<Record<string, string>>;
|
||||
getModels: (agentId: string, forceRefresh?: boolean) => Promise<string[]>;
|
||||
};
|
||||
dialog: {
|
||||
selectFolder: () => Promise<string | null>;
|
||||
|
||||
Reference in New Issue
Block a user