fix(tasks): Enable removing multiple tasks/subtasks via comma-separated IDs

- Refactors the core `removeTask` function (`task-manager/remove-task.js`) to accept and iterate over comma-separated task/subtask IDs.

- Updates dependency cleanup and file regeneration logic to run once after processing all specified IDs.

- Adjusts the `remove-task` CLI command (`commands.js`) description and confirmation prompt to handle multiple IDs correctly.

- Fixes a bug in the CLI confirmation prompt where task/subtask titles were not being displayed correctly.

- Updates the `remove_task` MCP tool description to reflect the new multi-ID capability.

This addresses the previously known issue where only the first ID in a comma-separated list was processed.

Closes #140
This commit is contained in:
Eyal Toledano
2025-04-28 00:41:32 -04:00
parent 66ac9ab9f6
commit 5aea93d4c0
7 changed files with 327 additions and 194 deletions

View File

@@ -0,0 +1,5 @@
---
'task-master-ai': patch
---
Fixes an issue that prevented remove-subtask with comma separated tasks/subtasks from being deleted (only the first ID was being deleted). Closes #140

View File

@@ -23,7 +23,9 @@ export function registerRemoveTaskTool(server) {
parameters: z.object({
id: z
.string()
.describe("ID of the task or subtask to remove (e.g., '5' or '5.2')"),
.describe(
"ID of the task or subtask to remove (e.g., '5' or '5.2'). Can be comma-separated to update multiple tasks/subtasks at once."
),
file: z.string().optional().describe('Absolute path to the tasks file'),
projectRoot: z
.string()

View File

@@ -1780,27 +1780,39 @@ function registerCommands(programInstance) {
// remove-task command
programInstance
.command('remove-task')
.description('Remove a task or subtask permanently')
.description('Remove one or more tasks or subtasks permanently')
.option(
'-i, --id <id>',
'ID of the task or subtask to remove (e.g., "5" or "5.2")'
'-i, --id <ids>',
'ID(s) of the task(s) or subtask(s) to remove (e.g., "5", "5.2", or "5,6.1,7")'
)
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
.option('-y, --yes', 'Skip confirmation prompt', false)
.action(async (options) => {
const tasksPath = options.file;
const taskId = options.id;
const taskIdsString = options.id;
if (!taskId) {
console.error(chalk.red('Error: Task ID is required'));
if (!taskIdsString) {
console.error(chalk.red('Error: Task ID(s) are required'));
console.error(
chalk.yellow('Usage: task-master remove-task --id=<taskId>')
chalk.yellow(
'Usage: task-master remove-task --id=<taskId1,taskId2...>'
)
);
process.exit(1);
}
const taskIdsToRemove = taskIdsString
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (taskIdsToRemove.length === 0) {
console.error(chalk.red('Error: No valid task IDs provided.'));
process.exit(1);
}
try {
// Check if the task exists
// Read data once for checks and confirmation
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
console.error(
@@ -1809,75 +1821,119 @@ function registerCommands(programInstance) {
process.exit(1);
}
const existingTasksToRemove = [];
const nonExistentIds = [];
let totalSubtasksToDelete = 0;
const dependentTaskMessages = [];
for (const taskId of taskIdsToRemove) {
if (!taskExists(data.tasks, taskId)) {
console.error(chalk.red(`Error: Task with ID ${taskId} not found`));
process.exit(1);
nonExistentIds.push(taskId);
} else {
// Correctly extract the task object from the result of findTaskById
const findResult = findTaskById(data.tasks, taskId);
const taskObject = findResult.task; // Get the actual task/subtask object
if (taskObject) {
existingTasksToRemove.push({ id: taskId, task: taskObject }); // Push the actual task object
// If it's a main task, count its subtasks and check dependents
if (!taskObject.isSubtask) {
// Check the actual task object
if (taskObject.subtasks && taskObject.subtasks.length > 0) {
totalSubtasksToDelete += taskObject.subtasks.length;
}
const dependentTasks = data.tasks.filter(
(t) =>
t.dependencies &&
t.dependencies.includes(parseInt(taskId, 10))
);
if (dependentTasks.length > 0) {
dependentTaskMessages.push(
` - Task ${taskId}: ${dependentTasks.length} dependent tasks (${dependentTasks.map((t) => t.id).join(', ')})`
);
}
}
} else {
// Handle case where findTaskById returned null for the task property (should be rare)
nonExistentIds.push(`${taskId} (error finding details)`);
}
}
}
// Load task for display
const task = findTaskById(data.tasks, taskId);
if (nonExistentIds.length > 0) {
console.warn(
chalk.yellow(
`Warning: The following task IDs were not found: ${nonExistentIds.join(', ')}`
)
);
}
if (existingTasksToRemove.length === 0) {
console.log(chalk.blue('No existing tasks found to remove.'));
process.exit(0);
}
// Skip confirmation if --yes flag is provided
if (!options.yes) {
// Display task information
console.log();
console.log(
chalk.red.bold(
'⚠️ WARNING: This will permanently delete the following task:'
`⚠️ WARNING: This will permanently delete the following ${existingTasksToRemove.length} item(s):`
)
);
console.log();
if (typeof taskId === 'string' && taskId.includes('.')) {
// It's a subtask
const [parentId, subtaskId] = taskId.split('.');
console.log(chalk.white.bold(`Subtask ${taskId}: ${task.title}`));
existingTasksToRemove.forEach(({ id, task }) => {
if (!task) return; // Should not happen due to taskExists check, but safeguard
if (task.isSubtask) {
// Subtask - title is directly on the task object
console.log(
chalk.white(` Subtask ${id}: ${task.title || '(no title)'}`)
);
// Optionally show parent context if available
if (task.parentTask) {
console.log(
chalk.gray(
`Parent Task: ${task.parentTask.id} - ${task.parentTask.title}`
` (Parent: ${task.parentTask.id} - ${task.parentTask.title || '(no title)'})`
)
);
}
} else {
// It's a main task
console.log(chalk.white.bold(`Task ${taskId}: ${task.title}`));
// Show if it has subtasks
if (task.subtasks && task.subtasks.length > 0) {
// Main task - title is directly on the task object
console.log(
chalk.yellow(
`⚠️ This task has ${task.subtasks.length} subtasks that will also be deleted!`
)
chalk.white.bold(` Task ${id}: ${task.title || '(no title)'}`)
);
}
// Show if other tasks depend on it
const dependentTasks = data.tasks.filter(
(t) =>
t.dependencies && t.dependencies.includes(parseInt(taskId, 10))
);
if (dependentTasks.length > 0) {
console.log(
chalk.yellow(
`⚠️ Warning: ${dependentTasks.length} other tasks depend on this task!`
)
);
console.log(chalk.yellow('These dependencies will be removed:'));
dependentTasks.forEach((t) => {
console.log(chalk.yellow(` - Task ${t.id}: ${t.title}`));
});
if (totalSubtasksToDelete > 0) {
console.log(
chalk.yellow(
`⚠️ This will also delete ${totalSubtasksToDelete} subtasks associated with the selected main tasks!`
)
);
}
if (dependentTaskMessages.length > 0) {
console.log(
chalk.yellow(
'⚠️ Warning: Dependencies on the following tasks will be removed:'
)
);
dependentTaskMessages.forEach((msg) =>
console.log(chalk.yellow(msg))
);
}
console.log();
// Prompt for confirmation
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: chalk.red.bold(
'Are you sure you want to permanently delete this task?'
`Are you sure you want to permanently delete these ${existingTasksToRemove.length} item(s)?`
),
default: false
}
@@ -1889,30 +1945,55 @@ function registerCommands(programInstance) {
}
}
const indicator = startLoadingIndicator('Removing task...');
const indicator = startLoadingIndicator(
`Removing ${existingTasksToRemove.length} task(s)/subtask(s)...`
);
// Remove the task
const result = await removeTask(tasksPath, taskId);
// Use the string of existing IDs for the core function
const existingIdsString = existingTasksToRemove
.map(({ id }) => id)
.join(',');
const result = await removeTask(tasksPath, existingIdsString);
stopLoadingIndicator(indicator);
// Display success message with appropriate color based on task or subtask
if (typeof taskId === 'string' && taskId.includes('.')) {
// It was a subtask
if (result.success) {
console.log(
boxen(
chalk.green(`Subtask ${taskId} has been successfully removed`),
chalk.green(
`Successfully removed ${result.removedTasks.length} task(s)/subtask(s).`
) +
(result.message ? `\n\nDetails:\n${result.message}` : '') +
(result.error
? `\n\nWarnings:\n${chalk.yellow(result.error)}`
: ''),
{ padding: 1, borderColor: 'green', borderStyle: 'round' }
)
);
} else {
// It was a main task
console.log(
boxen(chalk.green(`Task ${taskId} has been successfully removed`), {
console.error(
boxen(
chalk.red(
`Operation completed with errors. Removed ${result.removedTasks.length} task(s)/subtask(s).`
) +
(result.message ? `\n\nDetails:\n${result.message}` : '') +
(result.error ? `\n\nErrors:\n${chalk.red(result.error)}` : ''),
{
padding: 1,
borderColor: 'green',
borderColor: 'red',
borderStyle: 'round'
})
}
)
);
process.exit(1); // Exit with error code if any part failed
}
// Log any initially non-existent IDs again for clarity
if (nonExistentIds.length > 0) {
console.warn(
chalk.yellow(
`Note: The following IDs were not found initially and were skipped: ${nonExistentIds.join(', ')}`
)
);
}
} catch (error) {

View File

@@ -6,24 +6,48 @@ import generateTaskFiles from './generate-task-files.js';
import taskExists from './task-exists.js';
/**
* Removes a task or subtask from the tasks file
* Removes one or more tasks or subtasks from the tasks file
* @param {string} tasksPath - Path to the tasks file
* @param {string|number} taskId - ID of task or subtask to remove (e.g., '5' or '5.2')
* @returns {Object} Result object with success message and removed task info
* @param {string} taskIds - Comma-separated string of task/subtask IDs to remove (e.g., '5,6.1,7')
* @returns {Object} Result object with success status, messages, and removed task info
*/
async function removeTask(tasksPath, taskId) {
async function removeTask(tasksPath, taskIds) {
const results = {
success: true,
messages: [],
errors: [],
removedTasks: []
};
const taskIdsToRemove = taskIds
.split(',')
.map((id) => id.trim())
.filter(Boolean); // Remove empty strings if any
if (taskIdsToRemove.length === 0) {
results.success = false;
results.errors.push('No valid task IDs provided.');
return results;
}
try {
// Read the tasks file
// Read the tasks file ONCE before the loop
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}
// Check if the task ID exists
const tasksToDeleteFiles = []; // Collect IDs of main tasks whose files should be deleted
for (const taskId of taskIdsToRemove) {
// Check if the task ID exists *before* attempting removal
if (!taskExists(data.tasks, taskId)) {
throw new Error(`Task with ID ${taskId} not found`);
const errorMsg = `Task with ID ${taskId} not found or already removed.`;
results.errors.push(errorMsg);
results.success = false; // Mark overall success as false if any error occurs
continue; // Skip to the next ID
}
try {
// Handle subtask removal (e.g., '5.2')
if (typeof taskId === 'string' && taskId.includes('.')) {
const [parentTaskId, subtaskId] = taskId
@@ -34,7 +58,7 @@ async function removeTask(tasksPath, taskId) {
const parentTask = data.tasks.find((t) => t.id === parentTaskId);
if (!parentTask || !parentTask.subtasks) {
throw new Error(
`Parent task with ID ${parentTaskId} or its subtasks not found`
`Parent task ${parentTaskId} or its subtasks not found for subtask ${taskId}`
);
}
@@ -44,77 +68,88 @@ async function removeTask(tasksPath, taskId) {
);
if (subtaskIndex === -1) {
throw new Error(
`Subtask with ID ${subtaskId} not found in parent task ${parentTaskId}`
`Subtask ${subtaskId} not found in parent task ${parentTaskId}`
);
}
// Store the subtask info before removal for the result
const removedSubtask = parentTask.subtasks[subtaskIndex];
// Remove the subtask
parentTask.subtasks.splice(subtaskIndex, 1);
// Remove references to this subtask in other subtasks' dependencies
if (parentTask.subtasks && parentTask.subtasks.length > 0) {
parentTask.subtasks.forEach((subtask) => {
if (
subtask.dependencies &&
subtask.dependencies.includes(subtaskId)
) {
subtask.dependencies = subtask.dependencies.filter(
(depId) => depId !== subtaskId
);
}
});
}
// Save the updated tasks
writeJSON(tasksPath, data);
// Generate updated task files
try {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
} catch (genError) {
log(
'warn',
`Successfully removed subtask but failed to regenerate task files: ${genError.message}`
);
}
return {
success: true,
message: `Successfully removed subtask ${subtaskId} from task ${parentTaskId}`,
removedTask: removedSubtask,
// Store the subtask info before removal
const removedSubtask = {
...parentTask.subtasks[subtaskIndex],
parentTaskId: parentTaskId
};
}
results.removedTasks.push(removedSubtask);
// Remove the subtask from the parent
parentTask.subtasks.splice(subtaskIndex, 1);
results.messages.push(`Successfully removed subtask ${taskId}`);
}
// Handle main task removal
else {
const taskIdNum = parseInt(taskId, 10);
const taskIndex = data.tasks.findIndex((t) => t.id === taskIdNum);
if (taskIndex === -1) {
// This case should theoretically be caught by the taskExists check above,
// but keep it as a safeguard.
throw new Error(`Task with ID ${taskId} not found`);
}
// Store the task info before removal for the result
// Store the task info before removal
const removedTask = data.tasks[taskIndex];
results.removedTasks.push(removedTask);
tasksToDeleteFiles.push(taskIdNum); // Add to list for file deletion
// Remove the task
// Remove the task from the main array
data.tasks.splice(taskIndex, 1);
// Remove references to this task in other tasks' dependencies
results.messages.push(`Successfully removed task ${taskId}`);
}
} catch (innerError) {
// Catch errors specific to processing *this* ID
const errorMsg = `Error processing ID ${taskId}: ${innerError.message}`;
results.errors.push(errorMsg);
results.success = false;
log('warn', errorMsg); // Log as warning and continue with next ID
}
} // End of loop through taskIdsToRemove
// --- Post-Loop Operations ---
// Only proceed with cleanup and saving if at least one task was potentially removed
if (results.removedTasks.length > 0) {
// Remove all references AFTER all tasks/subtasks are removed
const allRemovedIds = new Set(
taskIdsToRemove.map((id) =>
typeof id === 'string' && id.includes('.') ? id : parseInt(id, 10)
)
);
data.tasks.forEach((task) => {
if (task.dependencies && task.dependencies.includes(taskIdNum)) {
// Clean dependencies in main tasks
if (task.dependencies) {
task.dependencies = task.dependencies.filter(
(depId) => depId !== taskIdNum
(depId) => !allRemovedIds.has(depId)
);
}
// Clean dependencies in remaining subtasks
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (subtask.dependencies) {
subtask.dependencies = subtask.dependencies.filter(
(depId) =>
!allRemovedIds.has(`${task.id}.${depId}`) &&
!allRemovedIds.has(depId) // check both subtask and main task refs
);
}
});
}
});
// Save the updated tasks
// Save the updated tasks file ONCE
writeJSON(tasksPath, data);
// Delete the task file if it exists
// Delete task files AFTER saving tasks.json
for (const taskIdNum of tasksToDeleteFiles) {
const taskFileName = path.join(
path.dirname(tasksPath),
`task_${taskIdNum.toString().padStart(3, '0')}.txt`
@@ -122,35 +157,49 @@ async function removeTask(tasksPath, taskId) {
if (fs.existsSync(taskFileName)) {
try {
fs.unlinkSync(taskFileName);
results.messages.push(`Deleted task file: ${taskFileName}`);
} catch (unlinkError) {
log(
'warn',
`Successfully removed task from tasks.json but failed to delete task file: ${unlinkError.message}`
);
const unlinkMsg = `Failed to delete task file ${taskFileName}: ${unlinkError.message}`;
results.errors.push(unlinkMsg);
results.success = false;
log('warn', unlinkMsg);
}
}
}
// Generate updated task files
// Generate updated task files ONCE
try {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
results.messages.push('Task files regenerated successfully.');
} catch (genError) {
log(
'warn',
`Successfully removed task but failed to regenerate task files: ${genError.message}`
);
const genErrMsg = `Failed to regenerate task files: ${genError.message}`;
results.errors.push(genErrMsg);
results.success = false;
log('warn', genErrMsg);
}
} else if (results.errors.length === 0) {
// Case where valid IDs were provided but none existed
results.messages.push('No tasks found matching the provided IDs.');
}
// Consolidate messages for final output
const finalMessage = results.messages.join('\n');
const finalError = results.errors.join('\n');
return {
success: true,
message: `Successfully removed task ${taskId}`,
removedTask: removedTask
success: results.success,
message: finalMessage || 'No tasks were removed.',
error: finalError || null,
removedTasks: results.removedTasks
};
} catch (error) {
log('error', `Error removing task: ${error.message}`);
throw {
code: 'REMOVE_TASK_ERROR',
message: error.message,
details: error.stack
// Catch errors from reading file or other initial setup
log('error', `Error removing tasks: ${error.message}`);
return {
success: false,
message: '',
error: `Operation failed: ${error.message}`,
removedTasks: []
};
}
}

View File

@@ -1,6 +1,6 @@
# Task ID: 36
# Title: Add Ollama Support for AI Services as Claude Alternative
# Status: pending
# Status: deferred
# Dependencies: None
# Priority: medium
# Description: Implement Ollama integration as an alternative to Claude for all main AI services, allowing users to run local language models instead of relying on cloud-based Claude API.

View File

@@ -1009,7 +1009,7 @@ When refactoring `sendChatWithContext` and related functions, ensure they align
</info added on 2025-04-20T03:53:03.709Z>
## 18. Refactor Callers of AI Parsing Utilities [deferred]
### Dependencies: 61.11,61.12,61.13,61.14,61.15,61.16,61.17,61.19
### Dependencies: None
### Description: Update the code that calls `parseSubtasksFromText`, `parseTaskJsonResponse`, and `parseTasksFromCompletion` to instead directly handle the structured JSON output provided by `generateObjectService` (as the refactored AI calls will now use it).
### Details:
@@ -1588,7 +1588,7 @@ export async function streamOpenAITextSimplified(params) {
</info added on 2025-04-27T05:39:31.942Z>
## 23. Implement Conditional Provider Logic in `ai-services-unified.js` [done]
### Dependencies: 61.20,61.21,61.22,61.24,61.25,61.26,61.27,61.28,61.29,61.30,61.34
### Dependencies: None
### Description: Implement logic within the functions of `ai-services-unified.js` (e.g., `generateTextService`, `generateObjectService`, `streamChatService`) to dynamically select and call the appropriate provider module (`anthropic.js`, `perplexity.js`, etc.) based on configuration (e.g., environment variables like `AI_PROVIDER` and `AI_MODEL` from `process.env` or `session.env`).
### Details:

View File

@@ -2298,7 +2298,7 @@
"id": 36,
"title": "Add Ollama Support for AI Services as Claude Alternative",
"description": "Implement Ollama integration as an alternative to Claude for all main AI services, allowing users to run local language models instead of relying on cloud-based Claude API.",
"status": "pending",
"status": "deferred",
"dependencies": [],
"priority": "medium",
"details": "This task involves creating a comprehensive Ollama integration that can replace Claude across all main AI services in the application. Implementation should include:\n\n1. Create an OllamaService class that implements the same interface as the ClaudeService to ensure compatibility\n2. Add configuration options to specify Ollama endpoint URL (default: http://localhost:11434)\n3. Implement model selection functionality to allow users to choose which Ollama model to use (e.g., llama3, mistral, etc.)\n4. Handle prompt formatting specific to Ollama models, ensuring proper system/user message separation\n5. Implement proper error handling for cases where Ollama server is unavailable or returns errors\n6. Add fallback mechanism to Claude when Ollama fails or isn't configured\n7. Update the AI service factory to conditionally create either Claude or Ollama service based on configuration\n8. Ensure token counting and rate limiting are appropriately handled for Ollama models\n9. Add documentation for users explaining how to set up and use Ollama with the application\n10. Optimize prompt templates specifically for Ollama models if needed\n\nThe implementation should be toggled through a configuration option (useOllama: true/false) and should maintain all existing functionality currently provided by Claude.",
@@ -3193,9 +3193,7 @@
"description": "Update the code that calls `parseSubtasksFromText`, `parseTaskJsonResponse`, and `parseTasksFromCompletion` to instead directly handle the structured JSON output provided by `generateObjectService` (as the refactored AI calls will now use it).",
"details": "\n\n<info added on 2025-04-20T03:52:45.518Z>\nThe refactoring of callers to AI parsing utilities should align with the new configuration system. When updating these callers:\n\n1. Replace direct API key references with calls to the configuration system using `resolveEnvVariable` for sensitive credentials.\n\n2. Update model selection logic to use the centralized configuration from `.taskmasterconfig` via the getter functions in `config-manager.js`. For example:\n ```javascript\n // Old approach\n const model = \"gpt-4\";\n \n // New approach\n import { getModelForRole } from './config-manager';\n const model = getModelForRole('parsing'); // or appropriate role\n ```\n\n3. Similarly, replace hardcoded parameters with configuration-based values:\n ```javascript\n // Old approach\n const maxTokens = 2000;\n const temperature = 0.2;\n \n // New approach\n import { getAIParameterValue } from './config-manager';\n const maxTokens = getAIParameterValue('maxTokens', 'parsing');\n const temperature = getAIParameterValue('temperature', 'parsing');\n ```\n\n4. Ensure logging behavior respects the centralized logging configuration settings.\n\n5. When calling `generateObjectService`, pass the appropriate configuration context to ensure it uses the correct settings from the centralized configuration system.\n</info added on 2025-04-20T03:52:45.518Z>",
"status": "deferred",
"dependencies": [
"61.11,61.12,61.13,61.14,61.15,61.16,61.17,61.19"
],
"dependencies": [],
"parentTaskId": 61
},
{
@@ -3242,9 +3240,7 @@
"description": "Implement logic within the functions of `ai-services-unified.js` (e.g., `generateTextService`, `generateObjectService`, `streamChatService`) to dynamically select and call the appropriate provider module (`anthropic.js`, `perplexity.js`, etc.) based on configuration (e.g., environment variables like `AI_PROVIDER` and `AI_MODEL` from `process.env` or `session.env`).",
"details": "\n\n<info added on 2025-04-20T03:52:13.065Z>\nThe unified service should now use the configuration manager for provider selection rather than directly accessing environment variables. Here's the implementation approach:\n\n1. Import the config-manager functions:\n```javascript\nconst { \n getMainProvider, \n getResearchProvider, \n getFallbackProvider,\n getModelForRole,\n getProviderParameters\n} = require('./config-manager');\n```\n\n2. Implement provider selection based on context/role:\n```javascript\nfunction selectProvider(role = 'default', context = {}) {\n // Try to get provider based on role or context\n let provider;\n \n if (role === 'research') {\n provider = getResearchProvider();\n } else if (context.fallback) {\n provider = getFallbackProvider();\n } else {\n provider = getMainProvider();\n }\n \n // Dynamically import the provider module\n return require(`./${provider}.js`);\n}\n```\n\n3. Update service functions to use this selection logic:\n```javascript\nasync function generateTextService(prompt, options = {}) {\n const { role = 'default', ...otherOptions } = options;\n const provider = selectProvider(role, options);\n const model = getModelForRole(role);\n const parameters = getProviderParameters(provider.name);\n \n return provider.generateText(prompt, { \n model, \n ...parameters,\n ...otherOptions \n });\n}\n```\n\n4. Implement fallback logic for service resilience:\n```javascript\nasync function executeWithFallback(serviceFunction, ...args) {\n try {\n return await serviceFunction(...args);\n } catch (error) {\n console.error(`Primary provider failed: ${error.message}`);\n const fallbackProvider = require(`./${getFallbackProvider()}.js`);\n return fallbackProvider[serviceFunction.name](...args);\n }\n}\n```\n\n5. Add provider capability checking to prevent calling unsupported features:\n```javascript\nfunction checkProviderCapability(provider, capability) {\n const capabilities = {\n 'anthropic': ['text', 'chat', 'stream'],\n 'perplexity': ['text', 'chat', 'stream', 'research'],\n 'openai': ['text', 'chat', 'stream', 'embedding', 'vision']\n // Add other providers as needed\n };\n \n return capabilities[provider]?.includes(capability) || false;\n}\n```\n</info added on 2025-04-20T03:52:13.065Z>",
"status": "done",
"dependencies": [
"61.20,61.21,61.22,61.24,61.25,61.26,61.27,61.28,61.29,61.30,61.34"
],
"dependencies": [],
"parentTaskId": 61
},
{