From c99df64f651fb40bae5d7979ee2b2428586f44d3 Mon Sep 17 00:00:00 2001 From: shenysun <40556411+shenysun@users.noreply.github.com> Date: Thu, 3 Jul 2025 04:35:49 +0800 Subject: [PATCH] feat: Support custom response language (#510) * feat: Support custom response language * fix: Add default values for response language in config-manager.js * chore: Update configuration file and add default response language settings * feat: Support MCP/CLI custom response language * chore: Update test comments to English for consistency * docs: Auto-update and format models.md * chore: fix format --------- Co-authored-by: github-actions[bot] Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> --- .changeset/tidy-meals-enter.md | 5 + .taskmaster/config.json | 3 +- assets/config.json | 9 +- docs/configuration.md | 3 +- .../direct-functions/response-language.js | 40 ++++++++ mcp-server/src/tools/index.js | 2 + mcp-server/src/tools/response-language.js | 46 +++++++++ scripts/init.js | 38 ++++++++ scripts/modules/ai-services-unified.js | 10 +- scripts/modules/commands.js | 60 +++++++++++- scripts/modules/config-manager.js | 9 +- scripts/modules/task-manager.js | 3 + .../modules/task-manager/response-language.js | 94 +++++++++++++++++++ tests/unit/ai-services-unified.test.js | 65 +++++++++++++ tests/unit/config-manager.test.js | 79 +++++++++++++++- 15 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 .changeset/tidy-meals-enter.md create mode 100644 mcp-server/src/core/direct-functions/response-language.js create mode 100644 mcp-server/src/tools/response-language.js create mode 100644 scripts/modules/task-manager/response-language.js diff --git a/.changeset/tidy-meals-enter.md b/.changeset/tidy-meals-enter.md new file mode 100644 index 00000000..5b3667b9 --- /dev/null +++ b/.changeset/tidy-meals-enter.md @@ -0,0 +1,5 @@ +--- +'task-master-ai': patch +--- + +Support custom response language diff --git a/.taskmaster/config.json b/.taskmaster/config.json index 3bd2b3f8..cef8dcaa 100644 --- a/.taskmaster/config.json +++ b/.taskmaster/config.json @@ -29,6 +29,7 @@ "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", "userId": "1234567890", "azureBaseURL": "https://your-endpoint.azure.com/", - "defaultTag": "master" + "defaultTag": "master", + "responseLanguage": "English" } } diff --git a/assets/config.json b/assets/config.json index d015eb4c..8ec52f58 100644 --- a/assets/config.json +++ b/assets/config.json @@ -3,7 +3,7 @@ "main": { "provider": "anthropic", "modelId": "claude-3-7-sonnet-20250219", - "maxTokens": 120000, + "maxTokens": 100000, "temperature": 0.2 }, "research": { @@ -14,9 +14,9 @@ }, "fallback": { "provider": "anthropic", - "modelId": "claude-3-5-sonnet-20240620", + "modelId": "claude-3-7-sonnet-20250219", "maxTokens": 8192, - "temperature": 0.1 + "temperature": 0.2 } }, "global": { @@ -28,6 +28,7 @@ "defaultTag": "master", "ollamaBaseURL": "http://localhost:11434/api", "azureOpenaiBaseURL": "https://your-endpoint.openai.azure.com/", - "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com" + "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", + "responseLanguage": "English" } } diff --git a/docs/configuration.md b/docs/configuration.md index 4d619dd9..9be224c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,7 +44,8 @@ Taskmaster uses two primary methods for configuration: "ollamaBaseURL": "http://localhost:11434/api", "azureBaseURL": "https://your-endpoint.azure.com/openai/deployments", "vertexProjectId": "your-gcp-project-id", - "vertexLocation": "us-central1" + "vertexLocation": "us-central1", + "responseLanguage": "English" } } ``` diff --git a/mcp-server/src/core/direct-functions/response-language.js b/mcp-server/src/core/direct-functions/response-language.js new file mode 100644 index 00000000..e39bda74 --- /dev/null +++ b/mcp-server/src/core/direct-functions/response-language.js @@ -0,0 +1,40 @@ +/** + * response-language.js + * Direct function for managing response language via MCP + */ + +import { setResponseLanguage } from '../../../../scripts/modules/task-manager.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; +import { createLogWrapper } from '../../tools/utils.js'; + +export async function responseLanguageDirect(args, log, context = {}) { + const { projectRoot, language } = args; + const mcpLog = createLogWrapper(log); + + log.info( + `Executing response-language_direct with args: ${JSON.stringify(args)}` + ); + log.info(`Using project root: ${projectRoot}`); + + try { + enableSilentMode(); + return setResponseLanguage(language, { + mcpLog, + projectRoot + }); + } catch (error) { + return { + success: false, + error: { + code: 'DIRECT_FUNCTION_ERROR', + message: error.message, + details: error.stack + } + }; + } finally { + disableSilentMode(); + } +} diff --git a/mcp-server/src/tools/index.js b/mcp-server/src/tools/index.js index a4aaaecc..32144619 100644 --- a/mcp-server/src/tools/index.js +++ b/mcp-server/src/tools/index.js @@ -29,6 +29,7 @@ import { registerRemoveTaskTool } from './remove-task.js'; import { registerInitializeProjectTool } from './initialize-project.js'; import { registerModelsTool } from './models.js'; import { registerMoveTaskTool } from './move-task.js'; +import { registerResponseLanguageTool } from './response-language.js'; import { registerAddTagTool } from './add-tag.js'; import { registerDeleteTagTool } from './delete-tag.js'; import { registerListTagsTool } from './list-tags.js'; @@ -83,6 +84,7 @@ export function registerTaskMasterTools(server) { registerRemoveDependencyTool(server); registerValidateDependenciesTool(server); registerFixDependenciesTool(server); + registerResponseLanguageTool(server); // Group 7: Tag Management registerListTagsTool(server); diff --git a/mcp-server/src/tools/response-language.js b/mcp-server/src/tools/response-language.js new file mode 100644 index 00000000..42a8ee06 --- /dev/null +++ b/mcp-server/src/tools/response-language.js @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { + createErrorResponse, + handleApiResult, + withNormalizedProjectRoot +} from './utils.js'; +import { responseLanguageDirect } from '../core/direct-functions/response-language.js'; + +export function registerResponseLanguageTool(server) { + server.addTool({ + name: 'response-language', + description: 'Get or set the response language for the project', + parameters: z.object({ + projectRoot: z + .string() + .describe( + 'The root directory for the project. ALWAYS SET THIS TO THE PROJECT ROOT DIRECTORY. IF NOT SET, THE TOOL WILL NOT WORK.' + ), + language: z + .string() + .describe( + 'The new response language to set. like "中文" "English" or "español".' + ) + }), + execute: withNormalizedProjectRoot(async (args, { log, session }) => { + try { + log.info( + `Executing response-language tool with args: ${JSON.stringify(args)}` + ); + + const result = await responseLanguageDirect( + { + ...args, + projectRoot: args.projectRoot + }, + log, + { session } + ); + return handleApiResult(result, log, 'Error setting response language'); + } catch (error) { + log.error(`Error in response-language tool: ${error.message}`); + return createErrorResponse(error.message); + } + }) + }); +} diff --git a/scripts/init.js b/scripts/init.js index 8009e4a3..10ecb200 100755 --- a/scripts/init.js +++ b/scripts/init.js @@ -766,6 +766,44 @@ function createProjectStructure( } // ===================================== + // === Add Response Language Step === + if (!isSilentMode() && !dryRun && !options?.yes) { + console.log( + boxen(chalk.cyan('Configuring Response Language...'), { + padding: 0.5, + margin: { top: 1, bottom: 0.5 }, + borderStyle: 'round', + borderColor: 'blue' + }) + ); + log( + 'info', + 'Running interactive response language setup. Please input your preferred language.' + ); + try { + execSync('npx task-master lang --setup', { + stdio: 'inherit', + cwd: targetDir + }); + log('success', 'Response Language configured.'); + } catch (error) { + log('error', 'Failed to configure response language:', error.message); + log('warn', 'You may need to run "task-master lang --setup" manually.'); + } + } else if (isSilentMode() && !dryRun) { + log( + 'info', + 'Skipping interactive response language setup in silent (MCP) mode.' + ); + log( + 'warn', + 'Please configure response language using "task-master models --set-response-language" or the "models" MCP tool.' + ); + } else if (dryRun) { + log('info', 'DRY RUN: Skipping interactive response language setup.'); + } + // ===================================== + // === Add Model Configuration Step === if (!isSilentMode() && !dryRun && !options?.yes) { console.log( diff --git a/scripts/modules/ai-services-unified.js b/scripts/modules/ai-services-unified.js index 283f69d4..e8f4f549 100644 --- a/scripts/modules/ai-services-unified.js +++ b/scripts/modules/ai-services-unified.js @@ -15,6 +15,7 @@ import { getFallbackProvider, getFallbackModelId, getParametersForRole, + getResponseLanguage, getUserId, MODEL_MAP, getDebugFlag, @@ -551,9 +552,12 @@ async function _unifiedServiceRunner(serviceType, params) { } const messages = []; - if (systemPrompt) { - messages.push({ role: 'system', content: systemPrompt }); - } + const responseLanguage = getResponseLanguage(effectiveProjectRoot); + const systemPromptWithLanguage = `${systemPrompt} \n\n Always respond in ${responseLanguage}.`; + messages.push({ + role: 'system', + content: systemPromptWithLanguage.trim() + }); // IN THE FUTURE WHEN DOING CONTEXT IMPROVEMENTS // { diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index fc54d80a..8b653463 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -42,7 +42,8 @@ import { findTaskById, taskExists, moveTask, - migrateProject + migrateProject, + setResponseLanguage } from './task-manager.js'; import { @@ -3661,6 +3662,63 @@ Examples: return; // Stop execution here }); + // response-language command + programInstance + .command('lang') + .description('Manage response language settings') + .option('--response ', 'Set the response language') + .option('--setup', 'Run interactive setup to configure response language') + .action(async (options) => { + const projectRoot = findProjectRoot(); // Find project root for context + const { response, setup } = options; + console.log( + chalk.blue('Response language set to:', JSON.stringify(options)) + ); + let responseLanguage = response || 'English'; + if (setup) { + console.log( + chalk.blue('Starting interactive response language setup...') + ); + try { + const userResponse = await inquirer.prompt([ + { + type: 'input', + name: 'responseLanguage', + message: 'Input your preferred response language', + default: 'English' + } + ]); + + console.log( + chalk.blue( + 'Response language set to:', + userResponse.responseLanguage + ) + ); + responseLanguage = userResponse.responseLanguage; + } catch (setupError) { + console.error( + chalk.red('\\nInteractive setup failed unexpectedly:'), + setupError.message + ); + } + } + + const result = setResponseLanguage(responseLanguage, { + projectRoot + }); + + if (result.success) { + console.log(chalk.green(`✅ ${result.data.message}`)); + } else { + console.error( + chalk.red( + `❌ Error setting response language: ${result.error.message}` + ) + ); + } + }); + // move-task command programInstance .command('move') diff --git a/scripts/modules/config-manager.js b/scripts/modules/config-manager.js index 51577436..43442682 100644 --- a/scripts/modules/config-manager.js +++ b/scripts/modules/config-manager.js @@ -66,7 +66,8 @@ const DEFAULTS = { defaultPriority: 'medium', projectName: 'Task Master', ollamaBaseURL: 'http://localhost:11434/api', - bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com' + bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com', + responseLanguage: 'English' } }; @@ -425,6 +426,11 @@ function getVertexLocation(explicitRoot = null) { return getGlobalConfig(explicitRoot).vertexLocation || 'us-central1'; } +function getResponseLanguage(explicitRoot = null) { + // Directly return value from config + return getGlobalConfig(explicitRoot).responseLanguage; +} + /** * Gets model parameters (maxTokens, temperature) for a specific role, * considering model-specific overrides from supported-models.json. @@ -841,6 +847,7 @@ export { getOllamaBaseURL, getAzureBaseURL, getBedrockBaseURL, + getResponseLanguage, getParametersForRole, getUserId, // API Key Checkers (still relevant) diff --git a/scripts/modules/task-manager.js b/scripts/modules/task-manager.js index e8af0023..7fab9d8f 100644 --- a/scripts/modules/task-manager.js +++ b/scripts/modules/task-manager.js @@ -23,10 +23,12 @@ import updateSubtaskById from './task-manager/update-subtask-by-id.js'; import removeTask from './task-manager/remove-task.js'; import taskExists from './task-manager/task-exists.js'; import isTaskDependentOn from './task-manager/is-task-dependent.js'; +import setResponseLanguage from './task-manager/response-language.js'; import moveTask from './task-manager/move-task.js'; import { migrateProject } from './task-manager/migrate.js'; import { performResearch } from './task-manager/research.js'; import { readComplexityReport } from './utils.js'; + // Export task manager functions export { parsePRD, @@ -49,6 +51,7 @@ export { findTaskById, taskExists, isTaskDependentOn, + setResponseLanguage, moveTask, readComplexityReport, migrateProject, diff --git a/scripts/modules/task-manager/response-language.js b/scripts/modules/task-manager/response-language.js new file mode 100644 index 00000000..7ca97ea6 --- /dev/null +++ b/scripts/modules/task-manager/response-language.js @@ -0,0 +1,94 @@ +import path from 'path'; +import fs from 'fs'; +import { + getConfig, + isConfigFilePresent, + writeConfig +} from '../config-manager.js'; + +function setResponseLanguage(lang, options = {}) { + const { mcpLog, projectRoot } = options; + + const report = (level, ...args) => { + if (mcpLog && typeof mcpLog[level] === 'function') { + mcpLog[level](...args); + } + }; + + let configPath; + let configExists = false; + + if (projectRoot) { + configPath = path.join(projectRoot, '.taskmasterconfig'); + configExists = fs.existsSync(configPath); + report( + 'info', + `Checking for .taskmasterconfig at: ${configPath}, exists: ${configExists}` + ); + } else { + configExists = isConfigFilePresent(); + report( + 'info', + `Checking for .taskmasterconfig using isConfigFilePresent(), exists: ${configExists}` + ); + } + + if (!configExists) { + return { + success: false, + error: { + code: 'CONFIG_MISSING', + message: + 'The .taskmasterconfig file is missing. Run "task-master models --setup" to create it.' + } + }; + } + + // Validate response language + if (typeof lang !== 'string' || lang.trim() === '') { + return { + success: false, + error: { + code: 'INVALID_RESPONSE_LANGUAGE', + message: `Invalid response language: ${lang}. Must be a non-empty string.` + } + }; + } + + try { + const currentConfig = getConfig(projectRoot); + currentConfig.global.responseLanguage = lang; + const writeResult = writeConfig(currentConfig, projectRoot); + + if (!writeResult) { + return { + success: false, + error: { + code: 'WRITE_ERROR', + message: 'Error writing updated configuration to .taskmasterconfig' + } + }; + } + + const successMessage = `Successfully set response language to: ${lang}`; + report('info', successMessage); + return { + success: true, + data: { + responseLanguage: lang, + message: successMessage + } + }; + } catch (error) { + report('error', `Error setting response language: ${error.message}`); + return { + success: false, + error: { + code: 'SET_RESPONSE_LANGUAGE_ERROR', + message: error.message + } + }; + } +} + +export default setResponseLanguage; diff --git a/tests/unit/ai-services-unified.test.js b/tests/unit/ai-services-unified.test.js index 333fe1ae..f12b37bf 100644 --- a/tests/unit/ai-services-unified.test.js +++ b/tests/unit/ai-services-unified.test.js @@ -8,6 +8,7 @@ const mockGetResearchModelId = jest.fn(); const mockGetFallbackProvider = jest.fn(); const mockGetFallbackModelId = jest.fn(); const mockGetParametersForRole = jest.fn(); +const mockGetResponseLanguage = jest.fn(); const mockGetUserId = jest.fn(); const mockGetDebugFlag = jest.fn(); const mockIsApiKeySet = jest.fn(); @@ -98,6 +99,7 @@ jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({ getFallbackMaxTokens: mockGetFallbackMaxTokens, getFallbackTemperature: mockGetFallbackTemperature, getParametersForRole: mockGetParametersForRole, + getResponseLanguage: mockGetResponseLanguage, getUserId: mockGetUserId, getDebugFlag: mockGetDebugFlag, getBaseUrlForRole: mockGetBaseUrlForRole, @@ -277,6 +279,7 @@ describe('Unified AI Services', () => { if (role === 'fallback') return { maxTokens: 150, temperature: 0.6 }; return { maxTokens: 100, temperature: 0.5 }; // Default }); + mockGetResponseLanguage.mockReturnValue('English'); mockResolveEnvVariable.mockImplementation((key) => { if (key === 'ANTHROPIC_API_KEY') return 'mock-anthropic-key'; if (key === 'PERPLEXITY_API_KEY') return 'mock-perplexity-key'; @@ -463,6 +466,68 @@ describe('Unified AI Services', () => { expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(1); }); + test('should use configured responseLanguage in system prompt', async () => { + mockGetResponseLanguage.mockReturnValue('中文'); + mockAnthropicProvider.generateText.mockResolvedValue('中文回复'); + + const params = { + role: 'main', + systemPrompt: 'You are an assistant', + prompt: 'Hello' + }; + await generateTextService(params); + + expect(mockAnthropicProvider.generateText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { + role: 'system', + content: expect.stringContaining('Always respond in 中文') + }, + { role: 'user', content: 'Hello' } + ] + }) + ); + expect(mockGetResponseLanguage).toHaveBeenCalledWith(fakeProjectRoot); + }); + + test('should pass custom projectRoot to getResponseLanguage', async () => { + const customRoot = '/custom/project/root'; + mockGetResponseLanguage.mockReturnValue('Español'); + mockAnthropicProvider.generateText.mockResolvedValue( + 'Respuesta en Español' + ); + + const params = { + role: 'main', + systemPrompt: 'You are an assistant', + prompt: 'Hello', + projectRoot: customRoot + }; + await generateTextService(params); + + expect(mockGetResponseLanguage).toHaveBeenCalledWith(customRoot); + expect(mockAnthropicProvider.generateText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { + role: 'system', + content: expect.stringContaining('Always respond in Español') + }, + { role: 'user', content: 'Hello' } + ] + }) + ); + }); + + // Add more tests for edge cases: + // - Missing API keys (should throw from _resolveApiKey) + // - Unsupported provider configured (should skip and log) + // - Missing provider/model config for a role (should skip and log) + // - Missing prompt + // - Different initial roles (research, fallback) + // - generateObjectService (mock schema, check object result) + // - streamTextService (more complex to test, might need stream helpers) test('should skip provider with missing API key and try next in fallback sequence', async () => { // Setup isApiKeySet to return false for anthropic but true for perplexity mockIsApiKeySet.mockImplementation((provider, session, root) => { diff --git a/tests/unit/config-manager.test.js b/tests/unit/config-manager.test.js index c7d14e63..51021afa 100644 --- a/tests/unit/config-manager.test.js +++ b/tests/unit/config-manager.test.js @@ -141,7 +141,8 @@ const DEFAULT_CONFIG = { defaultPriority: 'medium', projectName: 'Task Master', ollamaBaseURL: 'http://localhost:11434/api', - bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com' + bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com', + responseLanguage: 'English' } }; @@ -685,6 +686,82 @@ describe('Getter Functions', () => { expect(logLevel).toBe(VALID_CUSTOM_CONFIG.global.logLevel); }); + test('getResponseLanguage should return responseLanguage from config', () => { + // Arrange + // Prepare a config object with responseLanguage property for this test + const configWithLanguage = JSON.stringify({ + models: { + main: { provider: 'openai', modelId: 'gpt-4-turbo' } + }, + global: { + projectName: 'Test Project', + responseLanguage: '中文' + } + }); + + // Set up fs.readFileSync to return our test config + fsReadFileSyncSpy.mockImplementation((filePath) => { + if (filePath === MOCK_CONFIG_PATH) { + return configWithLanguage; + } + if (path.basename(filePath) === 'supported-models.json') { + return JSON.stringify({ + openai: [{ id: 'gpt-4-turbo' }] + }); + } + throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); + }); + + fsExistsSyncSpy.mockReturnValue(true); + + // Ensure getConfig returns new values instead of cached ones + configManager.getConfig(MOCK_PROJECT_ROOT, true); + + // Act + const responseLanguage = + configManager.getResponseLanguage(MOCK_PROJECT_ROOT); + + // Assert + expect(responseLanguage).toBe('中文'); + }); + + test('getResponseLanguage should return undefined when responseLanguage is not in config', () => { + // Arrange + const configWithoutLanguage = JSON.stringify({ + models: { + main: { provider: 'openai', modelId: 'gpt-4-turbo' } + }, + global: { + projectName: 'Test Project' + // No responseLanguage property + } + }); + + fsReadFileSyncSpy.mockImplementation((filePath) => { + if (filePath === MOCK_CONFIG_PATH) { + return configWithoutLanguage; + } + if (path.basename(filePath) === 'supported-models.json') { + return JSON.stringify({ + openai: [{ id: 'gpt-4-turbo' }] + }); + } + throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); + }); + + fsExistsSyncSpy.mockReturnValue(true); + + // Ensure getConfig returns new values instead of cached ones + configManager.getConfig(MOCK_PROJECT_ROOT, true); + + // Act + const responseLanguage = + configManager.getResponseLanguage(MOCK_PROJECT_ROOT); + + // Assert + expect(responseLanguage).toBe('English'); + }); + // Add more tests for other getters (getResearchProvider, getProjectName, etc.) });