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:
Pedram Amini
2025-12-16 23:00:52 -06:00
parent a8b54da75e
commit 4ba6503a89
4 changed files with 382 additions and 0 deletions

View File

@@ -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();
});
});
});

View File

@@ -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 [];
}
}
}

View File

@@ -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;
})
);
}

View File

@@ -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>;