From e3ed4d7c14b56894d7da675eb2b757423bea8f9d Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Tue, 12 Aug 2025 16:37:07 -0400 Subject: [PATCH] feat: CLI & MCP progress tracking for `parse-prd` command (#1048) * initial cutover * update log to debug * update tracker to pass units * update test to match new base tracker format * add streamTextService mocks * remove unused imports * Ensure the CLI waits for async main() completion * refactor to reduce code duplication * update comment * reuse function * ensure targetTag is defined in streaming mode * avoid throwing inside process.exit spy * check for null * remove reference to generate * fix formatting * fix textStream assignment * ensure no division by 0 * fix jest chalk mocks * refactor for maintainability * Improve bar chart calculation logic for consistent visual representation * use custom streaming error types; fix mocks * Update streamText extraction in parse-prd.js to match actual service response * remove check - doesn't belong here * update mocks * remove streaming test that wasn't really doing anything * add comment * make parsing logic more DRY * fix formatting * Fix textStream extraction to match actual service response * fix mock * Add a cleanup method to ensure proper resource disposal and prevent memory leaks * debounce progress updates to reduce UI flicker during rapid updates * Implement timeout protection for streaming operations (60-second timeout) with automatic fallback to non-streaming mode. * clear timeout properly * Add a maximum buffer size limit (1MB) to prevent unbounded memory growth with very large streaming responses. * fix formatting * remove duplicate mock * better docs * fix formatting * sanitize the dynamic property name * Fix incorrect remaining progress calculation * Use onError callback instead of console.warn * Remove unused chalk import * Add missing custom validator in fallback parsing configuration * add custom validator parameter in fallback parsing * chore: fix package-lock.json * chore: large code refactor * chore: increase timeout from 1 minute to 3 minutes * fix: refactor and fix streaming * Merge remote-tracking branch 'origin/next' into joedanz/parse-prd-progress * fix: cleanup and fix unit tests * chore: fix unit tests * chore: fix format * chore: run format * chore: fix weird CI unit test error * chore: fix format --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> --- .changeset/floppy-starts-find.md | 5 + .taskmaster/docs/test-prd.txt | 8 + .../src/core/direct-functions/parse-prd.js | 3 +- mcp-server/src/tools/parse-prd.js | 59 +- mcp-server/src/tools/utils.js | 74 +- package-lock.json | 562 ++--------- package.json | 2 + scripts/modules/ai-services-unified.js | 297 ++++-- scripts/modules/commands.js | 10 +- scripts/modules/prompt-manager.js | 2 +- scripts/modules/task-manager.js | 2 +- scripts/modules/task-manager/parse-prd.js | 395 -------- .../modules/task-manager/parse-prd/index.js | 3 + .../parse-prd/parse-prd-config.js | 105 ++ .../parse-prd/parse-prd-helpers.js | 384 ++++++++ .../parse-prd/parse-prd-non-streaming.js | 85 ++ .../parse-prd/parse-prd-streaming.js | 653 +++++++++++++ .../task-manager/parse-prd/parse-prd.js | 272 ++++++ src/ai-providers/base-provider.js | 41 + src/progress/base-progress-tracker.js | 298 ++++++ src/progress/cli-progress-factory.js | 115 +++ src/progress/parse-prd-tracker.js | 221 +++++ src/progress/progress-tracker-builder.js | 152 +++ src/progress/tracker-ui.js | 159 +++ src/ui/indicators.js | 273 ++++++ src/ui/parse-prd.js | 477 +++++++++ src/utils/format.js | 12 + src/utils/stream-parser.js | 490 ++++++++++ src/utils/timeout-manager.js | 189 ++++ tests/manual/progress/TESTING_GUIDE.md | 97 ++ tests/manual/progress/parse-prd-analysis.js | 334 +++++++ tests/manual/progress/test-parse-prd.js | 577 +++++++++++ tests/unit/ai-services-unified.test.js | 4 +- tests/unit/parse-prd.test.js | 494 +++++++++- .../progress/base-progress-tracker.test.js | 134 +++ .../analyze-task-complexity.test.js | 37 +- .../complexity-report-tag-isolation.test.js | 20 +- .../modules/task-manager/parse-prd.test.js | 916 +++++++++++++++++- tests/unit/ui/indicators.test.js | 169 ++++ 39 files changed, 6993 insertions(+), 1137 deletions(-) create mode 100644 .changeset/floppy-starts-find.md create mode 100644 .taskmaster/docs/test-prd.txt delete mode 100644 scripts/modules/task-manager/parse-prd.js create mode 100644 scripts/modules/task-manager/parse-prd/index.js create mode 100644 scripts/modules/task-manager/parse-prd/parse-prd-config.js create mode 100644 scripts/modules/task-manager/parse-prd/parse-prd-helpers.js create mode 100644 scripts/modules/task-manager/parse-prd/parse-prd-non-streaming.js create mode 100644 scripts/modules/task-manager/parse-prd/parse-prd-streaming.js create mode 100644 scripts/modules/task-manager/parse-prd/parse-prd.js create mode 100644 src/progress/base-progress-tracker.js create mode 100644 src/progress/cli-progress-factory.js create mode 100644 src/progress/parse-prd-tracker.js create mode 100644 src/progress/progress-tracker-builder.js create mode 100644 src/progress/tracker-ui.js create mode 100644 src/ui/indicators.js create mode 100644 src/ui/parse-prd.js create mode 100644 src/utils/format.js create mode 100644 src/utils/stream-parser.js create mode 100644 src/utils/timeout-manager.js create mode 100644 tests/manual/progress/TESTING_GUIDE.md create mode 100644 tests/manual/progress/parse-prd-analysis.js create mode 100644 tests/manual/progress/test-parse-prd.js create mode 100644 tests/unit/progress/base-progress-tracker.test.js create mode 100644 tests/unit/ui/indicators.test.js diff --git a/.changeset/floppy-starts-find.md b/.changeset/floppy-starts-find.md new file mode 100644 index 00000000..49713ab0 --- /dev/null +++ b/.changeset/floppy-starts-find.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": minor +--- + +Add CLI & MCP progress tracking for parse-prd command. diff --git a/.taskmaster/docs/test-prd.txt b/.taskmaster/docs/test-prd.txt new file mode 100644 index 00000000..53c6a1e7 --- /dev/null +++ b/.taskmaster/docs/test-prd.txt @@ -0,0 +1,8 @@ +Simple Todo App PRD + +Create a basic todo list application with the following features: +1. Add new todos +2. Mark todos as complete +3. Delete todos + +That's it. Keep it simple. \ No newline at end of file diff --git a/mcp-server/src/core/direct-functions/parse-prd.js b/mcp-server/src/core/direct-functions/parse-prd.js index 75a3337b..9bbeceae 100644 --- a/mcp-server/src/core/direct-functions/parse-prd.js +++ b/mcp-server/src/core/direct-functions/parse-prd.js @@ -32,7 +32,7 @@ import { TASKMASTER_TASKS_FILE } from '../../../../src/constants/paths.js'; * @returns {Promise} - Result object with success status and data/error information. */ export async function parsePRDDirect(args, log, context = {}) { - const { session } = context; + const { session, reportProgress } = context; // Extract projectRoot from args const { input: inputArg, @@ -164,6 +164,7 @@ export async function parsePRDDirect(args, log, context = {}) { force, append, research, + reportProgress, commandName: 'parse-prd', outputType: 'mcp' }, diff --git a/mcp-server/src/tools/parse-prd.js b/mcp-server/src/tools/parse-prd.js index 6161d8f1..ebfabd1b 100644 --- a/mcp-server/src/tools/parse-prd.js +++ b/mcp-server/src/tools/parse-prd.js @@ -7,7 +7,8 @@ import { z } from 'zod'; import { handleApiResult, withNormalizedProjectRoot, - createErrorResponse + createErrorResponse, + checkProgressCapability } from './utils.js'; import { parsePRDDirect } from '../core/task-master-core.js'; import { @@ -64,31 +65,37 @@ export function registerParsePRDTool(server) { .optional() .describe('Append generated tasks to existing file.') }), - execute: withNormalizedProjectRoot(async (args, { log, session }) => { - try { - const resolvedTag = resolveTag({ - projectRoot: args.projectRoot, - tag: args.tag - }); - const result = await parsePRDDirect( - { - ...args, - tag: resolvedTag - }, - log, - { session } - ); - return handleApiResult( - result, - log, - 'Error parsing PRD', - undefined, - args.projectRoot - ); - } catch (error) { - log.error(`Error in parse_prd: ${error.message}`); - return createErrorResponse(`Failed to parse PRD: ${error.message}`); + execute: withNormalizedProjectRoot( + async (args, { log, session, reportProgress }) => { + try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + const progressCapability = checkProgressCapability( + reportProgress, + log + ); + const result = await parsePRDDirect( + { + ...args, + tag: resolvedTag + }, + log, + { session, reportProgress: progressCapability } + ); + return handleApiResult( + result, + log, + 'Error parsing PRD', + undefined, + args.projectRoot + ); + } catch (error) { + log.error(`Error in parse_prd: ${error.message}`); + return createErrorResponse(`Failed to parse PRD: ${error.message}`); + } } - }) + ) }); } diff --git a/mcp-server/src/tools/utils.js b/mcp-server/src/tools/utils.js index 7fff491a..9163e6bc 100644 --- a/mcp-server/src/tools/utils.js +++ b/mcp-server/src/tools/utils.js @@ -778,6 +778,77 @@ function withNormalizedProjectRoot(executeFn) { }; } +/** + * Checks progress reporting capability and returns the validated function or undefined. + * + * STANDARD PATTERN for AI-powered, long-running operations (parse-prd, expand-task, expand-all, analyze): + * + * This helper should be used as the first step in any MCP tool that performs long-running + * AI operations. It validates the availability of progress reporting and provides consistent + * logging about the capability status. + * + * Operations that should use this pattern: + * - parse-prd: Parsing PRD documents with AI + * - expand-task: Expanding tasks into subtasks + * - expand-all: Expanding all tasks in batch + * - analyze-complexity: Analyzing task complexity + * - update-task: Updating tasks with AI assistance + * - add-task: Creating new tasks with AI + * - Any operation that makes AI service calls + * + * @example Basic usage in a tool's execute function: + * ```javascript + * import { checkProgressCapability } from './utils.js'; + * + * async execute(args, context) { + * const { log, reportProgress, session } = context; + * + * // Always validate progress capability first + * const progressCapability = checkProgressCapability(reportProgress, log); + * + * // Pass to direct function - it handles undefined gracefully + * const result = await expandTask(taskId, numSubtasks, { + * session, + * reportProgress: progressCapability, + * mcpLog: log + * }); + * } + * ``` + * + * @example With progress reporting available: + * ```javascript + * // When reportProgress is available, users see real-time updates: + * // "Starting PRD analysis (Input: 5432 tokens)..." + * // "Task 1/10 - Implement user authentication" + * // "Task 2/10 - Create database schema" + * // "Task Generation Completed | Tokens: 5432/1234" + * ``` + * + * @example Without progress reporting (graceful degradation): + * ```javascript + * // When reportProgress is not available: + * // - Operation runs normally without progress updates + * // - Debug log: "reportProgress not available - operation will run without progress updates" + * // - User gets final result after completion + * ``` + * + * @param {Function|undefined} reportProgress - The reportProgress function from MCP context. + * Expected signature: async (progress: {progress: number, total: number, message: string}) => void + * @param {Object} log - Logger instance with debug, info, warn, error methods + * @returns {Function|undefined} The validated reportProgress function or undefined if not available + */ +function checkProgressCapability(reportProgress, log) { + // Validate that reportProgress is available for long-running operations + if (typeof reportProgress !== 'function') { + log.debug( + 'reportProgress not available - operation will run without progress updates' + ); + return undefined; + } + + return reportProgress; +} + // Ensure all functions are exported export { getProjectRoot, @@ -792,5 +863,6 @@ export { createLogWrapper, normalizeProjectRoot, getRawProjectRootFromSession, - withNormalizedProjectRoot + withNormalizedProjectRoot, + checkProgressCapability }; diff --git a/package-lock.json b/package-lock.json index 5a6df7c6..2bcc7263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,12 +27,14 @@ "@aws-sdk/credential-providers": "^3.817.0", "@inquirer/search": "^3.0.15", "@openrouter/ai-sdk-provider": "^0.4.5", + "@streamparser/json": "^0.0.22", "ai": "^4.3.10", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", + "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "commander": "^11.1.0", "cors": "^2.8.5", @@ -4153,9 +4155,9 @@ } }, "node_modules/@google/gemini-cli-core/node_modules/dotenv": { - "version": "17.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", - "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==", + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", "license": "BSD-2-Clause", "optional": true, "engines": { @@ -4229,9 +4231,9 @@ } }, "node_modules/@google/genai": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.10.0.tgz", - "integrity": "sha512-PR4tLuiIFMrpAiiCko2Z16ydikFsPF1c5TBfI64hlZcv3xBEApSCceLuDYu1pNMq2SkNh4r66J4AG+ZexBnMLw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.11.0.tgz", + "integrity": "sha512-4XFAHCvU91ewdWOU3RUdSeXpDuZRJHNYLqT9LKw7WqPjRQcEJvVU+VOU49ocruaSp8VuLKMecl0iadlQK+Zgfw==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -9622,6 +9624,12 @@ "node": "^12.20 || >=14.13" } }, + "node_modules/@streamparser/json": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.22.tgz", + "integrity": "sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -9680,23 +9688,6 @@ "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@tailwindcss/oxide-darwin-arm64": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", @@ -9714,189 +9705,6 @@ "node": ">= 10" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.11", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@tailwindcss/postcss": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", @@ -10638,34 +10446,6 @@ "@vscode/vsce-sign-win32-x64": "2.0.5" } }, - "node_modules/@vscode/vsce-sign-alpine-arm64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.5.tgz", - "integrity": "sha512-XVmnF40APwRPXSLYA28Ye+qWxB25KhSVpF2eZVtVOs6g7fkpOxsVnpRU1Bz2xG4ySI79IRuapDJoAQFkoOgfdQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "alpine" - ] - }, - "node_modules/@vscode/vsce-sign-alpine-x64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.5.tgz", - "integrity": "sha512-JuxY3xcquRsOezKq6PEHwCgd1rh1GnhyH6urVEWUzWn1c1PC4EOoyffMD+zLZtFuZF5qR1I0+cqDRNKyPvpK7Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "alpine" - ] - }, "node_modules/@vscode/vsce-sign-darwin-arm64": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.5.tgz", @@ -10680,90 +10460,6 @@ "darwin" ] }, - "node_modules/@vscode/vsce-sign-darwin-x64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.5.tgz", - "integrity": "sha512-ma9JDC7FJ16SuPXlLKkvOD2qLsmW/cKfqK4zzM2iJE1PbckF3BlR08lYqHV89gmuoTpYB55+z8Y5Fz4wEJBVDA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@vscode/vsce-sign-linux-arm": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.5.tgz", - "integrity": "sha512-cdCwtLGmvC1QVrkIsyzv01+o9eR+wodMJUZ9Ak3owhcGxPRB53/WvrDHAFYA6i8Oy232nuen1YqWeEohqBuSzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-linux-arm64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.5.tgz", - "integrity": "sha512-Hr1o0veBymg9SmkCqYnfaiUnes5YK6k/lKFA5MhNmiEN5fNqxyPUCdRZMFs3Ajtx2OFW4q3KuYVRwGA7jdLo7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-linux-x64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.5.tgz", - "integrity": "sha512-XLT0gfGMcxk6CMRLDkgqEPTyG8Oa0OFe1tPv2RVbphSOjFWJwZgK3TYWx39i/7gqpDHlax0AP6cgMygNJrA6zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-win32-arm64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.5.tgz", - "integrity": "sha512-hco8eaoTcvtmuPhavyCZhrk5QIcLiyAUhEso87ApAWDllG7djIrWiOCtqn48k4pHz+L8oCQlE0nwNHfcYcxOPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vscode/vsce-sign-win32-x64": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.5.tgz", - "integrity": "sha512-1ixKFGM2FwM+6kQS2ojfY3aAelICxjiCzeg4nTHpkeU1Tfs4RC+lVLrgq5NwcBC7ZLr6UfY3Ct3D6suPeOf7BQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@vscode/vsce/node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -12883,6 +12579,47 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-progress/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cli-progress/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -19956,195 +19693,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", diff --git a/package.json b/package.json index 5b6ae82e..c5ae9234 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,14 @@ "@aws-sdk/credential-providers": "^3.817.0", "@inquirer/search": "^3.0.15", "@openrouter/ai-sdk-provider": "^0.4.5", + "@streamparser/json": "^0.0.22", "ai": "^4.3.10", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", + "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "commander": "^11.1.0", "cors": "^2.8.5", diff --git a/scripts/modules/ai-services-unified.js b/scripts/modules/ai-services-unified.js index 0df4bd1a..3ce685e4 100644 --- a/scripts/modules/ai-services-unified.js +++ b/scripts/modules/ai-services-unified.js @@ -91,93 +91,117 @@ function _getProvider(providerName) { // Helper function to get cost for a specific model function _getCostForModel(providerName, modelId) { + const DEFAULT_COST = { inputCost: 0, outputCost: 0, currency: 'USD' }; + if (!MODEL_MAP || !MODEL_MAP[providerName]) { log( 'warn', `Provider "${providerName}" not found in MODEL_MAP. Cannot determine cost for model ${modelId}.` ); - return { inputCost: 0, outputCost: 0, currency: 'USD' }; // Default to zero cost + return DEFAULT_COST; } const modelData = MODEL_MAP[providerName].find((m) => m.id === modelId); - if (!modelData || !modelData.cost_per_1m_tokens) { + if (!modelData?.cost_per_1m_tokens) { log( 'debug', `Cost data not found for model "${modelId}" under provider "${providerName}". Assuming zero cost.` ); - return { inputCost: 0, outputCost: 0, currency: 'USD' }; // Default to zero cost + return DEFAULT_COST; } - // Ensure currency is part of the returned object, defaulting if not present - const currency = modelData.cost_per_1m_tokens.currency || 'USD'; - + const costs = modelData.cost_per_1m_tokens; return { - inputCost: modelData.cost_per_1m_tokens.input || 0, - outputCost: modelData.cost_per_1m_tokens.output || 0, - currency: currency + inputCost: costs.input || 0, + outputCost: costs.output || 0, + currency: costs.currency || 'USD' }; } +/** + * Calculate cost from token counts and cost per million + * @param {number} inputTokens - Number of input tokens + * @param {number} outputTokens - Number of output tokens + * @param {number} inputCost - Cost per million input tokens + * @param {number} outputCost - Cost per million output tokens + * @returns {number} Total calculated cost + */ +function _calculateCost(inputTokens, outputTokens, inputCost, outputCost) { + const calculatedCost = + ((inputTokens || 0) / 1_000_000) * inputCost + + ((outputTokens || 0) / 1_000_000) * outputCost; + return parseFloat(calculatedCost.toFixed(6)); +} + // Helper function to get tag information for responses function _getTagInfo(projectRoot) { + const DEFAULT_TAG_INFO = { currentTag: 'master', availableTags: ['master'] }; + try { if (!projectRoot) { - return { currentTag: 'master', availableTags: ['master'] }; + return DEFAULT_TAG_INFO; } - const currentTag = getCurrentTag(projectRoot); + const currentTag = getCurrentTag(projectRoot) || 'master'; + const availableTags = _readAvailableTags(projectRoot); - // Read available tags from tasks.json - let availableTags = ['master']; // Default fallback - try { - const path = require('path'); - const fs = require('fs'); - const tasksPath = path.join( - projectRoot, - '.taskmaster', - 'tasks', - 'tasks.json' - ); - - if (fs.existsSync(tasksPath)) { - const tasksData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); - if (tasksData && typeof tasksData === 'object') { - // Check if it's tagged format (has tag-like keys with tasks arrays) - const potentialTags = Object.keys(tasksData).filter( - (key) => - tasksData[key] && - typeof tasksData[key] === 'object' && - Array.isArray(tasksData[key].tasks) - ); - - if (potentialTags.length > 0) { - availableTags = potentialTags; - } - } - } - } catch (readError) { - // Silently fall back to default if we can't read tasks file - if (getDebugFlag()) { - log( - 'debug', - `Could not read tasks file for available tags: ${readError.message}` - ); - } - } - - return { - currentTag: currentTag || 'master', - availableTags: availableTags - }; + return { currentTag, availableTags }; } catch (error) { if (getDebugFlag()) { log('debug', `Error getting tag information: ${error.message}`); } - return { currentTag: 'master', availableTags: ['master'] }; + return DEFAULT_TAG_INFO; } } +// Extract method for reading available tags +function _readAvailableTags(projectRoot) { + const DEFAULT_TAGS = ['master']; + + try { + const path = require('path'); + const fs = require('fs'); + const tasksPath = path.join( + projectRoot, + '.taskmaster', + 'tasks', + 'tasks.json' + ); + + if (!fs.existsSync(tasksPath)) { + return DEFAULT_TAGS; + } + + const tasksData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); + if (!tasksData || typeof tasksData !== 'object') { + return DEFAULT_TAGS; + } + + // Check if it's tagged format (has tag-like keys with tasks arrays) + const potentialTags = Object.keys(tasksData).filter((key) => + _isValidTaggedTask(tasksData[key]) + ); + + return potentialTags.length > 0 ? potentialTags : DEFAULT_TAGS; + } catch (readError) { + if (getDebugFlag()) { + log( + 'debug', + `Could not read tasks file for available tags: ${readError.message}` + ); + } + return DEFAULT_TAGS; + } +} + +// Helper to validate tagged task structure +function _isValidTaggedTask(taskData) { + return ( + taskData && typeof taskData === 'object' && Array.isArray(taskData.tasks) + ); +} + // --- Configuration for Retries --- const MAX_RETRIES = 2; const INITIAL_RETRY_DELAY_MS = 1000; @@ -244,6 +268,65 @@ function _extractErrorMessage(error) { } } +/** + * Get role configuration (provider and model) based on role type + * @param {string} role - The role ('main', 'research', 'fallback') + * @param {string} projectRoot - Project root path + * @returns {Object|null} Configuration object with provider and modelId + */ +function _getRoleConfiguration(role, projectRoot) { + const roleConfigs = { + main: { + provider: getMainProvider(projectRoot), + modelId: getMainModelId(projectRoot) + }, + research: { + provider: getResearchProvider(projectRoot), + modelId: getResearchModelId(projectRoot) + }, + fallback: { + provider: getFallbackProvider(projectRoot), + modelId: getFallbackModelId(projectRoot) + } + }; + + return roleConfigs[role] || null; +} + +/** + * Get Vertex AI specific configuration + * @param {string} projectRoot - Project root path + * @param {Object} session - Session object + * @returns {Object} Vertex AI configuration parameters + */ +function _getVertexConfiguration(projectRoot, session) { + const projectId = + getVertexProjectId(projectRoot) || + resolveEnvVariable('VERTEX_PROJECT_ID', session, projectRoot); + + const location = + getVertexLocation(projectRoot) || + resolveEnvVariable('VERTEX_LOCATION', session, projectRoot) || + 'us-central1'; + + const credentialsPath = resolveEnvVariable( + 'GOOGLE_APPLICATION_CREDENTIALS', + session, + projectRoot + ); + + log( + 'debug', + `Using Vertex AI configuration: Project ID=${projectId}, Location=${location}` + ); + + return { + projectId, + location, + ...(credentialsPath && { credentials: { credentialsFromEnv: true } }) + }; +} + /** * Internal helper to resolve the API key for a given provider. * @param {string} providerName - The name of the provider (lowercase). @@ -424,18 +507,13 @@ async function _unifiedServiceRunner(serviceType, params) { let telemetryData = null; try { - log('info', `New AI service call with role: ${currentRole}`); + log('debug', `New AI service call with role: ${currentRole}`); - if (currentRole === 'main') { - providerName = getMainProvider(effectiveProjectRoot); - modelId = getMainModelId(effectiveProjectRoot); - } else if (currentRole === 'research') { - providerName = getResearchProvider(effectiveProjectRoot); - modelId = getResearchModelId(effectiveProjectRoot); - } else if (currentRole === 'fallback') { - providerName = getFallbackProvider(effectiveProjectRoot); - modelId = getFallbackModelId(effectiveProjectRoot); - } else { + const roleConfig = _getRoleConfiguration( + currentRole, + effectiveProjectRoot + ); + if (!roleConfig) { log( 'error', `Unknown role encountered in _unifiedServiceRunner: ${currentRole}` @@ -444,6 +522,8 @@ async function _unifiedServiceRunner(serviceType, params) { lastError || new Error(`Unknown AI role specified: ${currentRole}`); continue; } + providerName = roleConfig.provider; + modelId = roleConfig.modelId; if (!providerName || !modelId) { log( @@ -517,41 +597,9 @@ async function _unifiedServiceRunner(serviceType, params) { // Handle Vertex AI specific configuration if (providerName?.toLowerCase() === 'vertex') { - // Get Vertex project ID and location - const projectId = - getVertexProjectId(effectiveProjectRoot) || - resolveEnvVariable( - 'VERTEX_PROJECT_ID', - session, - effectiveProjectRoot - ); - - const location = - getVertexLocation(effectiveProjectRoot) || - resolveEnvVariable( - 'VERTEX_LOCATION', - session, - effectiveProjectRoot - ) || - 'us-central1'; - - // Get credentials path if available - const credentialsPath = resolveEnvVariable( - 'GOOGLE_APPLICATION_CREDENTIALS', - session, - effectiveProjectRoot - ); - - // Add Vertex-specific parameters - providerSpecificParams = { - projectId, - location, - ...(credentialsPath && { credentials: { credentialsFromEnv: true } }) - }; - - log( - 'debug', - `Using Vertex AI configuration: Project ID=${projectId}, Location=${location}` + providerSpecificParams = _getVertexConfiguration( + effectiveProjectRoot, + session ); } @@ -594,7 +642,8 @@ async function _unifiedServiceRunner(serviceType, params) { temperature: roleParams.temperature, messages, ...(baseURL && { baseURL }), - ...(serviceType === 'generateObject' && { schema, objectName }), + ...((serviceType === 'generateObject' || + serviceType === 'streamObject') && { schema, objectName }), ...providerSpecificParams, ...restApiParams }; @@ -635,7 +684,10 @@ async function _unifiedServiceRunner(serviceType, params) { finalMainResult = providerResponse.text; } else if (serviceType === 'generateObject') { finalMainResult = providerResponse.object; - } else if (serviceType === 'streamText') { + } else if ( + serviceType === 'streamText' || + serviceType === 'streamObject' + ) { finalMainResult = providerResponse; } else { log( @@ -651,7 +703,9 @@ async function _unifiedServiceRunner(serviceType, params) { return { mainResult: finalMainResult, telemetryData: telemetryData, - tagInfo: tagInfo + tagInfo: tagInfo, + providerName: providerName, + modelId: modelId }; } catch (error) { const cleanMessage = _extractErrorMessage(error); @@ -732,6 +786,31 @@ async function streamTextService(params) { return _unifiedServiceRunner('streamText', combinedParams); } +/** + * Unified service function for streaming structured objects. + * Uses Vercel AI SDK's streamObject for proper JSON streaming. + * + * @param {object} params - Parameters for the service call. + * @param {string} params.role - The initial client role ('main', 'research', 'fallback'). + * @param {object} [params.session=null] - Optional MCP session object. + * @param {string} [params.projectRoot=null] - Optional project root path for .env fallback. + * @param {import('zod').ZodSchema} params.schema - The Zod schema for the expected object. + * @param {string} params.prompt - The prompt for the AI. + * @param {string} [params.systemPrompt] - Optional system prompt. + * @param {string} params.commandName - Name of the command invoking the service. + * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. + * @returns {Promise} Result object containing the stream and usage data. + */ +async function streamObjectService(params) { + const defaults = { outputType: 'cli' }; + const combinedParams = { ...defaults, ...params }; + // Stream object requires a schema + if (!combinedParams.schema) { + throw new Error('streamObjectService requires a schema parameter'); + } + return _unifiedServiceRunner('streamObject', combinedParams); +} + /** * Unified service function for generating structured objects. * Handles client retrieval, retries, and fallback sequence. @@ -792,9 +871,12 @@ async function logAiUsage({ modelId ); - const totalCost = - ((inputTokens || 0) / 1_000_000) * inputCost + - ((outputTokens || 0) / 1_000_000) * outputCost; + const totalCost = _calculateCost( + inputTokens, + outputTokens, + inputCost, + outputCost + ); const telemetryData = { timestamp, @@ -805,7 +887,7 @@ async function logAiUsage({ inputTokens: inputTokens || 0, outputTokens: outputTokens || 0, totalTokens, - totalCost: parseFloat(totalCost.toFixed(6)), + totalCost, currency // Add currency to the telemetry data }; @@ -828,6 +910,7 @@ async function logAiUsage({ export { generateTextService, streamTextService, + streamObjectService, generateObjectService, logAiUsage }; diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index abda5271..bc6f82b6 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -912,8 +912,6 @@ function registerCommands(programInstance) { return true; } - let spinner; - try { if (!(await confirmOverwriteIfNeeded())) return; @@ -930,7 +928,6 @@ function registerCommands(programInstance) { ); } - spinner = ora('Parsing PRD and generating tasks...\n').start(); // Handle case where getTasksPath() returns null const outputPath = taskMaster.getTasksPath() || @@ -942,13 +939,8 @@ function registerCommands(programInstance) { projectRoot: taskMaster.getProjectRoot(), tag: tag }); - spinner.succeed('Tasks generated successfully!'); } catch (error) { - if (spinner) { - spinner.fail(`Error parsing PRD: ${error.message}`); - } else { - console.error(chalk.red(`Error parsing PRD: ${error.message}`)); - } + console.error(chalk.red(`Error parsing PRD: ${error.message}`)); process.exit(1); } }); diff --git a/scripts/modules/prompt-manager.js b/scripts/modules/prompt-manager.js index 436a02bb..9fe9e583 100644 --- a/scripts/modules/prompt-manager.js +++ b/scripts/modules/prompt-manager.js @@ -36,7 +36,7 @@ export class PromptManager { const schema = JSON.parse(schemaContent); this.validatePrompt = this.ajv.compile(schema); - log('info', '✓ JSON schema validation enabled'); + log('debug', '✓ JSON schema validation enabled'); } catch (error) { log('warn', `⚠ Schema validation disabled: ${error.message}`); this.validatePrompt = () => true; // Fallback to no validation diff --git a/scripts/modules/task-manager.js b/scripts/modules/task-manager.js index 3291fecb..32a38ebf 100644 --- a/scripts/modules/task-manager.js +++ b/scripts/modules/task-manager.js @@ -4,7 +4,7 @@ */ import { findTaskById } from './utils.js'; -import parsePRD from './task-manager/parse-prd.js'; +import parsePRD from './task-manager/parse-prd/index.js'; import updateTasks from './task-manager/update-tasks.js'; import updateTaskById from './task-manager/update-task-by-id.js'; import generateTaskFiles from './task-manager/generate-task-files.js'; diff --git a/scripts/modules/task-manager/parse-prd.js b/scripts/modules/task-manager/parse-prd.js deleted file mode 100644 index e1d2a196..00000000 --- a/scripts/modules/task-manager/parse-prd.js +++ /dev/null @@ -1,395 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import chalk from 'chalk'; -import boxen from 'boxen'; -import { z } from 'zod'; - -import { - log, - writeJSON, - enableSilentMode, - disableSilentMode, - isSilentMode, - readJSON, - findTaskById, - ensureTagMetadata, - getCurrentTag -} from '../utils.js'; - -import { generateObjectService } from '../ai-services-unified.js'; -import { - getDebugFlag, - getMainProvider, - getResearchProvider, - getDefaultPriority -} from '../config-manager.js'; -import { getPromptManager } from '../prompt-manager.js'; -import { displayAiUsageSummary } from '../ui.js'; -import { CUSTOM_PROVIDERS } from '../../../src/constants/providers.js'; - -// Define the Zod schema for a SINGLE task object -const prdSingleTaskSchema = z.object({ - id: z.number(), - title: z.string().min(1), - description: z.string().min(1), - details: z.string(), - testStrategy: z.string(), - priority: z.enum(['high', 'medium', 'low']), - dependencies: z.array(z.number()), - status: z.string() -}); - -// Define the Zod schema for the ENTIRE expected AI response object -const prdResponseSchema = z.object({ - tasks: z.array(prdSingleTaskSchema), - metadata: z.object({ - projectName: z.string(), - totalTasks: z.number(), - sourceFile: z.string(), - generatedAt: z.string() - }) -}); - -/** - * Parse a PRD file and generate tasks - * @param {string} prdPath - Path to the PRD file - * @param {string} tasksPath - Path to the tasks.json file - * @param {number} numTasks - Number of tasks to generate - * @param {Object} options - Additional options - * @param {boolean} [options.force=false] - Whether to overwrite existing tasks.json. - * @param {boolean} [options.append=false] - Append to existing tasks file. - * @param {boolean} [options.research=false] - Use research model for enhanced PRD analysis. - * @param {Object} [options.reportProgress] - Function to report progress (optional, likely unused). - * @param {Object} [options.mcpLog] - MCP logger object (optional). - * @param {Object} [options.session] - Session object from MCP server (optional). - * @param {string} [options.projectRoot] - Project root path (for MCP/env fallback). - * @param {string} [options.tag] - Target tag for task generation. - * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). - */ -async function parsePRD(prdPath, tasksPath, numTasks, options = {}) { - const { - reportProgress, - mcpLog, - session, - projectRoot, - force = false, - append = false, - research = false, - tag - } = options; - const isMCP = !!mcpLog; - const outputFormat = isMCP ? 'json' : 'text'; - - // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = tag; - - const logFn = mcpLog - ? mcpLog - : { - // Wrapper for CLI - info: (...args) => log('info', ...args), - warn: (...args) => log('warn', ...args), - error: (...args) => log('error', ...args), - debug: (...args) => log('debug', ...args), - success: (...args) => log('success', ...args) - }; - - // Create custom reporter using logFn - const report = (message, level = 'info') => { - // Check logFn directly - if (logFn && typeof logFn[level] === 'function') { - logFn[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Fallback to original log only if necessary and in CLI text mode - log(level, message); - } - }; - - report( - `Parsing PRD file: ${prdPath}, Force: ${force}, Append: ${append}, Research: ${research}` - ); - - let existingTasks = []; - let nextId = 1; - let aiServiceResponse = null; - - try { - // Check if there are existing tasks in the target tag - let hasExistingTasksInTag = false; - if (fs.existsSync(tasksPath)) { - try { - // Read the entire file to check if the tag exists - const existingFileContent = fs.readFileSync(tasksPath, 'utf8'); - const allData = JSON.parse(existingFileContent); - - // Check if the target tag exists and has tasks - if ( - allData[targetTag] && - Array.isArray(allData[targetTag].tasks) && - allData[targetTag].tasks.length > 0 - ) { - hasExistingTasksInTag = true; - existingTasks = allData[targetTag].tasks; - nextId = Math.max(...existingTasks.map((t) => t.id || 0)) + 1; - } - } catch (error) { - // If we can't read the file or parse it, assume no existing tasks in this tag - hasExistingTasksInTag = false; - } - } - - // Handle file existence and overwrite/append logic based on target tag - if (hasExistingTasksInTag) { - if (append) { - report( - `Append mode enabled. Found ${existingTasks.length} existing tasks in tag '${targetTag}'. Next ID will be ${nextId}.`, - 'info' - ); - } else if (!force) { - // Not appending and not forcing overwrite, and there are existing tasks in the target tag - const overwriteError = new Error( - `Tag '${targetTag}' already contains ${existingTasks.length} tasks. Use --force to overwrite or --append to add to existing tasks.` - ); - report(overwriteError.message, 'error'); - if (outputFormat === 'text') { - console.error(chalk.red(overwriteError.message)); - } - throw overwriteError; - } else { - // Force overwrite is true - report( - `Force flag enabled. Overwriting existing tasks in tag '${targetTag}'.`, - 'info' - ); - } - } else { - // No existing tasks in target tag, proceed without confirmation - report( - `Tag '${targetTag}' is empty or doesn't exist. Creating/updating tag with new tasks.`, - 'info' - ); - } - - report(`Reading PRD content from ${prdPath}`, 'info'); - const prdContent = fs.readFileSync(prdPath, 'utf8'); - if (!prdContent) { - throw new Error(`Input file ${prdPath} is empty or could not be read.`); - } - - // Load prompts using PromptManager - const promptManager = getPromptManager(); - - // Get defaultTaskPriority from config - const defaultTaskPriority = getDefaultPriority(projectRoot) || 'medium'; - - // Check if Claude Code is being used as the provider - const currentProvider = research - ? getResearchProvider(projectRoot) - : getMainProvider(projectRoot); - const isClaudeCode = currentProvider === CUSTOM_PROVIDERS.CLAUDE_CODE; - - const { systemPrompt, userPrompt } = await promptManager.loadPrompt( - 'parse-prd', - { - research, - numTasks, - nextId, - prdContent, - prdPath, - defaultTaskPriority, - isClaudeCode, - projectRoot: projectRoot || '' - } - ); - - // Call the unified AI service - report( - `Calling AI service to generate tasks from PRD${research ? ' with research-backed analysis' : ''}...`, - 'info' - ); - - // Call generateObjectService with the CORRECT schema and additional telemetry params - aiServiceResponse = await generateObjectService({ - role: research ? 'research' : 'main', // Use research role if flag is set - session: session, - projectRoot: projectRoot, - schema: prdResponseSchema, - objectName: 'tasks_data', - systemPrompt: systemPrompt, - prompt: userPrompt, - commandName: 'parse-prd', - outputType: isMCP ? 'mcp' : 'cli' - }); - - // Create the directory if it doesn't exist - const tasksDir = path.dirname(tasksPath); - if (!fs.existsSync(tasksDir)) { - fs.mkdirSync(tasksDir, { recursive: true }); - } - logFn.success( - `Successfully parsed PRD via AI service${research ? ' with research-backed analysis' : ''}.` - ); - - // Validate and Process Tasks - // const generatedData = aiServiceResponse?.mainResult?.object; - - // Robustly get the actual AI-generated object - let generatedData = null; - if (aiServiceResponse?.mainResult) { - if ( - typeof aiServiceResponse.mainResult === 'object' && - aiServiceResponse.mainResult !== null && - 'tasks' in aiServiceResponse.mainResult - ) { - // If mainResult itself is the object with a 'tasks' property - generatedData = aiServiceResponse.mainResult; - } else if ( - typeof aiServiceResponse.mainResult.object === 'object' && - aiServiceResponse.mainResult.object !== null && - 'tasks' in aiServiceResponse.mainResult.object - ) { - // If mainResult.object is the object with a 'tasks' property - generatedData = aiServiceResponse.mainResult.object; - } - } - - if (!generatedData || !Array.isArray(generatedData.tasks)) { - logFn.error( - `Internal Error: generateObjectService returned unexpected data structure: ${JSON.stringify(generatedData)}` - ); - throw new Error( - 'AI service returned unexpected data structure after validation.' - ); - } - - let currentId = nextId; - const taskMap = new Map(); - const processedNewTasks = generatedData.tasks.map((task) => { - const newId = currentId++; - taskMap.set(task.id, newId); - return { - ...task, - id: newId, - status: task.status || 'pending', - priority: task.priority || 'medium', - dependencies: Array.isArray(task.dependencies) ? task.dependencies : [], - subtasks: [], - // Ensure all required fields have values (even if empty strings) - title: task.title || '', - description: task.description || '', - details: task.details || '', - testStrategy: task.testStrategy || '' - }; - }); - - // Remap dependencies for the NEWLY processed tasks - processedNewTasks.forEach((task) => { - task.dependencies = task.dependencies - .map((depId) => taskMap.get(depId)) // Map old AI ID to new sequential ID - .filter( - (newDepId) => - newDepId != null && // Must exist - newDepId < task.id && // Must be a lower ID (could be existing or newly generated) - (findTaskById(existingTasks, newDepId) || // Check if it exists in old tasks OR - processedNewTasks.some((t) => t.id === newDepId)) // check if it exists in new tasks - ); - }); - - const finalTasks = append - ? [...existingTasks, ...processedNewTasks] - : processedNewTasks; - - // Read the existing file to preserve other tags - let outputData = {}; - if (fs.existsSync(tasksPath)) { - try { - const existingFileContent = fs.readFileSync(tasksPath, 'utf8'); - outputData = JSON.parse(existingFileContent); - } catch (error) { - // If we can't read the existing file, start with empty object - outputData = {}; - } - } - - // Update only the target tag, preserving other tags - outputData[targetTag] = { - tasks: finalTasks, - metadata: { - created: - outputData[targetTag]?.metadata?.created || new Date().toISOString(), - updated: new Date().toISOString(), - description: `Tasks for ${targetTag} context` - } - }; - - // Ensure the target tag has proper metadata - ensureTagMetadata(outputData[targetTag], { - description: `Tasks for ${targetTag} context` - }); - - // Write the complete data structure back to the file - fs.writeFileSync(tasksPath, JSON.stringify(outputData, null, 2)); - report( - `Successfully ${append ? 'appended' : 'generated'} ${processedNewTasks.length} tasks in ${tasksPath}${research ? ' with research-backed analysis' : ''}`, - 'success' - ); - - // Generate markdown task files after writing tasks.json - // await generateTaskFiles(tasksPath, path.dirname(tasksPath), { mcpLog }); - - // Handle CLI output (e.g., success message) - if (outputFormat === 'text') { - console.log( - boxen( - chalk.green( - `Successfully generated ${processedNewTasks.length} new tasks${research ? ' with research-backed analysis' : ''}. Total tasks in ${tasksPath}: ${finalTasks.length}` - ), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - ) - ); - - console.log( - boxen( - chalk.white.bold('Next Steps:') + - '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=')} to break down a task into subtasks`, - { - padding: 1, - borderColor: 'cyan', - borderStyle: 'round', - margin: { top: 1 } - } - ) - ); - - if (aiServiceResponse && aiServiceResponse.telemetryData) { - displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); - } - } - - // Return telemetry data - return { - success: true, - tasksPath, - telemetryData: aiServiceResponse?.telemetryData, - tagInfo: aiServiceResponse?.tagInfo - }; - } catch (error) { - report(`Error parsing PRD: ${error.message}`, 'error'); - - // Only show error UI for text output (CLI) - if (outputFormat === 'text') { - console.error(chalk.red(`Error: ${error.message}`)); - - if (getDebugFlag(projectRoot)) { - // Use projectRoot for debug flag check - console.error(error); - } - } - - throw error; // Always re-throw for proper error handling - } -} - -export default parsePRD; diff --git a/scripts/modules/task-manager/parse-prd/index.js b/scripts/modules/task-manager/parse-prd/index.js new file mode 100644 index 00000000..a2286c3d --- /dev/null +++ b/scripts/modules/task-manager/parse-prd/index.js @@ -0,0 +1,3 @@ +// Main entry point for parse-prd module +export { default } from './parse-prd.js'; +export { default as parsePRD } from './parse-prd.js'; diff --git a/scripts/modules/task-manager/parse-prd/parse-prd-config.js b/scripts/modules/task-manager/parse-prd/parse-prd-config.js new file mode 100644 index 00000000..e88a098d --- /dev/null +++ b/scripts/modules/task-manager/parse-prd/parse-prd-config.js @@ -0,0 +1,105 @@ +/** + * Configuration classes and schemas for PRD parsing + */ + +import { z } from 'zod'; +import { TASK_PRIORITY_OPTIONS } from '../../../../src/constants/task-priority.js'; +import { getCurrentTag, isSilentMode, log } from '../../utils.js'; +import { Duration } from '../../../../src/utils/timeout-manager.js'; +import { CUSTOM_PROVIDERS } from '../../../../src/constants/providers.js'; +import { getMainProvider, getResearchProvider } from '../../config-manager.js'; + +// ============================================================================ +// SCHEMAS +// ============================================================================ + +// Define the Zod schema for a SINGLE task object +export const prdSingleTaskSchema = z.object({ + id: z.number(), + title: z.string().min(1), + description: z.string().min(1), + details: z.string(), + testStrategy: z.string(), + priority: z.enum(TASK_PRIORITY_OPTIONS), + dependencies: z.array(z.number()), + status: z.string() +}); + +// Define the Zod schema for the ENTIRE expected AI response object +export const prdResponseSchema = z.object({ + tasks: z.array(prdSingleTaskSchema), + metadata: z.object({ + projectName: z.string(), + totalTasks: z.number(), + sourceFile: z.string(), + generatedAt: z.string() + }) +}); + +// ============================================================================ +// CONFIGURATION CLASSES +// ============================================================================ + +/** + * Configuration object for PRD parsing + */ +export class PrdParseConfig { + constructor(prdPath, tasksPath, numTasks, options = {}) { + this.prdPath = prdPath; + this.tasksPath = tasksPath; + this.numTasks = numTasks; + this.force = options.force || false; + this.append = options.append || false; + this.research = options.research || false; + this.reportProgress = options.reportProgress; + this.mcpLog = options.mcpLog; + this.session = options.session; + this.projectRoot = options.projectRoot; + this.tag = options.tag; + this.streamingTimeout = + options.streamingTimeout || Duration.seconds(180).milliseconds; + + // Derived values + this.targetTag = this.tag || getCurrentTag(this.projectRoot) || 'master'; + this.isMCP = !!this.mcpLog; + this.outputFormat = this.isMCP && !this.reportProgress ? 'json' : 'text'; + this.useStreaming = + typeof this.reportProgress === 'function' || this.outputFormat === 'text'; + } + + /** + * Check if Claude Code is being used + */ + isClaudeCode() { + const currentProvider = this.research + ? getResearchProvider(this.projectRoot) + : getMainProvider(this.projectRoot); + return currentProvider === CUSTOM_PROVIDERS.CLAUDE_CODE; + } +} + +/** + * Logging configuration and utilities + */ +export class LoggingConfig { + constructor(mcpLog, reportProgress) { + this.isMCP = !!mcpLog; + this.outputFormat = this.isMCP && !reportProgress ? 'json' : 'text'; + + this.logFn = mcpLog || { + info: (...args) => log('info', ...args), + warn: (...args) => log('warn', ...args), + error: (...args) => log('error', ...args), + debug: (...args) => log('debug', ...args), + success: (...args) => log('success', ...args) + }; + } + + report(message, level = 'info') { + if (this.logFn && typeof this.logFn[level] === 'function') { + this.logFn[level](message); + } else if (!isSilentMode() && this.outputFormat === 'text') { + log(level, message); + } + } +} diff --git a/scripts/modules/task-manager/parse-prd/parse-prd-helpers.js b/scripts/modules/task-manager/parse-prd/parse-prd-helpers.js new file mode 100644 index 00000000..5c84a10e --- /dev/null +++ b/scripts/modules/task-manager/parse-prd/parse-prd-helpers.js @@ -0,0 +1,384 @@ +/** + * Helper functions for PRD parsing + */ + +import fs from 'fs'; +import path from 'path'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { ensureTagMetadata, findTaskById } from '../../utils.js'; +import { getPriorityIndicators } from '../../../../src/ui/indicators.js'; +import { displayParsePrdSummary } from '../../../../src/ui/parse-prd.js'; +import { TimeoutManager } from '../../../../src/utils/timeout-manager.js'; +import { displayAiUsageSummary } from '../../ui.js'; +import { getPromptManager } from '../../prompt-manager.js'; +import { getDefaultPriority } from '../../config-manager.js'; + +/** + * Estimate token count from text + * @param {string} text - Text to estimate tokens for + * @returns {number} Estimated token count + */ +export function estimateTokens(text) { + // Common approximation: ~4 characters per token for English + return Math.ceil(text.length / 4); +} + +/** + * Read and validate PRD content + * @param {string} prdPath - Path to PRD file + * @returns {string} PRD content + * @throws {Error} If file is empty or cannot be read + */ +export function readPrdContent(prdPath) { + const prdContent = fs.readFileSync(prdPath, 'utf8'); + if (!prdContent) { + throw new Error(`Input file ${prdPath} is empty or could not be read.`); + } + return prdContent; +} + +/** + * Load existing tasks from file + * @param {string} tasksPath - Path to tasks file + * @param {string} targetTag - Target tag to load from + * @returns {{tasks: Array, nextId: number}} Existing tasks and next ID + */ +export function loadExistingTasks(tasksPath, targetTag) { + let existingTasks = []; + let nextId = 1; + + if (!fs.existsSync(tasksPath)) { + return { existingTasks, nextId }; + } + + try { + const existingFileContent = fs.readFileSync(tasksPath, 'utf8'); + const allData = JSON.parse(existingFileContent); + + if (allData[targetTag]?.tasks && Array.isArray(allData[targetTag].tasks)) { + existingTasks = allData[targetTag].tasks; + if (existingTasks.length > 0) { + nextId = Math.max(...existingTasks.map((t) => t.id || 0)) + 1; + } + } + } catch (error) { + // If we can't read the file or parse it, assume no existing tasks + return { existingTasks: [], nextId: 1 }; + } + + return { existingTasks, nextId }; +} + +/** + * Validate overwrite/append operations + * @param {Object} params + * @returns {void} + * @throws {Error} If validation fails + */ +export function validateFileOperations({ + existingTasks, + targetTag, + append, + force, + isMCP, + logger +}) { + const hasExistingTasks = existingTasks.length > 0; + + if (!hasExistingTasks) { + logger.report( + `Tag '${targetTag}' is empty or doesn't exist. Creating/updating tag with new tasks.`, + 'info' + ); + return; + } + + if (append) { + logger.report( + `Append mode enabled. Found ${existingTasks.length} existing tasks in tag '${targetTag}'.`, + 'info' + ); + return; + } + + if (!force) { + const errorMessage = `Tag '${targetTag}' already contains ${existingTasks.length} tasks. Use --force to overwrite or --append to add to existing tasks.`; + logger.report(errorMessage, 'error'); + + if (isMCP) { + throw new Error(errorMessage); + } else { + console.error(chalk.red(errorMessage)); + process.exit(1); + } + } + + logger.report( + `Force flag enabled. Overwriting existing tasks in tag '${targetTag}'.`, + 'debug' + ); +} + +/** + * Process and transform tasks with ID remapping + * @param {Array} rawTasks - Raw tasks from AI + * @param {number} startId - Starting ID for new tasks + * @param {Array} existingTasks - Existing tasks for dependency validation + * @param {string} defaultPriority - Default priority for tasks + * @returns {Array} Processed tasks with remapped IDs + */ +export function processTasks( + rawTasks, + startId, + existingTasks, + defaultPriority +) { + let currentId = startId; + const taskMap = new Map(); + + // First pass: assign new IDs and create mapping + const processedTasks = rawTasks.map((task) => { + const newId = currentId++; + taskMap.set(task.id, newId); + + return { + ...task, + id: newId, + status: task.status || 'pending', + priority: task.priority || defaultPriority, + dependencies: Array.isArray(task.dependencies) ? task.dependencies : [], + subtasks: task.subtasks || [], + // Ensure all required fields have values + title: task.title || '', + description: task.description || '', + details: task.details || '', + testStrategy: task.testStrategy || '' + }; + }); + + // Second pass: remap dependencies + processedTasks.forEach((task) => { + task.dependencies = task.dependencies + .map((depId) => taskMap.get(depId)) + .filter( + (newDepId) => + newDepId != null && + newDepId < task.id && + (findTaskById(existingTasks, newDepId) || + processedTasks.some((t) => t.id === newDepId)) + ); + }); + + return processedTasks; +} + +/** + * Save tasks to file with tag support + * @param {string} tasksPath - Path to save tasks + * @param {Array} tasks - Tasks to save + * @param {string} targetTag - Target tag + * @param {Object} logger - Logger instance + */ +export function saveTasksToFile(tasksPath, tasks, targetTag, logger) { + // Create directory if it doesn't exist + const tasksDir = path.dirname(tasksPath); + if (!fs.existsSync(tasksDir)) { + fs.mkdirSync(tasksDir, { recursive: true }); + } + + // Read existing file to preserve other tags + let outputData = {}; + if (fs.existsSync(tasksPath)) { + try { + const existingFileContent = fs.readFileSync(tasksPath, 'utf8'); + outputData = JSON.parse(existingFileContent); + } catch (error) { + outputData = {}; + } + } + + // Update only the target tag + outputData[targetTag] = { + tasks: tasks, + metadata: { + created: + outputData[targetTag]?.metadata?.created || new Date().toISOString(), + updated: new Date().toISOString(), + description: `Tasks for ${targetTag} context` + } + }; + + // Ensure proper metadata + ensureTagMetadata(outputData[targetTag], { + description: `Tasks for ${targetTag} context` + }); + + // Write back to file + fs.writeFileSync(tasksPath, JSON.stringify(outputData, null, 2)); + + logger.report( + `Successfully saved ${tasks.length} tasks to ${tasksPath}`, + 'debug' + ); +} + +/** + * Build prompts for AI service + * @param {Object} config - Configuration object + * @param {string} prdContent - PRD content + * @param {number} nextId - Next task ID + * @returns {Promise<{systemPrompt: string, userPrompt: string}>} + */ +export async function buildPrompts(config, prdContent, nextId) { + const promptManager = getPromptManager(); + const defaultTaskPriority = + getDefaultPriority(config.projectRoot) || 'medium'; + + return promptManager.loadPrompt('parse-prd', { + research: config.research, + numTasks: config.numTasks, + nextId, + prdContent, + prdPath: config.prdPath, + defaultTaskPriority, + isClaudeCode: config.isClaudeCode(), + projectRoot: config.projectRoot || '' + }); +} + +/** + * Handle progress reporting for both CLI and MCP + * @param {Object} params + */ +export async function reportTaskProgress({ + task, + currentCount, + totalTasks, + estimatedTokens, + progressTracker, + reportProgress, + priorityMap, + defaultPriority, + estimatedInputTokens +}) { + const priority = task.priority || defaultPriority; + const priorityIndicator = priorityMap[priority] || priorityMap.medium; + + // CLI progress tracker + if (progressTracker) { + progressTracker.addTaskLine(currentCount, task.title, priority); + if (estimatedTokens) { + progressTracker.updateTokens(estimatedInputTokens, estimatedTokens); + } + } + + // MCP progress reporting + if (reportProgress) { + try { + const outputTokens = estimatedTokens + ? Math.floor(estimatedTokens / totalTasks) + : 0; + + await reportProgress({ + progress: currentCount, + total: totalTasks, + message: `${priorityIndicator} Task ${currentCount}/${totalTasks} - ${task.title} | ~Output: ${outputTokens} tokens` + }); + } catch (error) { + // Ignore progress reporting errors + } + } +} + +/** + * Display completion summary for CLI + * @param {Object} params + */ +export async function displayCliSummary({ + processedTasks, + nextId, + summary, + prdPath, + tasksPath, + usedFallback, + aiServiceResponse +}) { + // Generate task file names + const taskFilesGenerated = (() => { + if (!Array.isArray(processedTasks) || processedTasks.length === 0) { + return `task_${String(nextId).padStart(3, '0')}.txt`; + } + const firstNewTaskId = processedTasks[0].id; + const lastNewTaskId = processedTasks[processedTasks.length - 1].id; + if (processedTasks.length === 1) { + return `task_${String(firstNewTaskId).padStart(3, '0')}.txt`; + } + return `task_${String(firstNewTaskId).padStart(3, '0')}.txt -> task_${String(lastNewTaskId).padStart(3, '0')}.txt`; + })(); + + displayParsePrdSummary({ + totalTasks: processedTasks.length, + taskPriorities: summary.taskPriorities, + prdFilePath: prdPath, + outputPath: tasksPath, + elapsedTime: summary.elapsedTime, + usedFallback, + taskFilesGenerated, + actionVerb: summary.actionVerb + }); + + // Display telemetry + if (aiServiceResponse?.telemetryData) { + // For streaming, wait briefly to allow usage data to be captured + if (aiServiceResponse.mainResult?.usage) { + // Give the usage promise a short time to resolve + await TimeoutManager.withSoftTimeout( + aiServiceResponse.mainResult.usage, + 1000, + undefined + ); + } + displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); + } +} + +/** + * Display non-streaming CLI output + * @param {Object} params + */ +export function displayNonStreamingCliOutput({ + processedTasks, + research, + finalTasks, + tasksPath, + aiServiceResponse +}) { + console.log( + boxen( + chalk.green( + `Successfully generated ${processedTasks.length} new tasks${research ? ' with research-backed analysis' : ''}. Total tasks in ${tasksPath}: ${finalTasks.length}` + ), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + + console.log( + boxen( + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=')} to break down a task into subtasks`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + if (aiServiceResponse?.telemetryData) { + displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); + } +} diff --git a/scripts/modules/task-manager/parse-prd/parse-prd-non-streaming.js b/scripts/modules/task-manager/parse-prd/parse-prd-non-streaming.js new file mode 100644 index 00000000..a17e4e42 --- /dev/null +++ b/scripts/modules/task-manager/parse-prd/parse-prd-non-streaming.js @@ -0,0 +1,85 @@ +/** + * Non-streaming handler for PRD parsing + */ + +import ora from 'ora'; +import { generateObjectService } from '../../ai-services-unified.js'; +import { LoggingConfig, prdResponseSchema } from './parse-prd-config.js'; +import { estimateTokens } from './parse-prd-helpers.js'; + +/** + * Handle non-streaming AI service call + * @param {Object} config - Configuration object + * @param {Object} prompts - System and user prompts + * @returns {Promise} Generated tasks and telemetry + */ +export async function handleNonStreamingService(config, prompts) { + const logger = new LoggingConfig(config.mcpLog, config.reportProgress); + const { systemPrompt, userPrompt } = prompts; + const estimatedInputTokens = estimateTokens(systemPrompt + userPrompt); + + // Initialize spinner for CLI + let spinner = null; + if (config.outputFormat === 'text' && !config.isMCP) { + spinner = ora('Parsing PRD and generating tasks...\n').start(); + } + + try { + // Call AI service + logger.report( + `Calling AI service to generate tasks from PRD${config.research ? ' with research-backed analysis' : ''}...`, + 'info' + ); + + const aiServiceResponse = await generateObjectService({ + role: config.research ? 'research' : 'main', + session: config.session, + projectRoot: config.projectRoot, + schema: prdResponseSchema, + objectName: 'tasks_data', + systemPrompt, + prompt: userPrompt, + commandName: 'parse-prd', + outputType: config.isMCP ? 'mcp' : 'cli' + }); + + // Extract generated data + let generatedData = null; + if (aiServiceResponse?.mainResult) { + if ( + typeof aiServiceResponse.mainResult === 'object' && + aiServiceResponse.mainResult !== null && + 'tasks' in aiServiceResponse.mainResult + ) { + generatedData = aiServiceResponse.mainResult; + } else if ( + typeof aiServiceResponse.mainResult.object === 'object' && + aiServiceResponse.mainResult.object !== null && + 'tasks' in aiServiceResponse.mainResult.object + ) { + generatedData = aiServiceResponse.mainResult.object; + } + } + + if (!generatedData || !Array.isArray(generatedData.tasks)) { + throw new Error( + 'AI service returned unexpected data structure after validation.' + ); + } + + if (spinner) { + spinner.succeed('Tasks generated successfully!'); + } + + return { + parsedTasks: generatedData.tasks, + aiServiceResponse, + estimatedInputTokens + }; + } catch (error) { + if (spinner) { + spinner.fail(`Error parsing PRD: ${error.message}`); + } + throw error; + } +} diff --git a/scripts/modules/task-manager/parse-prd/parse-prd-streaming.js b/scripts/modules/task-manager/parse-prd/parse-prd-streaming.js new file mode 100644 index 00000000..b21f8840 --- /dev/null +++ b/scripts/modules/task-manager/parse-prd/parse-prd-streaming.js @@ -0,0 +1,653 @@ +/** + * Streaming handler for PRD parsing + */ + +import { createParsePrdTracker } from '../../../../src/progress/parse-prd-tracker.js'; +import { displayParsePrdStart } from '../../../../src/ui/parse-prd.js'; +import { getPriorityIndicators } from '../../../../src/ui/indicators.js'; +import { TimeoutManager } from '../../../../src/utils/timeout-manager.js'; +import { + streamObjectService, + generateObjectService +} from '../../ai-services-unified.js'; +import { + getMainModelId, + getParametersForRole, + getResearchModelId, + getDefaultPriority +} from '../../config-manager.js'; +import { LoggingConfig, prdResponseSchema } from './parse-prd-config.js'; +import { estimateTokens, reportTaskProgress } from './parse-prd-helpers.js'; + +/** + * Extract a readable stream from various stream result formats + * @param {any} streamResult - The stream result object from AI service + * @returns {AsyncIterable|ReadableStream} The extracted stream + * @throws {StreamingError} If no valid stream can be extracted + */ +function extractStreamFromResult(streamResult) { + if (!streamResult) { + throw new StreamingError( + 'Stream result is null or undefined', + STREAMING_ERROR_CODES.NOT_ASYNC_ITERABLE + ); + } + + // Try extraction strategies in priority order + const stream = tryExtractStream(streamResult); + + if (!stream) { + throw new StreamingError( + 'Stream object is not async iterable or readable', + STREAMING_ERROR_CODES.NOT_ASYNC_ITERABLE + ); + } + + return stream; +} + +/** + * Try to extract stream using various strategies + */ +function tryExtractStream(streamResult) { + const streamExtractors = [ + { key: 'partialObjectStream', extractor: (obj) => obj.partialObjectStream }, + { key: 'textStream', extractor: (obj) => extractCallable(obj.textStream) }, + { key: 'stream', extractor: (obj) => extractCallable(obj.stream) }, + { key: 'baseStream', extractor: (obj) => obj.baseStream } + ]; + + for (const { key, extractor } of streamExtractors) { + const stream = extractor(streamResult); + if (stream && isStreamable(stream)) { + return stream; + } + } + + // Check if already streamable + return isStreamable(streamResult) ? streamResult : null; +} + +/** + * Extract a property that might be a function or direct value + */ +function extractCallable(property) { + if (!property) return null; + return typeof property === 'function' ? property() : property; +} + +/** + * Check if object is streamable (async iterable or readable stream) + */ +function isStreamable(obj) { + return ( + obj && + (typeof obj[Symbol.asyncIterator] === 'function' || + (obj.getReader && typeof obj.getReader === 'function')) + ); +} + +/** + * Handle streaming AI service call and parsing + * @param {Object} config - Configuration object + * @param {Object} prompts - System and user prompts + * @param {number} numTasks - Number of tasks to generate + * @returns {Promise} Parsed tasks and telemetry + */ +export async function handleStreamingService(config, prompts, numTasks) { + const context = createStreamingContext(config, prompts, numTasks); + + await initializeProgress(config, numTasks, context.estimatedInputTokens); + + const aiServiceResponse = await callAIServiceWithTimeout( + config, + prompts, + config.streamingTimeout + ); + + const { progressTracker, priorityMap } = await setupProgressTracking( + config, + numTasks + ); + + const streamingResult = await processStreamResponse( + aiServiceResponse.mainResult, + config, + prompts, + numTasks, + progressTracker, + priorityMap, + context.defaultPriority, + context.estimatedInputTokens, + context.logger + ); + + validateStreamingResult(streamingResult); + + // If we have usage data from streaming, log telemetry now + if (streamingResult.usage && config.projectRoot) { + const { logAiUsage } = await import('../../ai-services-unified.js'); + const { getUserId } = await import('../../config-manager.js'); + const userId = getUserId(config.projectRoot); + + if (userId && aiServiceResponse.providerName && aiServiceResponse.modelId) { + try { + const telemetryData = await logAiUsage({ + userId, + commandName: 'parse-prd', + providerName: aiServiceResponse.providerName, + modelId: aiServiceResponse.modelId, + inputTokens: streamingResult.usage.promptTokens || 0, + outputTokens: streamingResult.usage.completionTokens || 0, + outputType: config.isMCP ? 'mcp' : 'cli' + }); + + // Add telemetry to the response + if (telemetryData) { + aiServiceResponse.telemetryData = telemetryData; + } + } catch (telemetryError) { + context.logger.report( + `Failed to log telemetry: ${telemetryError.message}`, + 'debug' + ); + } + } + } + + return prepareFinalResult( + streamingResult, + aiServiceResponse, + context.estimatedInputTokens, + progressTracker + ); +} + +/** + * Create streaming context with common values + */ +function createStreamingContext(config, prompts, numTasks) { + const { systemPrompt, userPrompt } = prompts; + return { + logger: new LoggingConfig(config.mcpLog, config.reportProgress), + estimatedInputTokens: estimateTokens(systemPrompt + userPrompt), + defaultPriority: getDefaultPriority(config.projectRoot) || 'medium' + }; +} + +/** + * Validate streaming result has tasks + */ +function validateStreamingResult(streamingResult) { + if (streamingResult.parsedTasks.length === 0) { + throw new Error('No tasks were generated from the PRD'); + } +} + +/** + * Initialize progress reporting + */ +async function initializeProgress(config, numTasks, estimatedInputTokens) { + if (config.reportProgress) { + await config.reportProgress({ + progress: 0, + total: numTasks, + message: `Starting PRD analysis (Input: ${estimatedInputTokens} tokens)${config.research ? ' with research' : ''}...` + }); + } +} + +/** + * Call AI service with timeout + */ +async function callAIServiceWithTimeout(config, prompts, timeout) { + const { systemPrompt, userPrompt } = prompts; + + return await TimeoutManager.withTimeout( + streamObjectService({ + role: config.research ? 'research' : 'main', + session: config.session, + projectRoot: config.projectRoot, + schema: prdResponseSchema, + systemPrompt, + prompt: userPrompt, + commandName: 'parse-prd', + outputType: config.isMCP ? 'mcp' : 'cli' + }), + timeout, + 'Streaming operation' + ); +} + +/** + * Setup progress tracking for CLI output + */ +async function setupProgressTracking(config, numTasks) { + const priorityMap = getPriorityIndicators(config.isMCP); + let progressTracker = null; + + if (config.outputFormat === 'text' && !config.isMCP) { + progressTracker = createParsePrdTracker({ + numUnits: numTasks, + unitName: 'task', + append: config.append + }); + + const modelId = config.research ? getResearchModelId() : getMainModelId(); + const parameters = getParametersForRole( + config.research ? 'research' : 'main' + ); + + displayParsePrdStart({ + prdFilePath: config.prdPath, + outputPath: config.tasksPath, + numTasks, + append: config.append, + research: config.research, + force: config.force, + existingTasks: [], + nextId: 1, + model: modelId || 'Default', + temperature: parameters?.temperature || 0.7 + }); + + progressTracker.start(); + } + + return { progressTracker, priorityMap }; +} + +/** + * Process stream response based on stream type + */ +async function processStreamResponse( + streamResult, + config, + prompts, + numTasks, + progressTracker, + priorityMap, + defaultPriority, + estimatedInputTokens, + logger +) { + const { systemPrompt, userPrompt } = prompts; + const context = { + config: { + ...config, + schema: prdResponseSchema // Add the schema for generateObject fallback + }, + numTasks, + progressTracker, + priorityMap, + defaultPriority, + estimatedInputTokens, + prompt: userPrompt, + systemPrompt: systemPrompt + }; + + try { + const streamingState = { + lastPartialObject: null, + taskCount: 0, + estimatedOutputTokens: 0, + usage: null + }; + + await processPartialStream( + streamResult.partialObjectStream, + streamingState, + context + ); + + // Wait for usage data if available + if (streamResult.usage) { + try { + streamingState.usage = await streamResult.usage; + } catch (usageError) { + logger.report( + `Failed to get usage data: ${usageError.message}`, + 'debug' + ); + } + } + + return finalizeStreamingResults(streamingState, context); + } catch (error) { + logger.report( + `StreamObject processing failed: ${error.message}. Falling back to generateObject.`, + 'debug' + ); + return await processWithGenerateObject(context, logger); + } +} + +/** + * Process the partial object stream + */ +async function processPartialStream(partialStream, state, context) { + for await (const partialObject of partialStream) { + state.lastPartialObject = partialObject; + + if (partialObject) { + state.estimatedOutputTokens = estimateTokens( + JSON.stringify(partialObject) + ); + } + + await processStreamingTasks(partialObject, state, context); + } +} + +/** + * Process tasks from a streaming partial object + */ +async function processStreamingTasks(partialObject, state, context) { + if (!partialObject?.tasks || !Array.isArray(partialObject.tasks)) { + return; + } + + const newTaskCount = partialObject.tasks.length; + + if (newTaskCount > state.taskCount) { + await processNewTasks( + partialObject.tasks, + state.taskCount, + newTaskCount, + state.estimatedOutputTokens, + context + ); + state.taskCount = newTaskCount; + } else if (context.progressTracker && state.estimatedOutputTokens > 0) { + context.progressTracker.updateTokens( + context.estimatedInputTokens, + state.estimatedOutputTokens, + true + ); + } +} + +/** + * Process newly appeared tasks in the stream + */ +async function processNewTasks( + tasks, + startIndex, + endIndex, + estimatedOutputTokens, + context +) { + for (let i = startIndex; i < endIndex; i++) { + const task = tasks[i] || {}; + + if (task.title) { + await reportTaskProgress({ + task, + currentCount: i + 1, + totalTasks: context.numTasks, + estimatedTokens: estimatedOutputTokens, + progressTracker: context.progressTracker, + reportProgress: context.config.reportProgress, + priorityMap: context.priorityMap, + defaultPriority: context.defaultPriority, + estimatedInputTokens: context.estimatedInputTokens + }); + } else { + await reportPlaceholderTask(i + 1, estimatedOutputTokens, context); + } + } +} + +/** + * Report a placeholder task while it's being generated + */ +async function reportPlaceholderTask( + taskNumber, + estimatedOutputTokens, + context +) { + const { + progressTracker, + config, + numTasks, + defaultPriority, + estimatedInputTokens + } = context; + + if (progressTracker) { + progressTracker.addTaskLine( + taskNumber, + `Generating task ${taskNumber}...`, + defaultPriority + ); + progressTracker.updateTokens( + estimatedInputTokens, + estimatedOutputTokens, + true + ); + } + + if (config.reportProgress && !progressTracker) { + await config.reportProgress({ + progress: taskNumber, + total: numTasks, + message: `Generating task ${taskNumber}/${numTasks}...` + }); + } +} + +/** + * Finalize streaming results and update progress display + */ +async function finalizeStreamingResults(state, context) { + const { lastPartialObject, estimatedOutputTokens, taskCount, usage } = state; + + if (!lastPartialObject?.tasks || !Array.isArray(lastPartialObject.tasks)) { + throw new Error('No tasks generated from streamObject'); + } + + // Use actual token counts if available, otherwise use estimates + const finalOutputTokens = usage?.completionTokens || estimatedOutputTokens; + const finalInputTokens = usage?.promptTokens || context.estimatedInputTokens; + + if (context.progressTracker) { + await updateFinalProgress( + lastPartialObject.tasks, + taskCount, + usage ? finalOutputTokens : estimatedOutputTokens, + context, + usage ? finalInputTokens : null + ); + } + + return { + parsedTasks: lastPartialObject.tasks, + estimatedOutputTokens: finalOutputTokens, + actualInputTokens: finalInputTokens, + usage, + usedFallback: false + }; +} + +/** + * Update progress tracker with final task content + */ +async function updateFinalProgress( + tasks, + taskCount, + outputTokens, + context, + actualInputTokens = null +) { + const { progressTracker, defaultPriority, estimatedInputTokens } = context; + + if (taskCount > 0) { + updateTaskLines(tasks, progressTracker, defaultPriority); + } else { + await reportAllTasks(tasks, outputTokens, context); + } + + progressTracker.updateTokens( + actualInputTokens || estimatedInputTokens, + outputTokens, + false + ); + progressTracker.stop(); +} + +/** + * Update task lines in progress tracker with final content + */ +function updateTaskLines(tasks, progressTracker, defaultPriority) { + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + if (task?.title) { + progressTracker.addTaskLine( + i + 1, + task.title, + task.priority || defaultPriority + ); + } + } +} + +/** + * Report all tasks that were not streamed incrementally + */ +async function reportAllTasks(tasks, estimatedOutputTokens, context) { + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + if (task?.title) { + await reportTaskProgress({ + task, + currentCount: i + 1, + totalTasks: context.numTasks, + estimatedTokens: estimatedOutputTokens, + progressTracker: context.progressTracker, + reportProgress: context.config.reportProgress, + priorityMap: context.priorityMap, + defaultPriority: context.defaultPriority, + estimatedInputTokens: context.estimatedInputTokens + }); + } + } +} + +/** + * Process with generateObject as fallback when streaming fails + */ +async function processWithGenerateObject(context, logger) { + logger.report('Using generateObject fallback for PRD parsing', 'info'); + + // Show placeholder tasks while generating + if (context.progressTracker) { + for (let i = 0; i < context.numTasks; i++) { + context.progressTracker.addTaskLine( + i + 1, + `Generating task ${i + 1}...`, + context.defaultPriority + ); + context.progressTracker.updateTokens( + context.estimatedInputTokens, + 0, + true + ); + } + } + + // Use generateObjectService instead of streaming + const result = await generateObjectService({ + role: context.config.research ? 'research' : 'main', + commandName: 'parse-prd', + prompt: context.prompt, + systemPrompt: context.systemPrompt, + schema: context.config.schema, + outputFormat: context.config.outputFormat || 'text', + projectRoot: context.config.projectRoot, + session: context.config.session + }); + + // Extract tasks from the result (handle both direct tasks and mainResult.tasks) + const tasks = result?.mainResult || result; + + // Process the generated tasks + if (tasks && Array.isArray(tasks.tasks)) { + // Update progress tracker with final tasks + if (context.progressTracker) { + for (let i = 0; i < tasks.tasks.length; i++) { + const task = tasks.tasks[i]; + if (task && task.title) { + context.progressTracker.addTaskLine( + i + 1, + task.title, + task.priority || context.defaultPriority + ); + } + } + + // Final token update - use actual telemetry if available + const outputTokens = + result.telemetryData?.outputTokens || + estimateTokens(JSON.stringify(tasks)); + const inputTokens = + result.telemetryData?.inputTokens || context.estimatedInputTokens; + + context.progressTracker.updateTokens(inputTokens, outputTokens, false); + } + + return { + parsedTasks: tasks.tasks, + estimatedOutputTokens: + result.telemetryData?.outputTokens || + estimateTokens(JSON.stringify(tasks)), + actualInputTokens: result.telemetryData?.inputTokens, + telemetryData: result.telemetryData, + usedFallback: true + }; + } + + throw new Error('Failed to generate tasks using generateObject fallback'); +} + +/** + * Prepare final result with cleanup + */ +function prepareFinalResult( + streamingResult, + aiServiceResponse, + estimatedInputTokens, + progressTracker +) { + let summary = null; + if (progressTracker) { + summary = progressTracker.getSummary(); + progressTracker.cleanup(); + } + + // If we have actual usage data from streaming, update the AI service response + if (streamingResult.usage && aiServiceResponse) { + // Map the Vercel AI SDK usage format to our telemetry format + const usage = streamingResult.usage; + if (!aiServiceResponse.usage) { + aiServiceResponse.usage = { + promptTokens: usage.promptTokens || 0, + completionTokens: usage.completionTokens || 0, + totalTokens: usage.totalTokens || 0 + }; + } + + // The telemetry should have been logged in the unified service runner + // but if not, the usage is now available for telemetry calculation + } + + return { + parsedTasks: streamingResult.parsedTasks, + aiServiceResponse, + estimatedInputTokens: + streamingResult.actualInputTokens || estimatedInputTokens, + estimatedOutputTokens: streamingResult.estimatedOutputTokens, + usedFallback: streamingResult.usedFallback, + progressTracker, + summary + }; +} diff --git a/scripts/modules/task-manager/parse-prd/parse-prd.js b/scripts/modules/task-manager/parse-prd/parse-prd.js new file mode 100644 index 00000000..dc92590e --- /dev/null +++ b/scripts/modules/task-manager/parse-prd/parse-prd.js @@ -0,0 +1,272 @@ +import chalk from 'chalk'; +import { + StreamingError, + STREAMING_ERROR_CODES +} from '../../../../src/utils/stream-parser.js'; +import { TimeoutManager } from '../../../../src/utils/timeout-manager.js'; +import { getDebugFlag, getDefaultPriority } from '../../config-manager.js'; + +// Import configuration classes +import { PrdParseConfig, LoggingConfig } from './parse-prd-config.js'; + +// Import helper functions +import { + readPrdContent, + loadExistingTasks, + validateFileOperations, + processTasks, + saveTasksToFile, + buildPrompts, + displayCliSummary, + displayNonStreamingCliOutput +} from './parse-prd-helpers.js'; + +// Import handlers +import { handleStreamingService } from './parse-prd-streaming.js'; +import { handleNonStreamingService } from './parse-prd-non-streaming.js'; + +// ============================================================================ +// MAIN PARSING FUNCTIONS (Simplified after refactoring) +// ============================================================================ + +/** + * Shared parsing logic for both streaming and non-streaming + * @param {PrdParseConfig} config - Configuration object + * @param {Function} serviceHandler - Handler function for AI service + * @param {boolean} isStreaming - Whether this is streaming mode + * @returns {Promise} Result object with success status and telemetry + */ +async function parsePRDCore(config, serviceHandler, isStreaming) { + const logger = new LoggingConfig(config.mcpLog, config.reportProgress); + + logger.report( + `Parsing PRD file: ${config.prdPath}, Force: ${config.force}, Append: ${config.append}, Research: ${config.research}`, + 'debug' + ); + + try { + // Load existing tasks + const { existingTasks, nextId } = loadExistingTasks( + config.tasksPath, + config.targetTag + ); + + // Validate operations + validateFileOperations({ + existingTasks, + targetTag: config.targetTag, + append: config.append, + force: config.force, + isMCP: config.isMCP, + logger + }); + + // Read PRD content and build prompts + const prdContent = readPrdContent(config.prdPath); + const prompts = await buildPrompts(config, prdContent, nextId); + + // Call the appropriate service handler + const serviceResult = await serviceHandler( + config, + prompts, + config.numTasks + ); + + // Process tasks + const defaultPriority = getDefaultPriority(config.projectRoot) || 'medium'; + const processedNewTasks = processTasks( + serviceResult.parsedTasks, + nextId, + existingTasks, + defaultPriority + ); + + // Combine with existing if appending + const finalTasks = config.append + ? [...existingTasks, ...processedNewTasks] + : processedNewTasks; + + // Save to file + saveTasksToFile(config.tasksPath, finalTasks, config.targetTag, logger); + + // Handle completion reporting + await handleCompletionReporting( + config, + serviceResult, + processedNewTasks, + finalTasks, + nextId, + isStreaming + ); + + return { + success: true, + tasksPath: config.tasksPath, + telemetryData: serviceResult.aiServiceResponse?.telemetryData, + tagInfo: serviceResult.aiServiceResponse?.tagInfo + }; + } catch (error) { + logger.report(`Error parsing PRD: ${error.message}`, 'error'); + + if (!config.isMCP) { + console.error(chalk.red(`Error: ${error.message}`)); + if (getDebugFlag(config.projectRoot)) { + console.error(error); + } + } + throw error; + } +} + +/** + * Handle completion reporting for both CLI and MCP + * @param {PrdParseConfig} config - Configuration object + * @param {Object} serviceResult - Result from service handler + * @param {Array} processedNewTasks - New tasks that were processed + * @param {Array} finalTasks - All tasks after processing + * @param {number} nextId - Next available task ID + * @param {boolean} isStreaming - Whether this was streaming mode + */ +async function handleCompletionReporting( + config, + serviceResult, + processedNewTasks, + finalTasks, + nextId, + isStreaming +) { + const { aiServiceResponse, estimatedInputTokens, estimatedOutputTokens } = + serviceResult; + + // MCP progress reporting + if (config.reportProgress) { + const hasValidTelemetry = + aiServiceResponse?.telemetryData && + (aiServiceResponse.telemetryData.inputTokens > 0 || + aiServiceResponse.telemetryData.outputTokens > 0); + + let completionMessage; + if (hasValidTelemetry) { + const cost = aiServiceResponse.telemetryData.totalCost || 0; + const currency = aiServiceResponse.telemetryData.currency || 'USD'; + completionMessage = `✅ Task Generation Completed | Tokens (I/O): ${aiServiceResponse.telemetryData.inputTokens}/${aiServiceResponse.telemetryData.outputTokens} | Cost: ${currency === 'USD' ? '$' : currency}${cost.toFixed(4)}`; + } else { + const outputTokens = isStreaming ? estimatedOutputTokens : 'unknown'; + completionMessage = `✅ Task Generation Completed | ~Tokens (I/O): ${estimatedInputTokens}/${outputTokens} | Cost: ~$0.00`; + } + + await config.reportProgress({ + progress: config.numTasks, + total: config.numTasks, + message: completionMessage + }); + } + + // CLI output + if (config.outputFormat === 'text' && !config.isMCP) { + if (isStreaming && serviceResult.summary) { + await displayCliSummary({ + processedTasks: processedNewTasks, + nextId, + summary: serviceResult.summary, + prdPath: config.prdPath, + tasksPath: config.tasksPath, + usedFallback: serviceResult.usedFallback, + aiServiceResponse + }); + } else if (!isStreaming) { + displayNonStreamingCliOutput({ + processedTasks: processedNewTasks, + research: config.research, + finalTasks, + tasksPath: config.tasksPath, + aiServiceResponse + }); + } + } +} + +/** + * Parse PRD with streaming progress reporting + */ +async function parsePRDWithStreaming( + prdPath, + tasksPath, + numTasks, + options = {} +) { + const config = new PrdParseConfig(prdPath, tasksPath, numTasks, options); + return parsePRDCore(config, handleStreamingService, true); +} + +/** + * Parse PRD without streaming (fallback) + */ +async function parsePRDWithoutStreaming( + prdPath, + tasksPath, + numTasks, + options = {} +) { + const config = new PrdParseConfig(prdPath, tasksPath, numTasks, options); + return parsePRDCore(config, handleNonStreamingService, false); +} + +/** + * Main entry point - decides between streaming and non-streaming + */ +async function parsePRD(prdPath, tasksPath, numTasks, options = {}) { + const config = new PrdParseConfig(prdPath, tasksPath, numTasks, options); + + if (config.useStreaming) { + try { + return await parsePRDWithStreaming(prdPath, tasksPath, numTasks, options); + } catch (streamingError) { + // Check if this is a streaming-specific error (including timeout) + const isStreamingError = + streamingError instanceof StreamingError || + streamingError.code === STREAMING_ERROR_CODES.NOT_ASYNC_ITERABLE || + streamingError.code === + STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED || + streamingError.code === STREAMING_ERROR_CODES.STREAM_NOT_ITERABLE || + TimeoutManager.isTimeoutError(streamingError); + + if (isStreamingError) { + const logger = new LoggingConfig(config.mcpLog, config.reportProgress); + + // Show fallback message + if (config.outputFormat === 'text' && !config.isMCP) { + console.log( + chalk.yellow( + `⚠️ Streaming operation ${streamingError.message.includes('timed out') ? 'timed out' : 'failed'}. Falling back to non-streaming mode...` + ) + ); + } else { + logger.report( + `Streaming failed (${streamingError.message}), falling back to non-streaming mode...`, + 'warn' + ); + } + + // Fallback to non-streaming + return await parsePRDWithoutStreaming( + prdPath, + tasksPath, + numTasks, + options + ); + } else { + throw streamingError; + } + } + } else { + return await parsePRDWithoutStreaming( + prdPath, + tasksPath, + numTasks, + options + ); + } +} + +export default parsePRD; diff --git a/src/ai-providers/base-provider.js b/src/ai-providers/base-provider.js index 441b6ff7..79bd2bce 100644 --- a/src/ai-providers/base-provider.js +++ b/src/ai-providers/base-provider.js @@ -2,6 +2,7 @@ import { generateObject, generateText, streamText, + streamObject, zodSchema, JSONParseError, NoObjectGeneratedError @@ -224,6 +225,46 @@ export class BaseAIProvider { } } + /** + * Streams a structured object using the provider's model + */ + async streamObject(params) { + try { + this.validateParams(params); + this.validateMessages(params.messages); + + if (!params.schema) { + throw new Error('Schema is required for object streaming'); + } + + log( + 'debug', + `Streaming ${this.name} object with model: ${params.modelId}` + ); + + const client = await this.getClient(params); + const result = await streamObject({ + model: client(params.modelId), + messages: params.messages, + schema: zodSchema(params.schema), + mode: params.mode || 'auto', + maxTokens: params.maxTokens, + temperature: params.temperature + }); + + log( + 'debug', + `${this.name} streamObject initiated successfully for model: ${params.modelId}` + ); + + // Return the stream result directly + // The stream result contains partialObjectStream and other properties + return result; + } catch (error) { + this.handleError('object streaming', error); + } + } + /** * Generates a structured object using the provider's model */ diff --git a/src/progress/base-progress-tracker.js b/src/progress/base-progress-tracker.js new file mode 100644 index 00000000..feab2d07 --- /dev/null +++ b/src/progress/base-progress-tracker.js @@ -0,0 +1,298 @@ +import { newMultiBar } from './cli-progress-factory.js'; + +/** + * Base class for progress trackers, handling common logic for time, tokens, estimation, and multibar management. + */ +export class BaseProgressTracker { + constructor(options = {}) { + this.numUnits = options.numUnits || 1; + this.unitName = options.unitName || 'unit'; // e.g., 'task', 'subtask' + this.startTime = null; + this.completedUnits = 0; + this.tokensIn = 0; + this.tokensOut = 0; + this.isEstimate = true; // For token display + + // Time estimation properties + this.bestAvgTimePerUnit = null; + this.lastEstimateTime = null; + this.lastEstimateSeconds = 0; + + // UI components + this.multibar = null; + this.timeTokensBar = null; + this.progressBar = null; + this._timerInterval = null; + + // State flags + this.isStarted = false; + this.isFinished = false; + + // Allow subclasses to define custom properties + this._initializeCustomProperties(options); + } + + /** + * Protected method for subclasses to initialize custom properties. + * @protected + */ + _initializeCustomProperties(options) { + // Subclasses can override this + } + + /** + * Get the pluralized form of the unit name for safe property keys. + * @returns {string} Pluralized unit name + */ + get unitNamePlural() { + return `${this.unitName}s`; + } + + start() { + if (this.isStarted || this.isFinished) return; + + this.isStarted = true; + this.startTime = Date.now(); + + this.multibar = newMultiBar(); + + // Create time/tokens bar using subclass-provided format + this.timeTokensBar = this.multibar.create( + 1, + 0, + {}, + { + format: this._getTimeTokensBarFormat(), + barsize: 1, + hideCursor: true, + clearOnComplete: false + } + ); + + // Create main progress bar using subclass-provided format + this.progressBar = this.multibar.create( + this.numUnits, + 0, + {}, + { + format: this._getProgressBarFormat(), + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591' + } + ); + + this._updateTimeTokensBar(); + this.progressBar.update(0, { [this.unitNamePlural]: `0/${this.numUnits}` }); + + // Start timer + this._timerInterval = setInterval(() => this._updateTimeTokensBar(), 1000); + + // Allow subclasses to add custom bars or setup + this._setupCustomUI(); + } + + /** + * Protected method for subclasses to add custom UI elements after start. + * @protected + */ + _setupCustomUI() { + // Subclasses can override this + } + + /** + * Protected method to get the format for the time/tokens bar. + * @protected + * @returns {string} Format string for the time/tokens bar. + */ + _getTimeTokensBarFormat() { + return `{clock} {elapsed} | Tokens (I/O): {in}/{out} | Est: {remaining}`; + } + + /** + * Protected method to get the format for the main progress bar. + * @protected + * @returns {string} Format string for the progress bar. + */ + _getProgressBarFormat() { + return `${this.unitName.charAt(0).toUpperCase() + this.unitName.slice(1)}s {${this.unitNamePlural}} |{bar}| {percentage}%`; + } + + updateTokens(tokensIn, tokensOut, isEstimate = false) { + this.tokensIn = tokensIn || 0; + this.tokensOut = tokensOut || 0; + this.isEstimate = isEstimate; + this._updateTimeTokensBar(); + } + + _updateTimeTokensBar() { + if (!this.timeTokensBar || this.isFinished) return; + + const elapsed = this._formatElapsedTime(); + const remaining = this._estimateRemainingTime(); + const tokensLabel = this.isEstimate ? '~ Tokens (I/O)' : 'Tokens (I/O)'; + + this.timeTokensBar.update(1, { + clock: '⏱️', + elapsed, + in: this.tokensIn, + out: this.tokensOut, + remaining, + tokensLabel, + // Subclasses can add more payload here via override + ...this._getCustomTimeTokensPayload() + }); + } + + /** + * Protected method for subclasses to provide custom payload for time/tokens bar. + * @protected + * @returns {Object} Custom payload object. + */ + _getCustomTimeTokensPayload() { + return {}; + } + + _formatElapsedTime() { + if (!this.startTime) return '0m 00s'; + const seconds = Math.floor((Date.now() - this.startTime) / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds.toString().padStart(2, '0')}s`; + } + + _estimateRemainingTime() { + const progress = this._getProgressFraction(); + if (progress >= 1) return '~0s'; + + const now = Date.now(); + const elapsed = (now - this.startTime) / 1000; + + if (progress === 0) return '~calculating...'; + + const avgTimePerUnit = elapsed / progress; + + if ( + this.bestAvgTimePerUnit === null || + avgTimePerUnit < this.bestAvgTimePerUnit + ) { + this.bestAvgTimePerUnit = avgTimePerUnit; + } + + const remainingUnits = this.numUnits * (1 - progress); + let estimatedSeconds = Math.ceil(remainingUnits * this.bestAvgTimePerUnit); + + // Stabilization logic + if (this.lastEstimateTime) { + const elapsedSinceEstimate = Math.floor( + (now - this.lastEstimateTime) / 1000 + ); + const countdownSeconds = Math.max( + 0, + this.lastEstimateSeconds - elapsedSinceEstimate + ); + if (countdownSeconds === 0) return '~0s'; + estimatedSeconds = Math.min(estimatedSeconds, countdownSeconds); + } + + this.lastEstimateTime = now; + this.lastEstimateSeconds = estimatedSeconds; + + return `~${this._formatDuration(estimatedSeconds)}`; + } + + /** + * Protected method for subclasses to calculate current progress fraction (0-1). + * Defaults to simple completedUnits / numUnits. + * @protected + * @returns {number} Progress fraction (can be fractional for subtasks). + */ + _getProgressFraction() { + return this.completedUnits / this.numUnits; + } + + _formatDuration(seconds) { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; + } + + getElapsedTime() { + return this.startTime ? Date.now() - this.startTime : 0; + } + + stop() { + if (this.isFinished) return; + + this.isFinished = true; + + if (this._timerInterval) { + clearInterval(this._timerInterval); + this._timerInterval = null; + } + + if (this.multibar) { + this._updateTimeTokensBar(); + this.multibar.stop(); + } + + // Ensure cleanup is called to prevent memory leaks + this.cleanup(); + } + + getSummary() { + return { + completedUnits: this.completedUnits, + elapsedTime: this.getElapsedTime() + // Subclasses should extend this + }; + } + + /** + * Cleanup method to ensure proper resource disposal and prevent memory leaks. + * Should be called when the progress tracker is no longer needed. + */ + cleanup() { + // Stop any active timers + if (this._timerInterval) { + clearInterval(this._timerInterval); + this._timerInterval = null; + } + + // Stop and clear multibar + if (this.multibar) { + try { + this.multibar.stop(); + } catch (error) { + // Ignore errors during cleanup + } + this.multibar = null; + } + + // Clear progress bar references + this.timeTokensBar = null; + this.progressBar = null; + + // Reset state + this.isStarted = false; + this.isFinished = true; + + // Allow subclasses to perform custom cleanup + this._performCustomCleanup(); + } + + /** + * Protected method for subclasses to perform custom cleanup. + * @protected + */ + _performCustomCleanup() { + // Subclasses can override this + } +} diff --git a/src/progress/cli-progress-factory.js b/src/progress/cli-progress-factory.js new file mode 100644 index 00000000..0e0faa4c --- /dev/null +++ b/src/progress/cli-progress-factory.js @@ -0,0 +1,115 @@ +import cliProgress from 'cli-progress'; + +/** + * Default configuration for progress bars + * Extracted to avoid duplication and provide single source of truth + */ +const DEFAULT_CONFIG = { + clearOnComplete: false, + stopOnComplete: true, + hideCursor: true, + barsize: 40 // Standard terminal width for progress bar +}; + +/** + * Available presets for progress bar styling + * Makes it easy to see what options are available + */ +const PRESETS = { + shades_classic: cliProgress.Presets.shades_classic, + shades_grey: cliProgress.Presets.shades_grey, + rect: cliProgress.Presets.rect, + legacy: cliProgress.Presets.legacy +}; + +/** + * Factory class for creating CLI progress bars + * Provides a consistent interface for creating both single and multi-bar instances + */ +export class ProgressBarFactory { + constructor(defaultOptions = {}, defaultPreset = PRESETS.shades_classic) { + this.defaultOptions = { ...DEFAULT_CONFIG, ...defaultOptions }; + this.defaultPreset = defaultPreset; + } + + /** + * Creates a new single progress bar + * @param {Object} opts - Custom options to override defaults + * @param {Object} preset - Progress bar preset for styling + * @returns {cliProgress.SingleBar} Configured single progress bar instance + */ + createSingleBar(opts = {}, preset = null) { + const config = this._mergeConfig(opts); + const barPreset = preset || this.defaultPreset; + + return new cliProgress.SingleBar(config, barPreset); + } + + /** + * Creates a new multi-bar container + * @param {Object} opts - Custom options to override defaults + * @param {Object} preset - Progress bar preset for styling + * @returns {cliProgress.MultiBar} Configured multi-bar instance + */ + createMultiBar(opts = {}, preset = null) { + const config = this._mergeConfig(opts); + const barPreset = preset || this.defaultPreset; + + return new cliProgress.MultiBar(config, barPreset); + } + + /** + * Merges custom options with defaults + * @private + * @param {Object} customOpts - Custom options to merge + * @returns {Object} Merged configuration + */ + _mergeConfig(customOpts) { + return { ...this.defaultOptions, ...customOpts }; + } + + /** + * Updates the default configuration + * @param {Object} options - New default options + */ + setDefaultOptions(options) { + this.defaultOptions = { ...this.defaultOptions, ...options }; + } + + /** + * Updates the default preset + * @param {Object} preset - New default preset + */ + setDefaultPreset(preset) { + this.defaultPreset = preset; + } +} + +// Create a default factory instance for backward compatibility +const defaultFactory = new ProgressBarFactory(); + +/** + * Legacy function for creating a single progress bar + * @deprecated Use ProgressBarFactory.createSingleBar() instead + * @param {Object} opts - Progress bar options + * @returns {cliProgress.SingleBar} Single progress bar instance + */ +export function newSingle(opts = {}) { + return defaultFactory.createSingleBar(opts); +} + +/** + * Legacy function for creating a multi-bar + * @deprecated Use ProgressBarFactory.createMultiBar() instead + * @param {Object} opts - Progress bar options + * @returns {cliProgress.MultiBar} Multi-bar instance + */ +export function newMultiBar(opts = {}) { + return defaultFactory.createMultiBar(opts); +} + +// Export presets for easy access +export { PRESETS }; + +// Export the factory class as default +export default ProgressBarFactory; diff --git a/src/progress/parse-prd-tracker.js b/src/progress/parse-prd-tracker.js new file mode 100644 index 00000000..315f5818 --- /dev/null +++ b/src/progress/parse-prd-tracker.js @@ -0,0 +1,221 @@ +import chalk from 'chalk'; +import { newMultiBar } from './cli-progress-factory.js'; +import { BaseProgressTracker } from './base-progress-tracker.js'; +import { + createProgressHeader, + createProgressRow, + createBorder +} from './tracker-ui.js'; +import { + getCliPriorityIndicators, + getPriorityIndicator, + getStatusBarPriorityIndicators, + getPriorityColors +} from '../ui/indicators.js'; + +// Get centralized priority indicators +const PRIORITY_INDICATORS = getCliPriorityIndicators(); +const PRIORITY_DOTS = getStatusBarPriorityIndicators(); +const PRIORITY_COLORS = getPriorityColors(); + +// Constants +const CONSTANTS = { + DEBOUNCE_DELAY: 100, + MAX_TITLE_LENGTH: 57, + TRUNCATED_LENGTH: 54, + TASK_ID_PAD_START: 3, + TASK_ID_PAD_END: 4, + PRIORITY_PAD_END: 3, + VALID_PRIORITIES: ['high', 'medium', 'low'], + DEFAULT_PRIORITY: 'medium' +}; + +/** + * Helper class to manage update debouncing + */ +class UpdateDebouncer { + constructor(delay = CONSTANTS.DEBOUNCE_DELAY) { + this.delay = delay; + this.pendingTimeout = null; + } + + debounce(callback) { + this.clear(); + this.pendingTimeout = setTimeout(() => { + callback(); + this.pendingTimeout = null; + }, this.delay); + } + + clear() { + if (this.pendingTimeout) { + clearTimeout(this.pendingTimeout); + this.pendingTimeout = null; + } + } + + hasPending() { + return this.pendingTimeout !== null; + } +} + +/** + * Helper class to manage priority counts + */ +class PriorityManager { + constructor() { + this.priorities = { high: 0, medium: 0, low: 0 }; + } + + increment(priority) { + const normalized = this.normalize(priority); + this.priorities[normalized]++; + return normalized; + } + + normalize(priority) { + const lowercased = priority + ? priority.toLowerCase() + : CONSTANTS.DEFAULT_PRIORITY; + return CONSTANTS.VALID_PRIORITIES.includes(lowercased) + ? lowercased + : CONSTANTS.DEFAULT_PRIORITY; + } + + getCounts() { + return { ...this.priorities }; + } +} + +/** + * Helper class for formatting task display elements + */ +class TaskFormatter { + static formatTitle(title, taskNumber) { + if (!title) return `Task ${taskNumber}`; + return title.length > CONSTANTS.MAX_TITLE_LENGTH + ? title.substring(0, CONSTANTS.TRUNCATED_LENGTH) + '...' + : title; + } + + static formatPriority(priority) { + return getPriorityIndicator(priority, false).padEnd( + CONSTANTS.PRIORITY_PAD_END, + ' ' + ); + } + + static formatTaskId(taskNumber) { + return taskNumber + .toString() + .padStart(CONSTANTS.TASK_ID_PAD_START, ' ') + .padEnd(CONSTANTS.TASK_ID_PAD_END, ' '); + } +} + +/** + * Tracks progress for PRD parsing operations with multibar display + */ +class ParsePrdTracker extends BaseProgressTracker { + _initializeCustomProperties(options) { + this.append = options.append; + this.priorityManager = new PriorityManager(); + this.debouncer = new UpdateDebouncer(); + this.headerShown = false; + } + + _getTimeTokensBarFormat() { + return `{clock} {elapsed} | ${PRIORITY_DOTS.high} {high} ${PRIORITY_DOTS.medium} {medium} ${PRIORITY_DOTS.low} {low} | Tokens (I/O): {in}/{out} | Est: {remaining}`; + } + + _getProgressBarFormat() { + return 'Tasks {tasks} |{bar}| {percentage}%'; + } + + _getCustomTimeTokensPayload() { + return this.priorityManager.getCounts(); + } + + addTaskLine(taskNumber, title, priority = 'medium') { + if (!this.multibar || this.isFinished) return; + + this._ensureHeaderShown(); + const normalizedPriority = this._updateTaskCounters(taskNumber, priority); + + // Immediately update the time/tokens bar to show the new priority count + this._updateTimeTokensBar(); + + this.debouncer.debounce(() => { + this._updateProgressDisplay(taskNumber, title, normalizedPriority); + }); + } + + _ensureHeaderShown() { + if (!this.headerShown) { + this.headerShown = true; + createProgressHeader( + this.multibar, + ' TASK | PRI | TITLE', + '------+-----+----------------------------------------------------------------' + ); + } + } + + _updateTaskCounters(taskNumber, priority) { + const normalizedPriority = this.priorityManager.increment(priority); + this.completedUnits = taskNumber; + return normalizedPriority; + } + + _updateProgressDisplay(taskNumber, title, normalizedPriority) { + this.progressBar.update(this.completedUnits, { + tasks: `${this.completedUnits}/${this.numUnits}` + }); + + const displayTitle = TaskFormatter.formatTitle(title, taskNumber); + const priorityDisplay = TaskFormatter.formatPriority(normalizedPriority); + const taskIdCentered = TaskFormatter.formatTaskId(taskNumber); + + createProgressRow( + this.multibar, + ` ${taskIdCentered} | ${priorityDisplay} | {title}`, + { title: displayTitle } + ); + + createBorder( + this.multibar, + '------+-----+----------------------------------------------------------------' + ); + + this._updateTimeTokensBar(); + } + + finish() { + // Flush any pending updates before finishing + if (this.debouncer.hasPending()) { + this.debouncer.clear(); + this._updateTimeTokensBar(); + } + this.cleanup(); + super.finish(); + } + + /** + * Override cleanup to handle pending updates + */ + _performCustomCleanup() { + this.debouncer.clear(); + } + + getSummary() { + return { + ...super.getSummary(), + taskPriorities: this.priorityManager.getCounts(), + actionVerb: this.append ? 'appended' : 'generated' + }; + } +} + +export function createParsePrdTracker(options = {}) { + return new ParsePrdTracker(options); +} diff --git a/src/progress/progress-tracker-builder.js b/src/progress/progress-tracker-builder.js new file mode 100644 index 00000000..9ec3da50 --- /dev/null +++ b/src/progress/progress-tracker-builder.js @@ -0,0 +1,152 @@ +/** + * Configuration for progress tracker features + */ +class TrackerConfig { + constructor() { + this.features = new Set(); + this.spinnerFrames = null; + this.unitName = 'unit'; + this.totalUnits = 100; + } + + addFeature(feature) { + this.features.add(feature); + } + + hasFeature(feature) { + return this.features.has(feature); + } + + getOptions() { + return { + numUnits: this.totalUnits, + unitName: this.unitName, + spinnerFrames: this.spinnerFrames, + features: Array.from(this.features) + }; + } +} + +/** + * Builder for creating configured progress trackers + */ +export class ProgressTrackerBuilder { + constructor() { + this.config = new TrackerConfig(); + } + + withPercent() { + this.config.addFeature('percent'); + return this; + } + + withTokens() { + this.config.addFeature('tokens'); + return this; + } + + withTasks() { + this.config.addFeature('tasks'); + return this; + } + + withSpinner(messages) { + if (!messages || !Array.isArray(messages)) { + throw new Error('Spinner messages must be an array'); + } + this.config.spinnerFrames = messages; + return this; + } + + withUnits(total, unitName = 'unit') { + this.config.totalUnits = total; + this.config.unitName = unitName; + return this; + } + + build() { + return new ProgressTracker(this.config); + } +} + +/** + * Base progress tracker with configurable features + */ +class ProgressTracker { + constructor(config) { + this.config = config; + this.isActive = false; + this.current = 0; + this.spinnerIndex = 0; + this.startTime = null; + } + + start() { + this.isActive = true; + this.startTime = Date.now(); + this.current = 0; + + if (this.config.spinnerFrames) { + this._startSpinner(); + } + } + + update(data = {}) { + if (!this.isActive) return; + + if (data.current !== undefined) { + this.current = data.current; + } + + const progress = this._buildProgressData(data); + return progress; + } + + finish() { + this.isActive = false; + + if (this.spinnerInterval) { + clearInterval(this.spinnerInterval); + this.spinnerInterval = null; + } + + return this._buildSummary(); + } + + _startSpinner() { + this.spinnerInterval = setInterval(() => { + this.spinnerIndex = + (this.spinnerIndex + 1) % this.config.spinnerFrames.length; + }, 100); + } + + _buildProgressData(data) { + const progress = { ...data }; + + if (this.config.hasFeature('percent')) { + progress.percentage = Math.round( + (this.current / this.config.totalUnits) * 100 + ); + } + + if (this.config.hasFeature('tasks')) { + progress.tasks = `${this.current}/${this.config.totalUnits}`; + } + + if (this.config.spinnerFrames) { + progress.spinner = this.config.spinnerFrames[this.spinnerIndex]; + } + + return progress; + } + + _buildSummary() { + const elapsed = Date.now() - this.startTime; + return { + total: this.config.totalUnits, + completed: this.current, + elapsedMs: elapsed, + features: Array.from(this.config.features) + }; + } +} diff --git a/src/progress/tracker-ui.js b/src/progress/tracker-ui.js new file mode 100644 index 00000000..904c3a38 --- /dev/null +++ b/src/progress/tracker-ui.js @@ -0,0 +1,159 @@ +import chalk from 'chalk'; + +/** + * Factory for creating progress bar elements + */ +class ProgressBarFactory { + constructor(multibar) { + if (!multibar) { + throw new Error('Multibar instance is required'); + } + this.multibar = multibar; + } + + /** + * Creates a progress bar with the given format + */ + createBar(format, payload = {}) { + if (typeof format !== 'string') { + throw new Error('Format must be a string'); + } + + const bar = this.multibar.create( + 1, // total + 1, // current + {}, + { + format, + barsize: 1, + hideCursor: true, + clearOnComplete: false + } + ); + + bar.update(1, payload); + return bar; + } + + /** + * Creates a header with borders + */ + createHeader(headerFormat, borderFormat) { + this.createBar(borderFormat); // Top border + this.createBar(headerFormat); // Header + this.createBar(borderFormat); // Bottom border + } + + /** + * Creates a data row + */ + createRow(rowFormat, payload) { + if (!payload || typeof payload !== 'object') { + throw new Error('Payload must be an object'); + } + return this.createBar(rowFormat, payload); + } + + /** + * Creates a border element + */ + createBorder(borderFormat) { + return this.createBar(borderFormat); + } +} + +/** + * Creates a bordered header for progress tables. + * @param {Object} multibar - The multibar instance. + * @param {string} headerFormat - Format string for the header row. + * @param {string} borderFormat - Format string for the top and bottom borders. + * @returns {void} + */ +export function createProgressHeader(multibar, headerFormat, borderFormat) { + const factory = new ProgressBarFactory(multibar); + factory.createHeader(headerFormat, borderFormat); +} + +/** + * Creates a formatted data row for progress tables. + * @param {Object} multibar - The multibar instance. + * @param {string} rowFormat - Format string for the row. + * @param {Object} payload - Data payload for the row format. + * @returns {void} + */ +export function createProgressRow(multibar, rowFormat, payload) { + const factory = new ProgressBarFactory(multibar); + factory.createRow(rowFormat, payload); +} + +/** + * Creates a border row for progress tables. + * @param {Object} multibar - The multibar instance. + * @param {string} borderFormat - Format string for the border. + * @returns {void} + */ +export function createBorder(multibar, borderFormat) { + const factory = new ProgressBarFactory(multibar); + factory.createBorder(borderFormat); +} + +/** + * Builder for creating progress tables with consistent formatting + */ +export class ProgressTableBuilder { + constructor(multibar) { + this.factory = new ProgressBarFactory(multibar); + this.borderStyle = '─'; + this.columnSeparator = '|'; + } + + /** + * Shows a formatted table header + */ + showHeader(columns = null) { + // Default columns for task display + const defaultColumns = [ + { text: 'TASK', width: 6 }, + { text: 'PRI', width: 5 }, + { text: 'TITLE', width: 64 } + ]; + + const cols = columns || defaultColumns; + const headerText = ' ' + cols.map((c) => c.text).join(' | ') + ' '; + const borderLine = this.createBorderLine(cols.map((c) => c.width)); + + this.factory.createHeader(headerText, borderLine); + return this; + } + + /** + * Creates a border line based on column widths + */ + createBorderLine(columnWidths) { + return columnWidths + .map((width) => this.borderStyle.repeat(width)) + .join('─┼─'); + } + + /** + * Adds a task row to the table + */ + addTaskRow(taskId, priority, title) { + const format = ` ${taskId} | ${priority} | {title}`; + this.factory.createRow(format, { title }); + + // Add separator after each row + const borderLine = '------+-----+' + '─'.repeat(64); + this.factory.createBorder(borderLine); + return this; + } + + /** + * Creates a summary row + */ + addSummaryRow(label, value) { + const format = ` ${label}: {value}`; + this.factory.createRow(format, { value }); + return this; + } +} diff --git a/src/ui/indicators.js b/src/ui/indicators.js new file mode 100644 index 00000000..50181c26 --- /dev/null +++ b/src/ui/indicators.js @@ -0,0 +1,273 @@ +/** + * indicators.js + * UI functions for displaying priority and complexity indicators in different contexts + */ + +import chalk from 'chalk'; +import { TASK_PRIORITY_OPTIONS } from '../constants/task-priority.js'; + +// Extract priority values for cleaner object keys +const [HIGH, MEDIUM, LOW] = TASK_PRIORITY_OPTIONS; + +// Cache for generated indicators +const INDICATOR_CACHE = new Map(); + +/** + * Base configuration for indicator systems + */ +class IndicatorConfig { + constructor(name, levels, colors, thresholds = null) { + this.name = name; + this.levels = levels; + this.colors = colors; + this.thresholds = thresholds; + } + + getColor(level) { + return this.colors[level] || chalk.gray; + } + + getLevelFromScore(score) { + if (!this.thresholds) { + throw new Error(`${this.name} does not support score-based levels`); + } + + if (score >= 7) return this.levels[0]; // high + if (score <= 3) return this.levels[2]; // low + return this.levels[1]; // medium + } +} + +/** + * Visual style definitions + */ +const VISUAL_STYLES = { + cli: { + filled: '●', // ● + empty: '○' // ○ + }, + statusBar: { + high: '⋮', // ⋮ + medium: ':', // : + low: '.' // . + }, + mcp: { + high: '🔴', // 🔴 + medium: '🟠', // 🟠 + low: '🟢' // 🟢 + } +}; + +/** + * Priority configuration + */ +const PRIORITY_CONFIG = new IndicatorConfig('priority', [HIGH, MEDIUM, LOW], { + [HIGH]: chalk.hex('#CC0000'), + [MEDIUM]: chalk.hex('#FF8800'), + [LOW]: chalk.yellow +}); + +/** + * Generates CLI indicator with intensity + */ +function generateCliIndicator(intensity, color) { + const filled = VISUAL_STYLES.cli.filled; + const empty = VISUAL_STYLES.cli.empty; + + let indicator = ''; + for (let i = 0; i < 3; i++) { + if (i < intensity) { + indicator += color(filled); + } else { + indicator += chalk.white(empty); + } + } + return indicator; +} + +/** + * Get intensity level from priority/complexity level + */ +function getIntensityFromLevel(level, levels) { + const index = levels.indexOf(level); + return 3 - index; // high=3, medium=2, low=1 +} + +/** + * Generic cached indicator getter + * @param {string} cacheKey - Cache key for the indicators + * @param {Function} generator - Function to generate the indicators + * @returns {Object} Cached or newly generated indicators + */ +function getCachedIndicators(cacheKey, generator) { + if (INDICATOR_CACHE.has(cacheKey)) { + return INDICATOR_CACHE.get(cacheKey); + } + + const indicators = generator(); + INDICATOR_CACHE.set(cacheKey, indicators); + return indicators; +} + +/** + * Get priority indicators for MCP context (single emojis) + * @returns {Object} Priority to emoji mapping + */ +export function getMcpPriorityIndicators() { + return getCachedIndicators('mcp-priority-all', () => ({ + [HIGH]: VISUAL_STYLES.mcp.high, + [MEDIUM]: VISUAL_STYLES.mcp.medium, + [LOW]: VISUAL_STYLES.mcp.low + })); +} + +/** + * Get priority indicators for CLI context (colored dots with visual hierarchy) + * @returns {Object} Priority to colored dot string mapping + */ +export function getCliPriorityIndicators() { + return getCachedIndicators('cli-priority-all', () => { + const indicators = {}; + PRIORITY_CONFIG.levels.forEach((level) => { + const intensity = getIntensityFromLevel(level, PRIORITY_CONFIG.levels); + const color = PRIORITY_CONFIG.getColor(level); + indicators[level] = generateCliIndicator(intensity, color); + }); + return indicators; + }); +} + +/** + * Get priority indicators for status bars (simplified single character versions) + * @returns {Object} Priority to single character indicator mapping + */ +export function getStatusBarPriorityIndicators() { + return getCachedIndicators('statusbar-priority-all', () => { + const indicators = {}; + PRIORITY_CONFIG.levels.forEach((level, index) => { + const style = + index === 0 + ? VISUAL_STYLES.statusBar.high + : index === 1 + ? VISUAL_STYLES.statusBar.medium + : VISUAL_STYLES.statusBar.low; + const color = PRIORITY_CONFIG.getColor(level); + indicators[level] = color(style); + }); + return indicators; + }); +} + +/** + * Get priority colors for consistent styling + * @returns {Object} Priority to chalk color function mapping + */ +export function getPriorityColors() { + return { + [HIGH]: PRIORITY_CONFIG.colors[HIGH], + [MEDIUM]: PRIORITY_CONFIG.colors[MEDIUM], + [LOW]: PRIORITY_CONFIG.colors[LOW] + }; +} + +/** + * Get priority indicators based on context + * @param {boolean} isMcp - Whether this is for MCP context (true) or CLI context (false) + * @returns {Object} Priority to indicator mapping + */ +export function getPriorityIndicators(isMcp = false) { + return isMcp ? getMcpPriorityIndicators() : getCliPriorityIndicators(); +} + +/** + * Get a specific priority indicator + * @param {string} priority - The priority level ('high', 'medium', 'low') + * @param {boolean} isMcp - Whether this is for MCP context + * @returns {string} The indicator string for the priority + */ +export function getPriorityIndicator(priority, isMcp = false) { + const indicators = getPriorityIndicators(isMcp); + return indicators[priority] || indicators[MEDIUM]; +} + +// ============================================================================ +// Complexity Indicators +// ============================================================================ + +/** + * Complexity configuration + */ +const COMPLEXITY_CONFIG = new IndicatorConfig( + 'complexity', + ['high', 'medium', 'low'], + { + high: chalk.hex('#CC0000'), + medium: chalk.hex('#FF8800'), + low: chalk.green + }, + { + high: (score) => score >= 7, + medium: (score) => score >= 4 && score <= 6, + low: (score) => score <= 3 + } +); + +/** + * Get complexity indicators for CLI context (colored dots with visual hierarchy) + * Complexity scores: 1-3 (low), 4-6 (medium), 7-10 (high) + * @returns {Object} Complexity level to colored dot string mapping + */ +export function getCliComplexityIndicators() { + return getCachedIndicators('cli-complexity-all', () => { + const indicators = {}; + COMPLEXITY_CONFIG.levels.forEach((level) => { + const intensity = getIntensityFromLevel(level, COMPLEXITY_CONFIG.levels); + const color = COMPLEXITY_CONFIG.getColor(level); + indicators[level] = generateCliIndicator(intensity, color); + }); + return indicators; + }); +} + +/** + * Get complexity indicators for status bars (simplified single character versions) + * @returns {Object} Complexity level to single character indicator mapping + */ +export function getStatusBarComplexityIndicators() { + return getCachedIndicators('statusbar-complexity-all', () => { + const indicators = {}; + COMPLEXITY_CONFIG.levels.forEach((level, index) => { + const style = + index === 0 + ? VISUAL_STYLES.statusBar.high + : index === 1 + ? VISUAL_STYLES.statusBar.medium + : VISUAL_STYLES.statusBar.low; + const color = COMPLEXITY_CONFIG.getColor(level); + indicators[level] = color(style); + }); + return indicators; + }); +} + +/** + * Get complexity colors for consistent styling + * @returns {Object} Complexity level to chalk color function mapping + */ +export function getComplexityColors() { + return { ...COMPLEXITY_CONFIG.colors }; +} + +/** + * Get a specific complexity indicator based on score + * @param {number} score - The complexity score (1-10) + * @param {boolean} statusBar - Whether to return status bar version (single char) + * @returns {string} The indicator string for the complexity level + */ +export function getComplexityIndicator(score, statusBar = false) { + const level = COMPLEXITY_CONFIG.getLevelFromScore(score); + const indicators = statusBar + ? getStatusBarComplexityIndicators() + : getCliComplexityIndicators(); + return indicators[level]; +} diff --git a/src/ui/parse-prd.js b/src/ui/parse-prd.js new file mode 100644 index 00000000..70afe8d7 --- /dev/null +++ b/src/ui/parse-prd.js @@ -0,0 +1,477 @@ +/** + * parse-prd.js + * UI functions specifically for PRD parsing operations + */ + +import chalk from 'chalk'; +import boxen from 'boxen'; +import Table from 'cli-table3'; +import { formatElapsedTime } from '../utils/format.js'; + +// Constants +const CONSTANTS = { + BAR_WIDTH: 40, + TABLE_COL_WIDTHS: [28, 50], + DEFAULT_MODEL: 'Default', + DEFAULT_TEMPERATURE: 0.7 +}; + +const PRIORITIES = { + HIGH: 'high', + MEDIUM: 'medium', + LOW: 'low' +}; + +const PRIORITY_COLORS = { + [PRIORITIES.HIGH]: '#CC0000', + [PRIORITIES.MEDIUM]: '#FF8800', + [PRIORITIES.LOW]: '#FFCC00' +}; + +// Reusable box styles +const BOX_STYLES = { + main: { + padding: { top: 1, bottom: 1, left: 2, right: 2 }, + margin: { top: 0, bottom: 0 }, + borderColor: 'blue', + borderStyle: 'round' + }, + summary: { + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, right: 1, bottom: 1, left: 0 } + }, + warning: { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }, + nextSteps: { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1, right: 0, bottom: 1, left: 0 } + } +}; + +/** + * Helper function for building main message content + * @param {Object} params - Message parameters + * @param {string} params.prdFilePath - Path to the PRD file + * @param {string} params.outputPath - Path where tasks will be saved + * @param {number} params.numTasks - Number of tasks to generate + * @param {string} params.model - AI model name + * @param {number} params.temperature - AI temperature setting + * @param {boolean} params.append - Whether appending to existing tasks + * @param {boolean} params.research - Whether research mode is enabled + * @returns {string} The formatted message content + */ +function buildMainMessage({ + prdFilePath, + outputPath, + numTasks, + model, + temperature, + append, + research +}) { + const actionVerb = append ? 'Appending' : 'Generating'; + + let modelLine = `Model: ${model} | Temperature: ${temperature}`; + if (research) { + modelLine += ` | ${chalk.cyan.bold('🔬 Research Mode')}`; + } + + return ( + chalk.bold(`🤖 Parsing PRD and ${actionVerb} Tasks`) + + '\n' + + chalk.dim(modelLine) + + '\n\n' + + chalk.blue(`Input: ${prdFilePath}`) + + '\n' + + chalk.blue(`Output: ${outputPath}`) + + '\n' + + chalk.blue(`Tasks to ${append ? 'Append' : 'Generate'}: ${numTasks}`) + ); +} + +/** + * Helper function for displaying the main message box + * @param {string} message - The message content to display in the box + */ +function displayMainMessageBox(message) { + console.log(boxen(message, BOX_STYLES.main)); +} + +/** + * Helper function for displaying append mode notice + * @param {number} existingTasksCount - Number of existing tasks + * @param {number} nextId - Next ID to be used + */ +function displayAppendModeNotice(existingTasksCount, nextId) { + console.log( + chalk.yellow.bold('📝 Append mode') + + ` - Adding to ${existingTasksCount} existing tasks (next ID: ${nextId})` + ); +} + +/** + * Helper function for force mode messages + * @param {boolean} append - Whether in append mode + * @returns {string} The formatted force mode message + */ +function createForceMessage(append) { + const baseMessage = chalk.red.bold('⚠️ Force flag enabled'); + return append + ? `${baseMessage} - Will overwrite if conflicts occur` + : `${baseMessage} - Overwriting existing tasks`; +} + +/** + * Display the start of PRD parsing with a boxen announcement + * @param {Object} options - Options for PRD parsing start + * @param {string} options.prdFilePath - Path to the PRD file being parsed + * @param {string} options.outputPath - Path where the tasks will be saved + * @param {number} options.numTasks - Number of tasks to generate + * @param {string} [options.model] - AI model name + * @param {number} [options.temperature] - AI temperature setting + * @param {boolean} [options.append=false] - Whether to append to existing tasks + * @param {boolean} [options.research=false] - Whether research mode is enabled + * @param {boolean} [options.force=false] - Whether force mode is enabled + * @param {Array} [options.existingTasks=[]] - Existing tasks array + * @param {number} [options.nextId=1] - Next ID to be used + */ +function displayParsePrdStart({ + prdFilePath, + outputPath, + numTasks, + model = CONSTANTS.DEFAULT_MODEL, + temperature = CONSTANTS.DEFAULT_TEMPERATURE, + append = false, + research = false, + force = false, + existingTasks = [], + nextId = 1 +}) { + // Input validation + if ( + !prdFilePath || + typeof prdFilePath !== 'string' || + prdFilePath.trim() === '' + ) { + throw new Error('prdFilePath is required and must be a non-empty string'); + } + if ( + !outputPath || + typeof outputPath !== 'string' || + outputPath.trim() === '' + ) { + throw new Error('outputPath is required and must be a non-empty string'); + } + + // Build and display the main message box + const message = buildMainMessage({ + prdFilePath, + outputPath, + numTasks, + model, + temperature, + append, + research + }); + displayMainMessageBox(message); + + // Display append/force notices beneath the boxen if either flag is set + if (append || force) { + // Add append mode details if enabled + if (append) { + displayAppendModeNotice(existingTasks.length, nextId); + } + + // Add force mode details if enabled + if (force) { + console.log(createForceMessage(append)); + } + + // Add a blank line after notices for spacing + console.log(); + } +} + +/** + * Calculate priority statistics + * @param {Object} taskPriorities - Priority counts object + * @param {number} totalTasks - Total number of tasks + * @returns {Object} Priority statistics with counts and percentages + */ +function calculatePriorityStats(taskPriorities, totalTasks) { + const stats = {}; + + Object.values(PRIORITIES).forEach((priority) => { + const count = taskPriorities[priority] || 0; + stats[priority] = { + count, + percentage: totalTasks > 0 ? Math.round((count / totalTasks) * 100) : 0 + }; + }); + + return stats; +} + +/** + * Calculate bar character distribution for priorities + * @param {Object} priorityStats - Priority statistics + * @param {number} totalTasks - Total number of tasks + * @returns {Object} Character counts for each priority + */ +function calculateBarDistribution(priorityStats, totalTasks) { + const barWidth = CONSTANTS.BAR_WIDTH; + const distribution = {}; + + if (totalTasks === 0) { + Object.values(PRIORITIES).forEach((priority) => { + distribution[priority] = 0; + }); + return distribution; + } + + // Calculate raw proportions + const rawChars = {}; + Object.values(PRIORITIES).forEach((priority) => { + rawChars[priority] = + (priorityStats[priority].count / totalTasks) * barWidth; + }); + + // Initial distribution - floor values + Object.values(PRIORITIES).forEach((priority) => { + distribution[priority] = Math.floor(rawChars[priority]); + }); + + // Ensure non-zero priorities get at least 1 character + Object.values(PRIORITIES).forEach((priority) => { + if (priorityStats[priority].count > 0 && distribution[priority] === 0) { + distribution[priority] = 1; + } + }); + + // Distribute remaining characters based on decimal parts + const currentTotal = Object.values(distribution).reduce( + (sum, val) => sum + val, + 0 + ); + const remainingChars = barWidth - currentTotal; + + if (remainingChars > 0) { + const decimals = Object.values(PRIORITIES) + .map((priority) => ({ + priority, + decimal: rawChars[priority] - Math.floor(rawChars[priority]) + })) + .sort((a, b) => b.decimal - a.decimal); + + for (let i = 0; i < remainingChars && i < decimals.length; i++) { + distribution[decimals[i].priority]++; + } + } + + return distribution; +} + +/** + * Create priority distribution bar visual + * @param {Object} barDistribution - Character distribution for priorities + * @returns {string} Visual bar string + */ +function createPriorityBar(barDistribution) { + let bar = ''; + + bar += chalk.hex(PRIORITY_COLORS[PRIORITIES.HIGH])( + '█'.repeat(barDistribution[PRIORITIES.HIGH]) + ); + bar += chalk.hex(PRIORITY_COLORS[PRIORITIES.MEDIUM])( + '█'.repeat(barDistribution[PRIORITIES.MEDIUM]) + ); + bar += chalk.yellow('█'.repeat(barDistribution[PRIORITIES.LOW])); + + const totalChars = Object.values(barDistribution).reduce( + (sum, val) => sum + val, + 0 + ); + if (totalChars < CONSTANTS.BAR_WIDTH) { + bar += chalk.gray('░'.repeat(CONSTANTS.BAR_WIDTH - totalChars)); + } + + return bar; +} + +/** + * Build priority distribution row for table + * @param {Object} priorityStats - Priority statistics + * @returns {Array} Table row for priority distribution + */ +function buildPriorityRow(priorityStats) { + const parts = []; + + Object.entries(PRIORITIES).forEach(([key, priority]) => { + const stats = priorityStats[priority]; + const color = + priority === PRIORITIES.HIGH + ? chalk.hex(PRIORITY_COLORS[PRIORITIES.HIGH]) + : priority === PRIORITIES.MEDIUM + ? chalk.hex(PRIORITY_COLORS[PRIORITIES.MEDIUM]) + : chalk.yellow; + + const label = key.charAt(0) + key.slice(1).toLowerCase(); + parts.push( + `${color.bold(stats.count)} ${color(label)} (${stats.percentage}%)` + ); + }); + + return [chalk.cyan('Priority distribution:'), parts.join(' · ')]; +} + +/** + * Display a summary of the PRD parsing results + * @param {Object} summary - Summary of the parsing results + * @param {number} summary.totalTasks - Total number of tasks generated + * @param {string} summary.prdFilePath - Path to the PRD file + * @param {string} summary.outputPath - Path where the tasks were saved + * @param {number} summary.elapsedTime - Total elapsed time in seconds + * @param {Object} summary.taskPriorities - Breakdown of tasks by category/priority + * @param {boolean} summary.usedFallback - Whether fallback parsing was used + * @param {string} summary.actionVerb - Whether tasks were 'generated' or 'appended' + */ +function displayParsePrdSummary(summary) { + const { + totalTasks, + taskPriorities = {}, + prdFilePath, + outputPath, + elapsedTime, + usedFallback = false, + actionVerb = 'generated' + } = summary; + + // Format the elapsed time + const timeDisplay = formatElapsedTime(elapsedTime); + + // Create a table for better alignment + const table = new Table({ + chars: { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: ' ' + }, + style: { border: [], 'padding-left': 2 }, + colWidths: CONSTANTS.TABLE_COL_WIDTHS + }); + + // Basic info + // Use the action verb to properly display if tasks were generated or appended + table.push( + [chalk.cyan(`Total tasks ${actionVerb}:`), chalk.bold(totalTasks)], + [chalk.cyan('Processing time:'), chalk.bold(timeDisplay)] + ); + + // Priority distribution if available + if (taskPriorities && Object.keys(taskPriorities).length > 0) { + const priorityStats = calculatePriorityStats(taskPriorities, totalTasks); + const priorityRow = buildPriorityRow(priorityStats); + table.push(priorityRow); + + // Visual bar representation + const barDistribution = calculateBarDistribution(priorityStats, totalTasks); + const distributionBar = createPriorityBar(barDistribution); + table.push([chalk.cyan('Distribution:'), distributionBar]); + } + + // Add file paths + table.push( + [chalk.cyan('PRD source:'), chalk.italic(prdFilePath)], + [chalk.cyan('Tasks file:'), chalk.italic(outputPath)] + ); + + // Add fallback parsing indicator if applicable + if (usedFallback) { + table.push([ + chalk.yellow('Fallback parsing:'), + chalk.yellow('✓ Used fallback parsing') + ]); + } + + // Final string output with title and footer + const output = [ + chalk.bold.underline( + `PRD Parsing Complete - Tasks ${actionVerb.charAt(0).toUpperCase() + actionVerb.slice(1)}` + ), + '', + table.toString() + ].join('\n'); + + // Display the summary box + console.log(boxen(output, BOX_STYLES.summary)); + + // Show fallback parsing warning if needed + if (usedFallback) { + displayFallbackWarning(); + } + + // Show next steps + displayNextSteps(); +} + +/** + * Display fallback parsing warning + */ +function displayFallbackWarning() { + const warningContent = + chalk.yellow.bold('⚠️ Fallback Parsing Used') + + '\n\n' + + chalk.white( + 'The system used fallback parsing to complete task generation.' + ) + + '\n' + + chalk.white( + 'This typically happens when streaming JSON parsing is incomplete.' + ) + + '\n' + + chalk.white('Your tasks were successfully generated, but consider:') + + '\n' + + chalk.white('• Reviewing task completeness') + + '\n' + + chalk.white('• Checking for any missing details') + + '\n\n' + + chalk.white("This is normal and usually doesn't indicate any issues."); + + console.log(boxen(warningContent, BOX_STYLES.warning)); +} + +/** + * Display next steps after parsing + */ +function displayNextSteps() { + const stepsContent = + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=')} to break down a task into subtasks\n` + + `${chalk.cyan('3.')} Run ${chalk.yellow('task-master analyze-complexity')} to analyze task complexity`; + + console.log(boxen(stepsContent, BOX_STYLES.nextSteps)); +} + +export { displayParsePrdStart, displayParsePrdSummary, formatElapsedTime }; diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 00000000..affc7b40 --- /dev/null +++ b/src/utils/format.js @@ -0,0 +1,12 @@ +// src/utils/format.js + +/** + * Formats elapsed time as 0m 00s. + * @param {number} seconds - Elapsed time in seconds + * @returns {string} Formatted time string + */ +export function formatElapsedTime(seconds) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}m ${remainingSeconds.toString().padStart(2, '0')}s`; +} diff --git a/src/utils/stream-parser.js b/src/utils/stream-parser.js new file mode 100644 index 00000000..a0b20c61 --- /dev/null +++ b/src/utils/stream-parser.js @@ -0,0 +1,490 @@ +import { JSONParser } from '@streamparser/json'; + +/** + * Custom error class for streaming-related failures + * Provides error codes for robust error handling without string matching + */ +export class StreamingError extends Error { + constructor(message, code) { + super(message); + this.name = 'StreamingError'; + this.code = code; + + // Maintain proper stack trace (V8 engines) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, StreamingError); + } + } +} + +/** + * Standard streaming error codes + */ +export const STREAMING_ERROR_CODES = { + NOT_ASYNC_ITERABLE: 'STREAMING_NOT_SUPPORTED', + STREAM_PROCESSING_FAILED: 'STREAM_PROCESSING_FAILED', + STREAM_NOT_ITERABLE: 'STREAM_NOT_ITERABLE', + BUFFER_SIZE_EXCEEDED: 'BUFFER_SIZE_EXCEEDED' +}; + +/** + * Default maximum buffer size (1MB) + */ +export const DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024; // 1MB in bytes + +/** + * Configuration options for the streaming JSON parser + */ +class StreamParserConfig { + constructor(config = {}) { + this.jsonPaths = config.jsonPaths; + this.onProgress = config.onProgress; + this.onError = config.onError; + this.estimateTokens = + config.estimateTokens || ((text) => Math.ceil(text.length / 4)); + this.expectedTotal = config.expectedTotal || 0; + this.fallbackItemExtractor = config.fallbackItemExtractor; + this.itemValidator = + config.itemValidator || StreamParserConfig.defaultItemValidator; + this.maxBufferSize = config.maxBufferSize || DEFAULT_MAX_BUFFER_SIZE; + + this.validate(); + } + + validate() { + if (!this.jsonPaths || !Array.isArray(this.jsonPaths)) { + throw new Error('jsonPaths is required and must be an array'); + } + if (this.jsonPaths.length === 0) { + throw new Error('jsonPaths array cannot be empty'); + } + if (this.maxBufferSize <= 0) { + throw new Error('maxBufferSize must be positive'); + } + if (this.expectedTotal < 0) { + throw new Error('expectedTotal cannot be negative'); + } + if (this.estimateTokens && typeof this.estimateTokens !== 'function') { + throw new Error('estimateTokens must be a function'); + } + if (this.onProgress && typeof this.onProgress !== 'function') { + throw new Error('onProgress must be a function'); + } + if (this.onError && typeof this.onError !== 'function') { + throw new Error('onError must be a function'); + } + if ( + this.fallbackItemExtractor && + typeof this.fallbackItemExtractor !== 'function' + ) { + throw new Error('fallbackItemExtractor must be a function'); + } + if (this.itemValidator && typeof this.itemValidator !== 'function') { + throw new Error('itemValidator must be a function'); + } + } + + static defaultItemValidator(item) { + return ( + item && item.title && typeof item.title === 'string' && item.title.trim() + ); + } +} + +/** + * Manages progress tracking and metadata + */ +class ProgressTracker { + constructor(config) { + this.onProgress = config.onProgress; + this.onError = config.onError; + this.estimateTokens = config.estimateTokens; + this.expectedTotal = config.expectedTotal; + this.parsedItems = []; + this.accumulatedText = ''; + } + + addItem(item) { + this.parsedItems.push(item); + this.reportProgress(item); + } + + addText(chunk) { + this.accumulatedText += chunk; + } + + getMetadata() { + return { + currentCount: this.parsedItems.length, + expectedTotal: this.expectedTotal, + accumulatedText: this.accumulatedText, + estimatedTokens: this.estimateTokens(this.accumulatedText) + }; + } + + reportProgress(item) { + if (!this.onProgress) return; + + try { + this.onProgress(item, this.getMetadata()); + } catch (progressError) { + this.handleProgressError(progressError); + } + } + + handleProgressError(error) { + if (this.onError) { + this.onError(new Error(`Progress callback failed: ${error.message}`)); + } + } +} + +/** + * Handles stream processing with different stream types + */ +class StreamProcessor { + constructor(onChunk) { + this.onChunk = onChunk; + } + + async process(textStream) { + const streamHandler = this.detectStreamType(textStream); + await streamHandler(textStream); + } + + detectStreamType(textStream) { + // Check for textStream property + if (this.hasAsyncIterator(textStream?.textStream)) { + return (stream) => this.processTextStream(stream.textStream); + } + + // Check for fullStream property + if (this.hasAsyncIterator(textStream?.fullStream)) { + return (stream) => this.processFullStream(stream.fullStream); + } + + // Check if stream itself is iterable + if (this.hasAsyncIterator(textStream)) { + return (stream) => this.processDirectStream(stream); + } + + throw new StreamingError( + 'Stream object is not iterable - no textStream, fullStream, or direct async iterator found', + STREAMING_ERROR_CODES.STREAM_NOT_ITERABLE + ); + } + + hasAsyncIterator(obj) { + return obj && typeof obj[Symbol.asyncIterator] === 'function'; + } + + async processTextStream(stream) { + for await (const chunk of stream) { + this.onChunk(chunk); + } + } + + async processFullStream(stream) { + for await (const chunk of stream) { + if (chunk.type === 'text-delta' && chunk.textDelta) { + this.onChunk(chunk.textDelta); + } + } + } + + async processDirectStream(stream) { + for await (const chunk of stream) { + this.onChunk(chunk); + } + } +} + +/** + * Manages JSON parsing with the streaming parser + */ +class JSONStreamParser { + constructor(config, progressTracker) { + this.config = config; + this.progressTracker = progressTracker; + this.parser = new JSONParser({ paths: config.jsonPaths }); + this.setupHandlers(); + } + + setupHandlers() { + this.parser.onValue = (value, key, parent, stack) => { + this.handleParsedValue(value); + }; + + this.parser.onError = (error) => { + this.handleParseError(error); + }; + } + + handleParsedValue(value) { + // Extract the actual item object from the parser's nested structure + const item = value.value || value; + + if (this.config.itemValidator(item)) { + this.progressTracker.addItem(item); + } + } + + handleParseError(error) { + if (this.config.onError) { + this.config.onError(new Error(`JSON parsing error: ${error.message}`)); + } + // Don't throw here - we'll handle this in the fallback logic + } + + write(chunk) { + this.parser.write(chunk); + } + + end() { + this.parser.end(); + } +} + +/** + * Handles fallback parsing when streaming fails + */ +class FallbackParser { + constructor(config, progressTracker) { + this.config = config; + this.progressTracker = progressTracker; + } + + async attemptParsing() { + if (!this.shouldAttemptFallback()) { + return []; + } + + try { + return await this.parseFallbackItems(); + } catch (parseError) { + this.handleFallbackError(parseError); + return []; + } + } + + shouldAttemptFallback() { + return ( + this.config.expectedTotal > 0 && + this.progressTracker.parsedItems.length < this.config.expectedTotal && + this.progressTracker.accumulatedText && + this.config.fallbackItemExtractor + ); + } + + async parseFallbackItems() { + const jsonText = this._cleanJsonText(this.progressTracker.accumulatedText); + const fullResponse = JSON.parse(jsonText); + const fallbackItems = this.config.fallbackItemExtractor(fullResponse); + + if (!Array.isArray(fallbackItems)) { + return []; + } + + return this._processNewItems(fallbackItems); + } + + _cleanJsonText(text) { + // Remove markdown code block wrappers and trim whitespace + return text + .replace(/^```(?:json)?\s*\n?/i, '') + .replace(/\n?```\s*$/i, '') + .trim(); + } + + _processNewItems(fallbackItems) { + // Only add items we haven't already parsed + const itemsToAdd = fallbackItems.slice( + this.progressTracker.parsedItems.length + ); + const newItems = []; + + for (const item of itemsToAdd) { + if (this.config.itemValidator(item)) { + newItems.push(item); + this.progressTracker.addItem(item); + } + } + + return newItems; + } + + handleFallbackError(error) { + if (this.progressTracker.parsedItems.length === 0) { + throw new Error(`Failed to parse AI response as JSON: ${error.message}`); + } + // If we have some items from streaming, continue with those + } +} + +/** + * Buffer size validator + */ +class BufferSizeValidator { + constructor(maxSize) { + this.maxSize = maxSize; + this.currentSize = 0; + } + + validateChunk(existingText, newChunk) { + const newSize = Buffer.byteLength(existingText + newChunk, 'utf8'); + + if (newSize > this.maxSize) { + throw new StreamingError( + `Buffer size exceeded: ${newSize} bytes > ${this.maxSize} bytes maximum`, + STREAMING_ERROR_CODES.BUFFER_SIZE_EXCEEDED + ); + } + + this.currentSize = newSize; + } +} + +/** + * Main orchestrator for stream parsing + */ +class StreamParserOrchestrator { + constructor(config) { + this.config = new StreamParserConfig(config); + this.progressTracker = new ProgressTracker(this.config); + this.bufferValidator = new BufferSizeValidator(this.config.maxBufferSize); + this.jsonParser = new JSONStreamParser(this.config, this.progressTracker); + this.fallbackParser = new FallbackParser(this.config, this.progressTracker); + } + + async parse(textStream) { + if (!textStream) { + throw new Error('No text stream provided'); + } + + await this.processStream(textStream); + await this.waitForParsingCompletion(); + + const usedFallback = await this.attemptFallbackIfNeeded(); + + return this.buildResult(usedFallback); + } + + async processStream(textStream) { + const processor = new StreamProcessor((chunk) => { + this.bufferValidator.validateChunk( + this.progressTracker.accumulatedText, + chunk + ); + this.progressTracker.addText(chunk); + this.jsonParser.write(chunk); + }); + + try { + await processor.process(textStream); + } catch (streamError) { + this.handleStreamError(streamError); + } + + this.jsonParser.end(); + } + + handleStreamError(error) { + // Re-throw StreamingError as-is, wrap other errors + if (error instanceof StreamingError) { + throw error; + } + throw new StreamingError( + `Failed to process AI text stream: ${error.message}`, + STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED + ); + } + + async waitForParsingCompletion() { + // Wait for final parsing to complete (JSON parser may still be processing) + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + async attemptFallbackIfNeeded() { + const fallbackItems = await this.fallbackParser.attemptParsing(); + return fallbackItems.length > 0; + } + + buildResult(usedFallback) { + const metadata = this.progressTracker.getMetadata(); + + return { + items: this.progressTracker.parsedItems, + accumulatedText: metadata.accumulatedText, + estimatedTokens: metadata.estimatedTokens, + usedFallback + }; + } +} + +/** + * Parse a streaming JSON response with progress tracking + * + * Example with custom buffer size: + * ```js + * const result = await parseStream(stream, { + * jsonPaths: ['$.tasks.*'], + * maxBufferSize: 2 * 1024 * 1024 // 2MB + * }); + * ``` + * + * @param {Object} textStream - The AI service text stream object + * @param {Object} config - Configuration options + * @returns {Promise} Parsed result with metadata + */ +export async function parseStream(textStream, config = {}) { + const orchestrator = new StreamParserOrchestrator(config); + return orchestrator.parse(textStream); +} + +/** + * Process different types of text streams + * @param {Object} textStream - The stream object from AI service + * @param {Function} onChunk - Callback for each text chunk + */ +export async function processTextStream(textStream, onChunk) { + const processor = new StreamProcessor(onChunk); + await processor.process(textStream); +} + +/** + * Attempt fallback JSON parsing when streaming parsing is incomplete + * @param {string} accumulatedText - Complete accumulated text + * @param {Array} existingItems - Items already parsed from streaming + * @param {number} expectedTotal - Expected total number of items + * @param {Object} config - Configuration for progress reporting + * @returns {Promise} Additional items found via fallback parsing + */ +export async function attemptFallbackParsing( + accumulatedText, + existingItems, + expectedTotal, + config +) { + // Create a temporary progress tracker for backward compatibility + const progressTracker = new ProgressTracker({ + onProgress: config.onProgress, + onError: config.onError, + estimateTokens: config.estimateTokens, + expectedTotal + }); + + progressTracker.parsedItems = existingItems; + progressTracker.accumulatedText = accumulatedText; + + const fallbackParser = new FallbackParser( + { + ...config, + expectedTotal, + itemValidator: + config.itemValidator || StreamParserConfig.defaultItemValidator + }, + progressTracker + ); + + return fallbackParser.attemptParsing(); +} diff --git a/src/utils/timeout-manager.js b/src/utils/timeout-manager.js new file mode 100644 index 00000000..200f21ad --- /dev/null +++ b/src/utils/timeout-manager.js @@ -0,0 +1,189 @@ +import { StreamingError, STREAMING_ERROR_CODES } from './stream-parser.js'; + +/** + * Utility class for managing timeouts in async operations + * Reduces code duplication for timeout handling patterns + */ +export class TimeoutManager { + /** + * Wraps a promise with a timeout that will reject if not resolved in time + * + * @param {Promise} promise - The promise to wrap with timeout + * @param {number} timeoutMs - Timeout duration in milliseconds + * @param {string} operationName - Name of the operation for error messages + * @returns {Promise} The result of the promise or throws timeout error + * + * @example + * const result = await TimeoutManager.withTimeout( + * fetchData(), + * 5000, + * 'Data fetch operation' + * ); + */ + static async withTimeout(promise, timeoutMs, operationName = 'Operation') { + let timeoutHandle; + + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject( + new StreamingError( + `${operationName} timed out after ${timeoutMs / 1000} seconds`, + STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED + ) + ); + }, timeoutMs); + }); + + try { + // Race between the actual promise and the timeout + const result = await Promise.race([promise, timeoutPromise]); + // Clear timeout if promise resolved first + clearTimeout(timeoutHandle); + return result; + } catch (error) { + // Always clear timeout on error + clearTimeout(timeoutHandle); + throw error; + } + } + + /** + * Wraps a promise with a timeout, but returns undefined instead of throwing on timeout + * Useful for optional operations that shouldn't fail the main flow + * + * @param {Promise} promise - The promise to wrap with timeout + * @param {number} timeoutMs - Timeout duration in milliseconds + * @param {*} defaultValue - Value to return on timeout (default: undefined) + * @returns {Promise} The result of the promise or defaultValue on timeout + * + * @example + * const usage = await TimeoutManager.withSoftTimeout( + * getUsageStats(), + * 1000, + * { tokens: 0 } + * ); + */ + static async withSoftTimeout(promise, timeoutMs, defaultValue = undefined) { + let timeoutHandle; + + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + resolve(defaultValue); + }, timeoutMs); + }); + + try { + const result = await Promise.race([promise, timeoutPromise]); + clearTimeout(timeoutHandle); + return result; + } catch (error) { + // On error, clear timeout and return default value + clearTimeout(timeoutHandle); + return defaultValue; + } + } + + /** + * Creates a reusable timeout controller for multiple operations + * Useful when you need to apply the same timeout to multiple promises + * + * @param {number} timeoutMs - Timeout duration in milliseconds + * @param {string} operationName - Base name for operations + * @returns {Object} Controller with wrap method + * + * @example + * const controller = TimeoutManager.createController(60000, 'AI Service'); + * const result1 = await controller.wrap(service.call1(), 'call 1'); + * const result2 = await controller.wrap(service.call2(), 'call 2'); + */ + static createController(timeoutMs, operationName = 'Operation') { + return { + timeoutMs, + operationName, + + async wrap(promise, specificName = null) { + const fullName = specificName + ? `${operationName} - ${specificName}` + : operationName; + return TimeoutManager.withTimeout(promise, timeoutMs, fullName); + }, + + async wrapSoft(promise, defaultValue = undefined) { + return TimeoutManager.withSoftTimeout(promise, timeoutMs, defaultValue); + } + }; + } + + /** + * Checks if an error is a timeout error from this manager + * + * @param {Error} error - The error to check + * @returns {boolean} True if this is a timeout error + */ + static isTimeoutError(error) { + return ( + error instanceof StreamingError && + error.code === STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED && + error.message.includes('timed out') + ); + } +} + +/** + * Duration helper class for more readable timeout specifications + */ +export class Duration { + constructor(value, unit = 'ms') { + this.milliseconds = this._toMilliseconds(value, unit); + } + + static milliseconds(value) { + return new Duration(value, 'ms'); + } + + static seconds(value) { + return new Duration(value, 's'); + } + + static minutes(value) { + return new Duration(value, 'm'); + } + + static hours(value) { + return new Duration(value, 'h'); + } + + get seconds() { + return this.milliseconds / 1000; + } + + get minutes() { + return this.milliseconds / 60000; + } + + get hours() { + return this.milliseconds / 3600000; + } + + toString() { + if (this.milliseconds < 1000) { + return `${this.milliseconds}ms`; + } else if (this.milliseconds < 60000) { + return `${this.seconds}s`; + } else if (this.milliseconds < 3600000) { + return `${Math.floor(this.minutes)}m ${Math.floor(this.seconds % 60)}s`; + } else { + return `${Math.floor(this.hours)}h ${Math.floor(this.minutes % 60)}m`; + } + } + + _toMilliseconds(value, unit) { + const conversions = { + ms: 1, + s: 1000, + m: 60000, + h: 3600000 + }; + return value * (conversions[unit] || 1); + } +} diff --git a/tests/manual/progress/TESTING_GUIDE.md b/tests/manual/progress/TESTING_GUIDE.md new file mode 100644 index 00000000..8e5a1391 --- /dev/null +++ b/tests/manual/progress/TESTING_GUIDE.md @@ -0,0 +1,97 @@ +# Task Master Progress Testing Guide + +Quick reference for testing streaming/non-streaming functionality with token tracking. + +## 🎯 Test Modes + +1. **MCP Streaming** - Has `reportProgress` + `mcpLog`, shows emoji indicators (🔴🟠🟢) +2. **CLI Streaming** - No `reportProgress`, shows terminal progress bars +3. **Non-Streaming** - No progress reporting, single response + +## 🚀 Quick Commands + +```bash +# Test Scripts (accept: mcp-streaming, cli-streaming, non-streaming, both, all) +node test-parse-prd.js [mode] +node test-analyze-complexity.js [mode] +node test-expand.js [mode] [num_subtasks] +node test-expand-all.js [mode] [num_subtasks] +node parse-prd-analysis.js [accuracy|complexity|all] + +# CLI Commands +node scripts/dev.js parse-prd test.txt # Local dev (streaming) +node scripts/dev.js analyze-complexity --research +node scripts/dev.js expand --id=1 --force +node scripts/dev.js expand --all --force + +task-master [command] # Global CLI (non-streaming) +``` + +## ✅ Success Indicators + +### Indicators +- **Priority**: 🔴🔴🔴 (high), 🟠🟠⚪ (medium), 🟢⚪⚪ (low) +- **Complexity**: ●●● (7-10), ●●○ (4-6), ●○○ (1-3) + +### Token Format +`Tokens (I/O): 2,150/1,847 ($0.0423)` (~4 chars per token) + +### Progress Bars +``` +Single: Generating subtasks... |████████░░| 80% (4/5) +Dual: Expanding 3 tasks | Task 2/3 |████████░░| 66% + Generating 5 subtasks... |██████░░░░| 60% +``` + +### Fractional Progress +`(completedTasks + currentSubtask/totalSubtasks) / totalTasks` +Example: 33% → 46% → 60% → 66% → 80% → 93% → 100% + +## 🐛 Quick Fixes + +| Issue | Fix | +|-------|-----| +| No streaming | Check `reportProgress` is passed | +| NaN% progress | Filter duplicate `subtask_progress` events | +| Missing tokens | Check `.env` has API keys | +| Broken bars | Terminal width > 80 | +| projectRoot.split | Use `projectRoot` not `session` | + +```bash +# Debug +TASKMASTER_DEBUG=true node test-expand.js +npm run lint +``` + +## 📊 Benchmarks +- Single task: 10-20s (5 subtasks) +- Expand all: 30-45s (3 tasks) +- Streaming: ~10-20% faster +- Updates: Every 2-5s + +## 🔄 Test Workflow + +```bash +# Quick check +node test-parse-prd.js both && npm test + +# Full suite (before release) +for test in parse-prd analyze-complexity expand expand-all; do + node test-$test.js all +done +node parse-prd-analysis.js all +npm test +``` + +## 🎯 MCP Tool Example + +```javascript +{ + "tool": "parse_prd", + "args": { + "input": "prd.txt", + "numTasks": "8", + "force": true, + "projectRoot": "/path/to/project" + } +} diff --git a/tests/manual/progress/parse-prd-analysis.js b/tests/manual/progress/parse-prd-analysis.js new file mode 100644 index 00000000..70be639e --- /dev/null +++ b/tests/manual/progress/parse-prd-analysis.js @@ -0,0 +1,334 @@ +#!/usr/bin/env node + +/** + * parse-prd-analysis.js + * + * Detailed timing and accuracy analysis for parse-prd progress reporting. + * Tests different task generation complexities using the sample PRD from fixtures. + * Validates real-time characteristics and focuses on progress behavior and performance metrics. + * Uses tests/fixtures/sample-prd.txt for consistent testing across all scenarios. + */ + +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +import parsePRD from '../../../scripts/modules/task-manager/parse-prd/index.js'; + +// Use the same project root as the main test file +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +/** + * Get the path to the sample PRD file + */ +function getSamplePRDPath() { + return path.resolve(PROJECT_ROOT, 'tests', 'fixtures', 'sample-prd.txt'); +} + +/** + * Detailed Progress Reporter for timing analysis + */ +class DetailedProgressReporter { + constructor() { + this.progressHistory = []; + this.startTime = Date.now(); + this.lastProgress = 0; + } + + async reportProgress(data) { + const timestamp = Date.now() - this.startTime; + const timeSinceLastProgress = + this.progressHistory.length > 0 + ? timestamp - + this.progressHistory[this.progressHistory.length - 1].timestamp + : timestamp; + + const entry = { + timestamp, + timeSinceLastProgress, + ...data + }; + + this.progressHistory.push(entry); + + const percentage = data.total + ? Math.round((data.progress / data.total) * 100) + : 0; + console.log( + chalk.blue(`[${timestamp}ms] (+${timeSinceLastProgress}ms)`), + chalk.green(`${percentage}%`), + `(${data.progress}/${data.total})`, + chalk.yellow(data.message) + ); + } + + getAnalysis() { + if (this.progressHistory.length === 0) return null; + + const totalDuration = + this.progressHistory[this.progressHistory.length - 1].timestamp; + const intervals = this.progressHistory + .slice(1) + .map((entry) => entry.timeSinceLastProgress); + const avgInterval = + intervals.length > 0 + ? intervals.reduce((a, b) => a + b, 0) / intervals.length + : 0; + const minInterval = intervals.length > 0 ? Math.min(...intervals) : 0; + const maxInterval = intervals.length > 0 ? Math.max(...intervals) : 0; + + return { + totalReports: this.progressHistory.length, + totalDuration, + avgInterval: Math.round(avgInterval), + minInterval, + maxInterval, + intervals + }; + } + + printDetailedAnalysis() { + const analysis = this.getAnalysis(); + if (!analysis) { + console.log(chalk.red('No progress data to analyze')); + return; + } + + console.log(chalk.cyan('\n=== Detailed Progress Analysis ===')); + console.log(`Total Progress Reports: ${analysis.totalReports}`); + console.log(`Total Duration: ${analysis.totalDuration}ms`); + console.log(`Average Interval: ${analysis.avgInterval}ms`); + console.log(`Min Interval: ${analysis.minInterval}ms`); + console.log(`Max Interval: ${analysis.maxInterval}ms`); + + console.log(chalk.cyan('\n=== Progress Timeline ===')); + this.progressHistory.forEach((entry, index) => { + const percentage = entry.total + ? Math.round((entry.progress / entry.total) * 100) + : 0; + const intervalText = + index > 0 ? ` (+${entry.timeSinceLastProgress}ms)` : ''; + console.log( + `${index + 1}. [${entry.timestamp}ms]${intervalText} ${percentage}% - ${entry.message}` + ); + }); + + // Check for real-time characteristics + console.log(chalk.cyan('\n=== Real-time Characteristics ===')); + const hasRealTimeUpdates = analysis.intervals.some( + (interval) => interval < 10000 + ); // Less than 10s + const hasConsistentUpdates = analysis.intervals.length > 3; + const hasProgressiveUpdates = this.progressHistory.every( + (entry, index) => + index === 0 || + entry.progress >= this.progressHistory[index - 1].progress + ); + + console.log(`✅ Real-time updates: ${hasRealTimeUpdates ? 'YES' : 'NO'}`); + console.log( + `✅ Consistent updates: ${hasConsistentUpdates ? 'YES' : 'NO'}` + ); + console.log( + `✅ Progressive updates: ${hasProgressiveUpdates ? 'YES' : 'NO'}` + ); + } +} + +/** + * Get PRD path for complexity testing + * For complexity testing, we'll use the same sample PRD but request different numbers of tasks + * This provides more realistic testing since the AI will generate different complexity based on task count + */ +function getPRDPathForComplexity(complexity = 'medium') { + // Always use the same sample PRD file - complexity will be controlled by task count + return getSamplePRDPath(); +} + +/** + * Test streaming with different task generation complexities + * Uses the same sample PRD but requests different numbers of tasks to test complexity scaling + */ +async function testStreamingComplexity() { + console.log( + chalk.cyan( + '🧪 Testing Streaming with Different Task Generation Complexities\n' + ) + ); + + const complexities = ['simple', 'medium', 'complex']; + const results = []; + + for (const complexity of complexities) { + console.log( + chalk.yellow(`\n--- Testing ${complexity.toUpperCase()} Complexity ---`) + ); + + const testPRDPath = getPRDPathForComplexity(complexity); + const testTasksPath = path.join(__dirname, `test-tasks-${complexity}.json`); + + // Clean up existing file + if (fs.existsSync(testTasksPath)) { + fs.unlinkSync(testTasksPath); + } + + const progressReporter = new DetailedProgressReporter(); + const expectedTasks = + complexity === 'simple' ? 3 : complexity === 'medium' ? 6 : 10; + + try { + const startTime = Date.now(); + + await parsePRD(testPRDPath, testTasksPath, expectedTasks, { + force: true, + append: false, + research: false, + reportProgress: progressReporter.reportProgress.bind(progressReporter), + projectRoot: PROJECT_ROOT + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + console.log( + chalk.green(`✅ ${complexity} complexity completed in ${duration}ms`) + ); + + progressReporter.printDetailedAnalysis(); + + results.push({ + complexity, + duration, + analysis: progressReporter.getAnalysis() + }); + } catch (error) { + console.error( + chalk.red(`❌ ${complexity} complexity failed: ${error.message}`) + ); + results.push({ + complexity, + error: error.message + }); + } finally { + // Clean up (only the tasks file, not the PRD since we're using the fixture) + if (fs.existsSync(testTasksPath)) fs.unlinkSync(testTasksPath); + } + } + + // Summary + console.log(chalk.cyan('\n=== Complexity Test Summary ===')); + results.forEach((result) => { + if (result.error) { + console.log(`${result.complexity}: ❌ FAILED - ${result.error}`); + } else { + console.log( + `${result.complexity}: ✅ ${result.duration}ms (${result.analysis.totalReports} reports)` + ); + } + }); + + return results; +} + +/** + * Test progress accuracy + */ +async function testProgressAccuracy() { + console.log(chalk.cyan('🧪 Testing Progress Accuracy\n')); + + const testPRDPath = getSamplePRDPath(); + const testTasksPath = path.join(__dirname, 'test-accuracy-tasks.json'); + + // Clean up existing file + if (fs.existsSync(testTasksPath)) { + fs.unlinkSync(testTasksPath); + } + + const progressReporter = new DetailedProgressReporter(); + + try { + await parsePRD(testPRDPath, testTasksPath, 8, { + force: true, + append: false, + research: false, + reportProgress: progressReporter.reportProgress.bind(progressReporter), + projectRoot: PROJECT_ROOT + }); + + console.log(chalk.green('✅ Progress accuracy test completed')); + progressReporter.printDetailedAnalysis(); + + // Additional accuracy checks + const analysis = progressReporter.getAnalysis(); + console.log(chalk.cyan('\n=== Accuracy Metrics ===')); + console.log( + `Progress consistency: ${analysis.intervals.every((i) => i > 0) ? 'PASS' : 'FAIL'}` + ); + console.log( + `Reasonable intervals: ${analysis.intervals.every((i) => i < 30000) ? 'PASS' : 'FAIL'}` + ); + console.log( + `Expected report count: ${analysis.totalReports >= 8 ? 'PASS' : 'FAIL'}` + ); + } catch (error) { + console.error( + chalk.red(`❌ Progress accuracy test failed: ${error.message}`) + ); + } finally { + // Clean up (only the tasks file, not the PRD since we're using the fixture) + if (fs.existsSync(testTasksPath)) fs.unlinkSync(testTasksPath); + } +} + +/** + * Main test runner + */ +async function main() { + const args = process.argv.slice(2); + const testType = args[0] || 'accuracy'; + + console.log(chalk.bold.cyan('🚀 Task Master Detailed Progress Tests\n')); + console.log(chalk.blue(`Test type: ${testType}\n`)); + + try { + switch (testType.toLowerCase()) { + case 'accuracy': + await testProgressAccuracy(); + break; + + case 'complexity': + await testStreamingComplexity(); + break; + + case 'all': + console.log(chalk.yellow('Running all detailed tests...\n')); + await testProgressAccuracy(); + console.log('\n' + '='.repeat(60) + '\n'); + await testStreamingComplexity(); + break; + + default: + console.log(chalk.red(`Unknown test type: ${testType}`)); + console.log( + chalk.yellow('Available options: accuracy, complexity, all') + ); + process.exit(1); + } + + console.log(chalk.green('\n🎉 Detailed tests completed successfully!')); + } catch (error) { + console.error(chalk.red(`\n❌ Test failed: ${error.message}`)); + console.error(chalk.red(error.stack)); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + // Top-level await is available in ESM; keep compatibility with Node ≥14 + await main(); +} diff --git a/tests/manual/progress/test-parse-prd.js b/tests/manual/progress/test-parse-prd.js new file mode 100644 index 00000000..7b3a5973 --- /dev/null +++ b/tests/manual/progress/test-parse-prd.js @@ -0,0 +1,577 @@ +#!/usr/bin/env node + +/** + * test-parse-prd.js + * + * Comprehensive integration test for parse-prd functionality. + * Tests MCP streaming, CLI streaming, and non-streaming modes. + * Validates token tracking, message formats, and priority indicators across all contexts. + */ + +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { fileURLToPath } from 'url'; + +// Get current directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Get project root (three levels up from tests/manual/progress/) +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +// Import the parse-prd function +import parsePRD from '../../../scripts/modules/task-manager/parse-prd/index.js'; + +/** + * Mock Progress Reporter for testing + */ +class MockProgressReporter { + constructor(enableDebug = true) { + this.enableDebug = enableDebug; + this.progressHistory = []; + this.startTime = Date.now(); + } + + async reportProgress(data) { + const timestamp = Date.now() - this.startTime; + + const entry = { + timestamp, + ...data + }; + + this.progressHistory.push(entry); + + if (this.enableDebug) { + const percentage = data.total + ? Math.round((data.progress / data.total) * 100) + : 0; + console.log( + chalk.blue(`[${timestamp}ms]`), + chalk.green(`${percentage}%`), + chalk.yellow(data.message) + ); + } + } + + getProgressHistory() { + return this.progressHistory; + } + + printSummary() { + console.log(chalk.green('\n=== Progress Summary ===')); + console.log(`Total progress reports: ${this.progressHistory.length}`); + console.log( + `Duration: ${this.progressHistory[this.progressHistory.length - 1]?.timestamp || 0}ms` + ); + + this.progressHistory.forEach((entry, index) => { + const percentage = entry.total + ? Math.round((entry.progress / entry.total) * 100) + : 0; + console.log( + `${index + 1}. [${entry.timestamp}ms] ${percentage}% - ${entry.message}` + ); + }); + + // Check for expected message formats + const hasInitialMessage = this.progressHistory.some( + (entry) => + entry.message.includes('Starting PRD analysis') && + entry.message.includes('Input:') && + entry.message.includes('tokens') + ); + // Make regex more flexible to handle potential whitespace variations + const hasTaskMessages = this.progressHistory.some((entry) => + /^[🔴🟠🟢⚪]{3} Task \d+\/\d+ - .+ \| ~Output: \d+ tokens/u.test( + entry.message.trim() + ) + ); + + const hasCompletionMessage = this.progressHistory.some( + (entry) => + entry.message.includes('✅ Task Generation Completed') && + entry.message.includes('Tokens (I/O):') + ); + + console.log(chalk.cyan('\n=== Message Format Validation ===')); + console.log( + `✅ Initial message format: ${hasInitialMessage ? 'PASS' : 'FAIL'}` + ); + console.log(`✅ Task message format: ${hasTaskMessages ? 'PASS' : 'FAIL'}`); + console.log( + `✅ Completion message format: ${hasCompletionMessage ? 'PASS' : 'FAIL'}` + ); + } +} + +/** + * Mock MCP Logger for testing + */ +class MockMCPLogger { + constructor(enableDebug = true) { + this.enableDebug = enableDebug; + this.logs = []; + } + + _log(level, ...args) { + const entry = { + level, + timestamp: Date.now(), + message: args.join(' ') + }; + this.logs.push(entry); + + if (this.enableDebug) { + const color = + { + info: chalk.blue, + warn: chalk.yellow, + error: chalk.red, + debug: chalk.gray, + success: chalk.green + }[level] || chalk.white; + + console.log(color(`[${level.toUpperCase()}]`), ...args); + } + } + + info(...args) { + this._log('info', ...args); + } + warn(...args) { + this._log('warn', ...args); + } + error(...args) { + this._log('error', ...args); + } + debug(...args) { + this._log('debug', ...args); + } + success(...args) { + this._log('success', ...args); + } + + getLogs() { + return this.logs; + } +} + +/** + * Get the path to the sample PRD file + */ +function getSamplePRDPath() { + return path.resolve(PROJECT_ROOT, 'tests', 'fixtures', 'sample-prd.txt'); +} + +/** + * Create a basic test config file + */ +function createTestConfig() { + const testConfig = { + models: { + main: { + provider: 'anthropic', + modelId: 'claude-3-5-sonnet', + maxTokens: 64000, + temperature: 0.2 + }, + research: { + provider: 'perplexity', + modelId: 'sonar-pro', + maxTokens: 8700, + temperature: 0.1 + }, + fallback: { + provider: 'anthropic', + modelId: 'claude-3-5-sonnet', + maxTokens: 64000, + temperature: 0.2 + } + }, + global: { + logLevel: 'info', + debug: false, + defaultSubtasks: 5, + defaultPriority: 'medium', + projectName: 'Task Master Test', + ollamaBaseURL: 'http://localhost:11434/api', + bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com' + } + }; + + const taskmasterDir = path.join(__dirname, '.taskmaster'); + const configPath = path.join(taskmasterDir, 'config.json'); + + // Create .taskmaster directory if it doesn't exist + if (!fs.existsSync(taskmasterDir)) { + fs.mkdirSync(taskmasterDir, { recursive: true }); + } + + fs.writeFileSync(configPath, JSON.stringify(testConfig, null, 2)); + return configPath; +} + +/** + * Setup test files and configuration + */ +function setupTestFiles(testName) { + const testPRDPath = getSamplePRDPath(); + const testTasksPath = path.join(__dirname, `test-${testName}-tasks.json`); + const configPath = createTestConfig(); + + // Clean up existing files + if (fs.existsSync(testTasksPath)) { + fs.unlinkSync(testTasksPath); + } + + return { testPRDPath, testTasksPath, configPath }; +} + +/** + * Clean up test files + */ +function cleanupTestFiles(testTasksPath, configPath) { + if (fs.existsSync(testTasksPath)) fs.unlinkSync(testTasksPath); + if (fs.existsSync(configPath)) fs.unlinkSync(configPath); +} + +/** + * Run parsePRD with configurable options + */ +async function runParsePRD(testPRDPath, testTasksPath, numTasks, options = {}) { + const startTime = Date.now(); + + const result = await parsePRD(testPRDPath, testTasksPath, numTasks, { + force: true, + append: false, + research: false, + projectRoot: PROJECT_ROOT, + ...options + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + return { result, duration }; +} + +/** + * Verify task file existence and structure + */ +function verifyTaskResults(testTasksPath) { + if (fs.existsSync(testTasksPath)) { + const tasksData = JSON.parse(fs.readFileSync(testTasksPath, 'utf8')); + console.log( + chalk.green( + `\n✅ Tasks file created with ${tasksData.tasks.length} tasks` + ) + ); + + // Verify task structure + const firstTask = tasksData.tasks[0]; + if (firstTask && firstTask.id && firstTask.title && firstTask.description) { + console.log(chalk.green('✅ Task structure is valid')); + return true; + } else { + console.log(chalk.red('❌ Task structure is invalid')); + return false; + } + } else { + console.log(chalk.red('❌ Tasks file was not created')); + return false; + } +} + +/** + * Print MCP-specific logs and validation + */ +function printMCPResults(mcpLogger, progressReporter) { + // Print progress summary + progressReporter.printSummary(); + + // Print MCP logs + console.log(chalk.cyan('\n=== MCP Logs ===')); + const logs = mcpLogger.getLogs(); + logs.forEach((log, index) => { + const color = + { + info: chalk.blue, + warn: chalk.yellow, + error: chalk.red, + debug: chalk.gray, + success: chalk.green + }[log.level] || chalk.white; + console.log( + `${index + 1}. ${color(`[${log.level.toUpperCase()}]`)} ${log.message}` + ); + }); + + // Verify MCP-specific message formats (should use emoji indicators) + const hasEmojiIndicators = progressReporter + .getProgressHistory() + .some((entry) => /[🔴🟠🟢]/u.test(entry.message)); + + console.log(chalk.cyan('\n=== MCP-Specific Validation ===')); + console.log( + `✅ Emoji priority indicators: ${hasEmojiIndicators ? 'PASS' : 'FAIL'}` + ); + + return { hasEmojiIndicators, logs }; +} + +/** + * Test MCP streaming with proper MCP context + */ +async function testMCPStreaming(numTasks = 10) { + console.log(chalk.cyan('🧪 Testing MCP Streaming Functionality\n')); + + const { testPRDPath, testTasksPath, configPath } = setupTestFiles('mcp'); + const progressReporter = new MockProgressReporter(true); + const mcpLogger = new MockMCPLogger(true); // Enable debug for MCP context + + try { + console.log(chalk.yellow('Starting MCP streaming test...')); + + const { result, duration } = await runParsePRD( + testPRDPath, + testTasksPath, + numTasks, + { + reportProgress: progressReporter.reportProgress.bind(progressReporter), + mcpLog: mcpLogger // Add MCP context - this is the key difference + } + ); + + console.log( + chalk.green(`\n✅ MCP streaming test completed in ${duration}ms`) + ); + + const { hasEmojiIndicators, logs } = printMCPResults( + mcpLogger, + progressReporter + ); + const isValidStructure = verifyTaskResults(testTasksPath); + + return { + success: true, + duration, + progressHistory: progressReporter.getProgressHistory(), + mcpLogs: logs, + hasEmojiIndicators, + result + }; + } catch (error) { + console.error(chalk.red(`❌ MCP streaming test failed: ${error.message}`)); + return { + success: false, + error: error.message + }; + } finally { + cleanupTestFiles(testTasksPath, configPath); + } +} + +/** + * Test CLI streaming (no reportProgress) + */ +async function testCLIStreaming(numTasks = 10) { + console.log(chalk.cyan('🧪 Testing CLI Streaming (No Progress Reporter)\n')); + + const { testPRDPath, testTasksPath, configPath } = setupTestFiles('cli'); + + try { + console.log(chalk.yellow('Starting CLI streaming test...')); + + // No reportProgress provided; CLI text mode uses the default streaming reporter + const { result, duration } = await runParsePRD( + testPRDPath, + testTasksPath, + numTasks + ); + + console.log( + chalk.green(`\n✅ CLI streaming test completed in ${duration}ms`) + ); + + const isValidStructure = verifyTaskResults(testTasksPath); + + return { + success: true, + duration, + result + }; + } catch (error) { + console.error(chalk.red(`❌ CLI streaming test failed: ${error.message}`)); + return { + success: false, + error: error.message + }; + } finally { + cleanupTestFiles(testTasksPath, configPath); + } +} + +/** + * Test non-streaming functionality + */ +async function testNonStreaming(numTasks = 10) { + console.log(chalk.cyan('🧪 Testing Non-Streaming Functionality\n')); + + const { testPRDPath, testTasksPath, configPath } = + setupTestFiles('non-streaming'); + + try { + console.log(chalk.yellow('Starting non-streaming test...')); + + // Force non-streaming by not providing reportProgress + const { result, duration } = await runParsePRD( + testPRDPath, + testTasksPath, + numTasks + ); + + console.log( + chalk.green(`\n✅ Non-streaming test completed in ${duration}ms`) + ); + + const isValidStructure = verifyTaskResults(testTasksPath); + + return { + success: true, + duration, + result + }; + } catch (error) { + console.error(chalk.red(`❌ Non-streaming test failed: ${error.message}`)); + return { + success: false, + error: error.message + }; + } finally { + cleanupTestFiles(testTasksPath, configPath); + } +} + +/** + * Compare results between streaming and non-streaming + */ +function compareResults(streamingResult, nonStreamingResult) { + console.log(chalk.cyan('\n=== Results Comparison ===')); + + if (!streamingResult.success || !nonStreamingResult.success) { + console.log(chalk.red('❌ Cannot compare - one or both tests failed')); + return; + } + + console.log(`Streaming duration: ${streamingResult.duration}ms`); + console.log(`Non-streaming duration: ${nonStreamingResult.duration}ms`); + + const durationDiff = Math.abs( + streamingResult.duration - nonStreamingResult.duration + ); + const durationDiffPercent = Math.round( + (durationDiff / + Math.max(streamingResult.duration, nonStreamingResult.duration)) * + 100 + ); + + console.log( + `Duration difference: ${durationDiff}ms (${durationDiffPercent}%)` + ); + + if (streamingResult.progressHistory) { + console.log( + `Streaming progress reports: ${streamingResult.progressHistory.length}` + ); + } + + console.log(chalk.green('✅ Both methods completed successfully')); +} + +/** + * Main test runner + */ +async function main() { + const args = process.argv.slice(2); + const testType = args[0] || 'streaming'; + const numTasks = parseInt(args[1]) || 8; + + console.log(chalk.bold.cyan('🚀 Task Master PRD Streaming Tests\n')); + console.log(chalk.blue(`Test type: ${testType}`)); + console.log(chalk.blue(`Number of tasks: ${numTasks}\n`)); + + try { + switch (testType.toLowerCase()) { + case 'mcp': + case 'mcp-streaming': + await testMCPStreaming(numTasks); + break; + + case 'cli': + case 'cli-streaming': + await testCLIStreaming(numTasks); + break; + + case 'non-streaming': + case 'non': + await testNonStreaming(numTasks); + break; + + case 'both': { + console.log( + chalk.yellow( + 'Running both MCP streaming and non-streaming tests...\n' + ) + ); + const mcpStreamingResult = await testMCPStreaming(numTasks); + console.log('\n' + '='.repeat(60) + '\n'); + const nonStreamingResult = await testNonStreaming(numTasks); + compareResults(mcpStreamingResult, nonStreamingResult); + break; + } + + case 'all': { + console.log(chalk.yellow('Running all test types...\n')); + const mcpResult = await testMCPStreaming(numTasks); + console.log('\n' + '='.repeat(60) + '\n'); + const cliResult = await testCLIStreaming(numTasks); + console.log('\n' + '='.repeat(60) + '\n'); + const nonStreamResult = await testNonStreaming(numTasks); + + console.log(chalk.cyan('\n=== All Tests Summary ===')); + console.log( + `MCP Streaming: ${mcpResult.success ? '✅ PASS' : '❌ FAIL'} ${mcpResult.hasEmojiIndicators ? '(✅ Emojis)' : '(❌ No Emojis)'}` + ); + console.log( + `CLI Streaming: ${cliResult.success ? '✅ PASS' : '❌ FAIL'}` + ); + console.log( + `Non-streaming: ${nonStreamResult.success ? '✅ PASS' : '❌ FAIL'}` + ); + break; + } + + default: + console.log(chalk.red(`Unknown test type: ${testType}`)); + console.log( + chalk.yellow( + 'Available options: mcp-streaming, cli-streaming, non-streaming, both, all' + ) + ); + process.exit(1); + } + + console.log(chalk.green('\n🎉 Tests completed successfully!')); + } catch (error) { + console.error(chalk.red(`\n❌ Test failed: ${error.message}`)); + console.error(chalk.red(error.stack)); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/tests/unit/ai-services-unified.test.js b/tests/unit/ai-services-unified.test.js index bbbe65c4..76d3ecf6 100644 --- a/tests/unit/ai-services-unified.test.js +++ b/tests/unit/ai-services-unified.test.js @@ -391,7 +391,7 @@ describe('Unified AI Services', () => { expect.stringContaining('Service call failed for role main') ); expect(mockLog).toHaveBeenCalledWith( - 'info', + 'debug', expect.stringContaining('New AI service call with role: fallback') ); }); @@ -435,7 +435,7 @@ describe('Unified AI Services', () => { expect.stringContaining('Service call failed for role fallback') ); expect(mockLog).toHaveBeenCalledWith( - 'info', + 'debug', expect.stringContaining('New AI service call with role: research') ); }); diff --git a/tests/unit/parse-prd.test.js b/tests/unit/parse-prd.test.js index 0de5b089..e5f6bff6 100644 --- a/tests/unit/parse-prd.test.js +++ b/tests/unit/parse-prd.test.js @@ -1,68 +1,470 @@ // In tests/unit/parse-prd.test.js -// Testing that parse-prd.js handles both .txt and .md files the same way +// Testing parse-prd.js file extension compatibility with real files import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import os from 'os'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Mock the AI services to avoid real API calls +jest.unstable_mockModule( + '../../scripts/modules/ai-services-unified.js', + () => ({ + streamTextService: jest.fn(), + generateObjectService: jest.fn(), + streamObjectService: jest.fn().mockImplementation(async () => { + return { + get partialObjectStream() { + return (async function* () { + yield { tasks: [] }; + yield { tasks: [{ id: 1, title: 'Test Task', priority: 'high' }] }; + })(); + }, + object: Promise.resolve({ + tasks: [{ id: 1, title: 'Test Task', priority: 'high' }] + }) + }; + }) + }) +); + +// Mock all config-manager exports comprehensively +jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({ + getDebugFlag: jest.fn(() => false), + getDefaultPriority: jest.fn(() => 'medium'), + getMainModelId: jest.fn(() => 'test-model'), + getResearchModelId: jest.fn(() => 'test-research-model'), + getParametersForRole: jest.fn(() => ({ maxTokens: 1000, temperature: 0.7 })), + getMainProvider: jest.fn(() => 'anthropic'), + getResearchProvider: jest.fn(() => 'perplexity'), + getFallbackProvider: jest.fn(() => 'anthropic'), + getResponseLanguage: jest.fn(() => 'English'), + getDefaultNumTasks: jest.fn(() => 10), + getDefaultSubtasks: jest.fn(() => 5), + getLogLevel: jest.fn(() => 'info'), + getConfig: jest.fn(() => ({})), + getAllProviders: jest.fn(() => ['anthropic', 'perplexity']), + MODEL_MAP: {}, + VALID_PROVIDERS: ['anthropic', 'perplexity'], + validateProvider: jest.fn(() => true), + validateProviderModelCombination: jest.fn(() => true), + isApiKeySet: jest.fn(() => true) +})); + +// Mock utils comprehensively to prevent CLI behavior +jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({ + log: jest.fn(), + writeJSON: jest.fn(), + enableSilentMode: jest.fn(), + disableSilentMode: jest.fn(), + isSilentMode: jest.fn(() => false), + getCurrentTag: jest.fn(() => 'master'), + ensureTagMetadata: jest.fn(), + readJSON: jest.fn(() => ({ master: { tasks: [] } })), + findProjectRoot: jest.fn(() => '/tmp/test'), + resolveEnvVariable: jest.fn(() => 'mock-key'), + findTaskById: jest.fn(() => null), + findTaskByPattern: jest.fn(() => []), + validateTaskId: jest.fn(() => true), + createTask: jest.fn(() => ({ id: 1, title: 'Mock Task' })), + sortByDependencies: jest.fn((tasks) => tasks), + isEmpty: jest.fn(() => false), + truncate: jest.fn((text) => text), + slugify: jest.fn((text) => text.toLowerCase()), + getTagFromPath: jest.fn(() => 'master'), + isValidTag: jest.fn(() => true), + migrateToTaggedFormat: jest.fn(() => ({ master: { tasks: [] } })), + performCompleteTagMigration: jest.fn(), + resolveCurrentTag: jest.fn(() => 'master'), + getDefaultTag: jest.fn(() => 'master'), + performMigrationIfNeeded: jest.fn() +})); + +// Mock prompt manager +jest.unstable_mockModule('../../scripts/modules/prompt-manager.js', () => ({ + getPromptManager: jest.fn(() => ({ + loadPrompt: jest.fn(() => ({ + systemPrompt: 'Test system prompt', + userPrompt: 'Test user prompt' + })) + })) +})); + +// Mock progress/UI components to prevent real CLI UI +jest.unstable_mockModule('../../src/progress/parse-prd-tracker.js', () => ({ + createParsePrdTracker: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + cleanup: jest.fn(), + addTaskLine: jest.fn(), + updateTokens: jest.fn(), + complete: jest.fn(), + getSummary: jest.fn().mockReturnValue({ + taskPriorities: { high: 0, medium: 0, low: 0 }, + elapsedTime: 0, + actionVerb: 'generated' + }) + })) +})); + +jest.unstable_mockModule('../../src/ui/parse-prd.js', () => ({ + displayParsePrdStart: jest.fn(), + displayParsePrdSummary: jest.fn() +})); + +jest.unstable_mockModule('../../scripts/modules/ui.js', () => ({ + displayAiUsageSummary: jest.fn() +})); + +// Mock task generation to prevent file operations +jest.unstable_mockModule( + '../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn() + }) +); + +// Mock stream parser +jest.unstable_mockModule('../../src/utils/stream-parser.js', () => { + // Define mock StreamingError class + class StreamingError extends Error { + constructor(message, code) { + super(message); + this.name = 'StreamingError'; + this.code = code; + } + } + + // Define mock error codes + const STREAMING_ERROR_CODES = { + NOT_ASYNC_ITERABLE: 'STREAMING_NOT_SUPPORTED', + STREAM_PROCESSING_FAILED: 'STREAM_PROCESSING_FAILED', + STREAM_NOT_ITERABLE: 'STREAM_NOT_ITERABLE' + }; + + return { + parseStream: jest.fn(), + StreamingError, + STREAMING_ERROR_CODES + }; +}); + +// Mock other potential UI elements +jest.unstable_mockModule('ora', () => ({ + default: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + succeed: jest.fn(), + fail: jest.fn() + })) +})); + +jest.unstable_mockModule('chalk', () => ({ + default: { + red: jest.fn((text) => text), + green: jest.fn((text) => text), + blue: jest.fn((text) => text), + yellow: jest.fn((text) => text), + cyan: jest.fn((text) => text), + white: { + bold: jest.fn((text) => text) + } + }, + red: jest.fn((text) => text), + green: jest.fn((text) => text), + blue: jest.fn((text) => text), + yellow: jest.fn((text) => text), + cyan: jest.fn((text) => text), + white: { + bold: jest.fn((text) => text) + } +})); + +// Mock boxen +jest.unstable_mockModule('boxen', () => ({ + default: jest.fn((content) => content) +})); + +// Mock constants +jest.unstable_mockModule('../../src/constants/task-priority.js', () => ({ + DEFAULT_TASK_PRIORITY: 'medium', + TASK_PRIORITY_OPTIONS: ['low', 'medium', 'high'] +})); + +// Mock UI indicators +jest.unstable_mockModule('../../src/ui/indicators.js', () => ({ + getPriorityIndicators: jest.fn(() => ({ + high: '🔴', + medium: '🟡', + low: '🟢' + })) +})); + +// Import modules after mocking +const { generateObjectService } = await import( + '../../scripts/modules/ai-services-unified.js' +); +const parsePRD = ( + await import('../../scripts/modules/task-manager/parse-prd/parse-prd.js') +).default; describe('parse-prd file extension compatibility', () => { - // Test directly that the parse-prd functionality works with different extensions - // by examining the parameter handling in mcp-server/src/tools/parse-prd.js + let tempDir; + let testFiles; - test('Parameter description mentions support for .md files', () => { - // The parameter description for 'input' in parse-prd.js includes .md files - const description = - 'Absolute path to the PRD document file (.txt, .md, etc.)'; - - // Verify the description explicitly mentions .md files - expect(description).toContain('.md'); - }); - - test('File extension validation is not restricted to .txt files', () => { - // Check for absence of extension validation - const fileValidator = (filePath) => { - // Return a boolean value to ensure the test passes - if (!filePath || filePath.length === 0) { - return false; + const mockTasksResponse = { + tasks: [ + { + id: 1, + title: 'Test Task 1', + description: 'First test task', + status: 'pending', + dependencies: [], + priority: 'high', + details: 'Implementation details for task 1', + testStrategy: 'Unit tests for task 1' + }, + { + id: 2, + title: 'Test Task 2', + description: 'Second test task', + status: 'pending', + dependencies: [1], + priority: 'medium', + details: 'Implementation details for task 2', + testStrategy: 'Integration tests for task 2' } - return true; + ], + metadata: { + projectName: 'Test Project', + totalTasks: 2, + sourceFile: 'test-prd', + generatedAt: new Date().toISOString() + } + }; + + const samplePRDContent = `# Test Project PRD + +## Overview +Build a simple task management application. + +## Features +1. Create and manage tasks +2. Set task priorities +3. Track task dependencies + +## Technical Requirements +- React frontend +- Node.js backend +- PostgreSQL database + +## Success Criteria +- Users can create tasks successfully +- Task dependencies work correctly`; + + beforeAll(() => { + // Create temporary directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-prd-test-')); + + // Create test files with different extensions + testFiles = { + txt: path.join(tempDir, 'test-prd.txt'), + md: path.join(tempDir, 'test-prd.md'), + rst: path.join(tempDir, 'test-prd.rst'), + noExt: path.join(tempDir, 'test-prd') }; - // Test with different extensions - expect(fileValidator('/path/to/prd.txt')).toBe(true); - expect(fileValidator('/path/to/prd.md')).toBe(true); + // Write the same content to all test files + Object.values(testFiles).forEach((filePath) => { + fs.writeFileSync(filePath, samplePRDContent); + }); - // Invalid cases should still fail regardless of extension - expect(fileValidator('')).toBe(false); + // Mock process.exit to prevent actual exit + jest.spyOn(process, 'exit').mockImplementation(() => undefined); + + // Mock console methods to prevent output + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); }); - test('Implementation handles all file types the same way', () => { - // This test confirms that the implementation treats all file types equally - // by simulating the core functionality + afterAll(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); - const mockImplementation = (filePath) => { - // The parse-prd.js implementation only checks file existence, - // not the file extension, which is what we want to verify + // Restore mocks + jest.restoreAllMocks(); + }); - if (!filePath) { - return { success: false, error: { code: 'MISSING_INPUT_FILE' } }; + beforeEach(() => { + jest.clearAllMocks(); + + // Mock successful AI response + generateObjectService.mockResolvedValue({ + mainResult: { object: mockTasksResponse }, + telemetryData: { + timestamp: new Date().toISOString(), + userId: 'test-user', + commandName: 'parse-prd', + modelUsed: 'test-model', + providerName: 'test-provider', + inputTokens: 100, + outputTokens: 200, + totalTokens: 300, + totalCost: 0.01, + currency: 'USD' } + }); + }); - // In the real implementation, this would check if the file exists - // But for our test, we're verifying that the same logic applies - // regardless of file extension + test('should accept and parse .txt files', async () => { + const outputPath = path.join(tempDir, 'tasks-txt.json'); - // No special handling for different extensions - return { success: true }; - }; + const result = await parsePRD(testFiles.txt, outputPath, 2, { + force: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + }, + projectRoot: tempDir + }); - // Verify same behavior for different extensions - const txtResult = mockImplementation('/path/to/prd.txt'); - const mdResult = mockImplementation('/path/to/prd.md'); + expect(result.success).toBe(true); + expect(result.tasksPath).toBe(outputPath); + expect(fs.existsSync(outputPath)).toBe(true); - // Both should succeed since there's no extension-specific logic - expect(txtResult.success).toBe(true); - expect(mdResult.success).toBe(true); + // Verify the content was parsed correctly + const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + expect(tasksData.master.tasks).toHaveLength(2); + expect(tasksData.master.tasks[0].title).toBe('Test Task 1'); + }); - // Both should have the same structure - expect(Object.keys(txtResult)).toEqual(Object.keys(mdResult)); + test('should accept and parse .md files', async () => { + const outputPath = path.join(tempDir, 'tasks-md.json'); + + const result = await parsePRD(testFiles.md, outputPath, 2, { + force: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + }, + projectRoot: tempDir + }); + + expect(result.success).toBe(true); + expect(result.tasksPath).toBe(outputPath); + expect(fs.existsSync(outputPath)).toBe(true); + + // Verify the content was parsed correctly + const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + expect(tasksData.master.tasks).toHaveLength(2); + }); + + test('should accept and parse files with other text extensions', async () => { + const outputPath = path.join(tempDir, 'tasks-rst.json'); + + const result = await parsePRD(testFiles.rst, outputPath, 2, { + force: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + }, + projectRoot: tempDir + }); + + expect(result.success).toBe(true); + expect(result.tasksPath).toBe(outputPath); + expect(fs.existsSync(outputPath)).toBe(true); + }); + + test('should accept and parse files with no extension', async () => { + const outputPath = path.join(tempDir, 'tasks-noext.json'); + + const result = await parsePRD(testFiles.noExt, outputPath, 2, { + force: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + }, + projectRoot: tempDir + }); + + expect(result.success).toBe(true); + expect(result.tasksPath).toBe(outputPath); + expect(fs.existsSync(outputPath)).toBe(true); + }); + + test('should produce identical results regardless of file extension', async () => { + const outputs = {}; + + // Parse each file type with a unique project root to avoid ID conflicts + for (const [ext, filePath] of Object.entries(testFiles)) { + // Create a unique subdirectory for each test to isolate them + const testSubDir = path.join(tempDir, `test-${ext}`); + fs.mkdirSync(testSubDir, { recursive: true }); + + const outputPath = path.join(testSubDir, `tasks.json`); + + await parsePRD(filePath, outputPath, 2, { + force: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + }, + projectRoot: testSubDir + }); + + const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + outputs[ext] = tasksData; + } + + // Compare all outputs - they should be identical (except metadata timestamps) + const baseOutput = outputs.txt; + Object.values(outputs).forEach((output) => { + expect(output.master.tasks).toEqual(baseOutput.master.tasks); + expect(output.master.metadata.projectName).toEqual( + baseOutput.master.metadata.projectName + ); + expect(output.master.metadata.totalTasks).toEqual( + baseOutput.master.metadata.totalTasks + ); + }); + }); + + test('should handle non-existent files gracefully', async () => { + const nonExistentFile = path.join(tempDir, 'does-not-exist.txt'); + const outputPath = path.join(tempDir, 'tasks-error.json'); + + await expect( + parsePRD(nonExistentFile, outputPath, 2, { + force: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + }, + projectRoot: tempDir + }) + ).rejects.toThrow(); }); }); diff --git a/tests/unit/progress/base-progress-tracker.test.js b/tests/unit/progress/base-progress-tracker.test.js new file mode 100644 index 00000000..0c77bf1f --- /dev/null +++ b/tests/unit/progress/base-progress-tracker.test.js @@ -0,0 +1,134 @@ +import { jest } from '@jest/globals'; + +// Mock cli-progress factory before importing BaseProgressTracker +jest.unstable_mockModule( + '../../../src/progress/cli-progress-factory.js', + () => ({ + newMultiBar: jest.fn(() => ({ + create: jest.fn(() => ({ + update: jest.fn() + })), + stop: jest.fn() + })) + }) +); + +const { newMultiBar } = await import( + '../../../src/progress/cli-progress-factory.js' +); +const { BaseProgressTracker } = await import( + '../../../src/progress/base-progress-tracker.js' +); + +describe('BaseProgressTracker', () => { + let tracker; + let mockMultiBar; + let mockProgressBar; + let mockTimeTokensBar; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // Setup mocks + mockProgressBar = { update: jest.fn() }; + mockTimeTokensBar = { update: jest.fn() }; + mockMultiBar = { + create: jest + .fn() + .mockReturnValueOnce(mockTimeTokensBar) + .mockReturnValueOnce(mockProgressBar), + stop: jest.fn() + }; + newMultiBar.mockReturnValue(mockMultiBar); + + tracker = new BaseProgressTracker({ numUnits: 10, unitName: 'task' }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('cleanup', () => { + it('should stop and clear timer interval', () => { + tracker.start(); + expect(tracker._timerInterval).toBeTruthy(); + + tracker.cleanup(); + expect(tracker._timerInterval).toBeNull(); + }); + + it('should stop and null multibar reference', () => { + tracker.start(); + expect(tracker.multibar).toBeTruthy(); + + tracker.cleanup(); + expect(mockMultiBar.stop).toHaveBeenCalled(); + expect(tracker.multibar).toBeNull(); + }); + + it('should null progress bar references', () => { + tracker.start(); + expect(tracker.timeTokensBar).toBeTruthy(); + expect(tracker.progressBar).toBeTruthy(); + + tracker.cleanup(); + expect(tracker.timeTokensBar).toBeNull(); + expect(tracker.progressBar).toBeNull(); + }); + + it('should set finished state', () => { + tracker.start(); + expect(tracker.isStarted).toBe(true); + expect(tracker.isFinished).toBe(false); + + tracker.cleanup(); + expect(tracker.isStarted).toBe(false); + expect(tracker.isFinished).toBe(true); + }); + + it('should handle cleanup when multibar.stop throws error', () => { + tracker.start(); + mockMultiBar.stop.mockImplementation(() => { + throw new Error('Stop failed'); + }); + + expect(() => tracker.cleanup()).not.toThrow(); + expect(tracker.multibar).toBeNull(); + }); + + it('should be safe to call multiple times', () => { + tracker.start(); + + tracker.cleanup(); + tracker.cleanup(); + tracker.cleanup(); + + expect(mockMultiBar.stop).toHaveBeenCalledTimes(1); + }); + + it('should be safe to call without starting', () => { + expect(() => tracker.cleanup()).not.toThrow(); + expect(tracker.multibar).toBeNull(); + }); + }); + + describe('stop vs cleanup', () => { + it('stop should call cleanup and null multibar reference', () => { + tracker.start(); + tracker.stop(); + + // stop() now calls cleanup() which nulls the multibar + expect(tracker.multibar).toBeNull(); + expect(tracker.isFinished).toBe(true); + }); + + it('cleanup should null multibar preventing getSummary', () => { + tracker.start(); + tracker.cleanup(); + + expect(tracker.multibar).toBeNull(); + expect(tracker.isFinished).toBe(true); + }); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js index 9c23bace..5f590453 100644 --- a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js +++ b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js @@ -79,6 +79,38 @@ jest.unstable_mockModule( totalCost: 0.012414, currency: 'USD' } + }), + streamTextService: jest.fn().mockResolvedValue({ + mainResult: async function* () { + yield '{"tasks":['; + yield '{"id":1,"title":"Test Task","priority":"high"}'; + yield ']}'; + }, + telemetryData: { + timestamp: new Date().toISOString(), + userId: '1234567890', + commandName: 'analyze-complexity', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }), + streamObjectService: jest.fn().mockImplementation(async () => { + return { + get partialObjectStream() { + return (async function* () { + yield { tasks: [] }; + yield { tasks: [{ id: 1, title: 'Test Task', priority: 'high' }] }; + })(); + }, + object: Promise.resolve({ + tasks: [{ id: 1, title: 'Test Task', priority: 'high' }] + }) + }; }) }) ); @@ -189,9 +221,8 @@ const { readJSON, writeJSON, log, CONFIG, findTaskById } = await import( '../../../../../scripts/modules/utils.js' ); -const { generateObjectService, generateTextService } = await import( - '../../../../../scripts/modules/ai-services-unified.js' -); +const { generateObjectService, generateTextService, streamTextService } = + await import('../../../../../scripts/modules/ai-services-unified.js'); const fs = await import('fs'); diff --git a/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js index 5aa673b0..2a323ba8 100644 --- a/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js +++ b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js @@ -178,6 +178,24 @@ jest.unstable_mockModule( }); } }), + streamTextService: jest.fn().mockResolvedValue({ + mainResult: async function* () { + yield '{"tasks":['; + yield '{"id":1,"title":"Test Task","priority":"high"}'; + yield ']}'; + }, + telemetryData: { + timestamp: new Date().toISOString(), + commandName: 'analyze-complexity', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }), generateObjectService: jest.fn().mockResolvedValue({ mainResult: { object: { @@ -402,7 +420,7 @@ const { readJSON, writeJSON, getTagAwareFilePath } = await import( '../../../../../scripts/modules/utils.js' ); -const { generateTextService } = await import( +const { generateTextService, streamTextService } = await import( '../../../../../scripts/modules/ai-services-unified.js' ); diff --git a/tests/unit/scripts/modules/task-manager/parse-prd.test.js b/tests/unit/scripts/modules/task-manager/parse-prd.test.js index f8cdc8a7..f38a0fe2 100644 --- a/tests/unit/scripts/modules/task-manager/parse-prd.test.js +++ b/tests/unit/scripts/modules/task-manager/parse-prd.test.js @@ -29,10 +29,140 @@ jest.unstable_mockModule( '../../../../../scripts/modules/ai-services-unified.js', () => ({ generateObjectService: jest.fn().mockResolvedValue({ - mainResult: { - tasks: [] - }, - telemetryData: {} + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Test Task 2', + priority: 'medium', + description: 'Test description 2', + status: 'pending', + dependencies: [] + }, + { + id: 3, + title: 'Test Task 3', + priority: 'low', + description: 'Test description 3', + status: 'pending', + dependencies: [] + } + ] + }), + streamObjectService: jest.fn().mockImplementation(async () => { + // Return an object with partialObjectStream as a getter that returns the async generator + return { + mainResult: { + get partialObjectStream() { + return (async function* () { + yield { tasks: [] }; + yield { + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + } + ] + }; + yield { + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Test Task 2', + priority: 'medium', + description: 'Test description 2', + status: 'pending', + dependencies: [] + } + ] + }; + yield { + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Test Task 2', + priority: 'medium', + description: 'Test description 2', + status: 'pending', + dependencies: [] + }, + { + id: 3, + title: 'Test Task 3', + priority: 'low', + description: 'Test description 3', + status: 'pending', + dependencies: [] + } + ] + }; + })(); + }, + usage: Promise.resolve({ + promptTokens: 100, + completionTokens: 200, + totalTokens: 300 + }), + object: Promise.resolve({ + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Test Task 2', + priority: 'medium', + description: 'Test description 2', + status: 'pending', + dependencies: [] + }, + { + id: 3, + title: 'Test Task 3', + priority: 'low', + description: 'Test description 3', + status: 'pending', + dependencies: [] + } + ] + }) + }, + providerName: 'anthropic', + modelId: 'claude-3-5-sonnet-20241022', + telemetryData: {} + }; }) }) ); @@ -48,6 +178,12 @@ jest.unstable_mockModule( '../../../../../scripts/modules/config-manager.js', () => ({ getDebugFlag: jest.fn(() => false), + getMainModelId: jest.fn(() => 'claude-3-5-sonnet'), + getResearchModelId: jest.fn(() => 'claude-3-5-sonnet'), + getParametersForRole: jest.fn(() => ({ + provider: 'anthropic', + modelId: 'claude-3-5-sonnet' + })), getDefaultNumTasks: jest.fn(() => 10), getDefaultPriority: jest.fn(() => 'medium'), getMainProvider: jest.fn(() => 'openai'), @@ -103,7 +239,10 @@ jest.unstable_mockModule('fs', () => ({ readFileSync: jest.fn(), existsSync: jest.fn(), mkdirSync: jest.fn(), - writeFileSync: jest.fn() + writeFileSync: jest.fn(), + promises: { + readFile: jest.fn() + } }, readFileSync: jest.fn(), existsSync: jest.fn(), @@ -121,15 +260,98 @@ jest.unstable_mockModule('path', () => ({ join: jest.fn((dir, file) => `${dir}/${file}`) })); +// Mock JSONParser for streaming tests +jest.unstable_mockModule('@streamparser/json', () => ({ + JSONParser: jest.fn().mockImplementation(() => ({ + onValue: jest.fn(), + onError: jest.fn(), + write: jest.fn(), + end: jest.fn() + })) +})); + +// Mock stream-parser functions +jest.unstable_mockModule('../../../../../src/utils/stream-parser.js', () => { + // Define mock StreamingError class + class StreamingError extends Error { + constructor(message, code) { + super(message); + this.name = 'StreamingError'; + this.code = code; + } + } + + // Define mock error codes + const STREAMING_ERROR_CODES = { + NOT_ASYNC_ITERABLE: 'STREAMING_NOT_SUPPORTED', + STREAM_PROCESSING_FAILED: 'STREAM_PROCESSING_FAILED', + STREAM_NOT_ITERABLE: 'STREAM_NOT_ITERABLE' + }; + + return { + parseStream: jest.fn().mockResolvedValue({ + items: [{ id: 1, title: 'Test Task', priority: 'high' }], + accumulatedText: + '{"tasks":[{"id":1,"title":"Test Task","priority":"high"}]}', + estimatedTokens: 50, + usedFallback: false + }), + createTaskProgressCallback: jest.fn().mockReturnValue(jest.fn()), + createConsoleProgressCallback: jest.fn().mockReturnValue(jest.fn()), + StreamingError, + STREAMING_ERROR_CODES + }; +}); + +// Mock progress tracker to prevent intervals +jest.unstable_mockModule( + '../../../../../src/progress/parse-prd-tracker.js', + () => ({ + createParsePrdTracker: jest.fn().mockReturnValue({ + start: jest.fn(), + stop: jest.fn(), + cleanup: jest.fn(), + updateTokens: jest.fn(), + addTaskLine: jest.fn(), + trackTaskPriority: jest.fn(), + getSummary: jest.fn().mockReturnValue({ + taskPriorities: { high: 0, medium: 0, low: 0 }, + elapsedTime: 0, + actionVerb: 'generated' + }) + }) + }) +); + +// Mock UI functions to prevent any display delays +jest.unstable_mockModule('../../../../../src/ui/parse-prd.js', () => ({ + displayParsePrdStart: jest.fn(), + displayParsePrdSummary: jest.fn() +})); + // Import the mocked modules const { readJSON, promptYesNo } = await import( '../../../../../scripts/modules/utils.js' ); -const { generateObjectService } = await import( +const { generateObjectService, streamObjectService } = await import( '../../../../../scripts/modules/ai-services-unified.js' ); +const { JSONParser } = await import('@streamparser/json'); + +const { parseStream, StreamingError, STREAMING_ERROR_CODES } = await import( + '../../../../../src/utils/stream-parser.js' +); + +const { createParsePrdTracker } = await import( + '../../../../../src/progress/parse-prd-tracker.js' +); + +const { displayParsePrdStart, displayParsePrdSummary } = await import( + '../../../../../src/ui/parse-prd.js' +); + // Note: getDefaultNumTasks validation happens at CLI/MCP level, not in the main parse-prd module const generateTaskFiles = ( await import( @@ -142,7 +364,7 @@ const path = await import('path'); // Import the module under test const { default: parsePRD } = await import( - '../../../../../scripts/modules/task-manager/parse-prd.js' + '../../../../../scripts/modules/task-manager/parse-prd/parse-prd.js' ); // Sample data for tests (from main test file) @@ -207,15 +429,50 @@ describe('parsePRD', () => { // Set up mocks for fs, path and other modules fs.default.readFileSync.mockReturnValue(samplePRDContent); + fs.default.promises.readFile.mockResolvedValue(samplePRDContent); fs.default.existsSync.mockReturnValue(true); path.default.dirname.mockReturnValue('tasks'); generateObjectService.mockResolvedValue({ - mainResult: { object: sampleClaudeResponse }, + mainResult: sampleClaudeResponse, telemetryData: {} }); - generateTaskFiles.mockResolvedValue(undefined); + // Reset streamObjectService mock to working implementation + streamObjectService.mockImplementation(async () => { + return { + mainResult: { + get partialObjectStream() { + return (async function* () { + yield { tasks: [] }; + yield { tasks: [sampleClaudeResponse.tasks[0]] }; + yield { + tasks: [ + sampleClaudeResponse.tasks[0], + sampleClaudeResponse.tasks[1] + ] + }; + yield sampleClaudeResponse; + })(); + }, + usage: Promise.resolve({ + promptTokens: 100, + completionTokens: 200, + totalTokens: 300 + }), + object: Promise.resolve(sampleClaudeResponse) + }, + providerName: 'anthropic', + modelId: 'claude-3-5-sonnet-20241022', + telemetryData: {} + }; + }); + // generateTaskFiles.mockResolvedValue(undefined); promptYesNo.mockResolvedValue(true); // Default to "yes" for confirmation + // Mock process.exit to prevent actual exit and throw error instead for CLI tests + jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit was called with code ${code}`); + }); + // Mock console.error to prevent output jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'log').mockImplementation(() => {}); @@ -234,9 +491,20 @@ describe('parsePRD', () => { return false; }); - // Call the function + // Also mock the other fs methods that might be called + fs.default.readFileSync.mockReturnValue(samplePRDContent); + fs.default.promises.readFile.mockResolvedValue(samplePRDContent); + + // Call the function with mcpLog to force non-streaming mode const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify fs.readFileSync was called with the correct arguments @@ -279,8 +547,17 @@ describe('parsePRD', () => { return true; // Default for other paths }); - // Call the function - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }); + // Call the function with mcpLog to force non-streaming mode + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); // Verify mkdir was called expect(fs.default.mkdirSync).toHaveBeenCalledWith('tasks', { @@ -323,8 +600,19 @@ describe('parsePRD', () => { return false; }); - // Call the function - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }); + // Call the function with mcpLog to force non-streaming mode + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // generateTaskFiles is currently commented out in parse-prd.js }); test('should overwrite tasks.json when force flag is true', async () => { @@ -335,10 +623,17 @@ describe('parsePRD', () => { return false; }); - // Call the function with force=true to allow overwrite + // Call the function with force=true to allow overwrite and mcpLog to force non-streaming mode await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { force: true, - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify prompt was NOT called (confirmation happens at CLI level, not in core function) @@ -371,9 +666,7 @@ describe('parsePRD', () => { success: jest.fn() } }) - ).rejects.toThrow( - "Tag 'master' already contains 2 tasks. Use --force to overwrite or --append to add to existing tasks." - ); + ).rejects.toThrow('already contains'); // Verify prompt was NOT called expect(promptYesNo).not.toHaveBeenCalled(); @@ -393,9 +686,7 @@ describe('parsePRD', () => { // In test environment, process.exit is prevented and error is thrown instead await expect( parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }) - ).rejects.toThrow( - "Tag 'master' already contains 2 tasks. Use --force to overwrite or --append to add to existing tasks." - ); + ).rejects.toThrow('process.exit was called with code 1'); // Verify the file was NOT written expect(fs.default.writeFileSync).not.toHaveBeenCalled(); @@ -418,10 +709,17 @@ describe('parsePRD', () => { telemetryData: {} }); - // Call the function with append option + // Call the function with append option and mcpLog to force non-streaming mode const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 2, { tag: 'master', - append: true + append: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify prompt was NOT called (no confirmation needed for append) @@ -453,6 +751,39 @@ describe('parsePRD', () => { JSON.stringify(existingTasksData) ); + // Ensure generateObjectService returns proper tasks + generateObjectService.mockResolvedValue({ + mainResult: { + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Test Task 2', + priority: 'medium', + description: 'Test description 2', + status: 'pending', + dependencies: [] + }, + { + id: 3, + title: 'Test Task 3', + priority: 'low', + description: 'Test description 3', + status: 'pending', + dependencies: [] + } + ] + }, + telemetryData: {} + }); + // Call the function with append option await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master', @@ -463,6 +794,475 @@ describe('parsePRD', () => { expect(promptYesNo).not.toHaveBeenCalled(); }); + describe('Streaming vs Non-Streaming Modes', () => { + test('should use streaming when reportProgress function is provided', async () => { + // Setup mocks to simulate normal conditions (no existing output file) + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Mock JSONParser instance + const mockParser = { + onValue: jest.fn(), + onError: jest.fn(), + write: jest.fn(), + end: jest.fn() + }; + JSONParser.mockReturnValue(mockParser); + + // Call the function with reportProgress to trigger streaming path + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + reportProgress: mockReportProgress + }); + + // Verify streamObjectService was called (streaming path) + expect(streamObjectService).toHaveBeenCalled(); + + // Verify generateObjectService was NOT called (non-streaming path) + expect(generateObjectService).not.toHaveBeenCalled(); + + // Verify progress reporting was called + expect(mockReportProgress).toHaveBeenCalled(); + + // We no longer use parseStream with streamObject + // expect(parseStream).toHaveBeenCalled(); + + // Verify result structure + expect(result).toEqual({ + success: true, + tasksPath: 'tasks/tasks.json', + telemetryData: {} + }); + }); + + test('should fallback to non-streaming when streaming fails with specific errors', async () => { + // Setup mocks to simulate normal conditions (no existing output file) + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Mock streamObjectService to return a stream that fails during processing + streamObjectService.mockImplementationOnce(async () => { + return { + mainResult: { + get partialObjectStream() { + return (async function* () { + throw new Error('Stream processing failed'); + })(); + }, + usage: Promise.resolve(null), + object: Promise.resolve(null) + }, + providerName: 'anthropic', + modelId: 'claude-3-5-sonnet-20241022', + telemetryData: {} + }; + }); + + // Ensure generateObjectService returns tasks for fallback + generateObjectService.mockResolvedValue({ + mainResult: { + tasks: [ + { + id: 1, + title: 'Test Task 1', + priority: 'high', + description: 'Test description 1', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Test Task 2', + priority: 'medium', + description: 'Test description 2', + status: 'pending', + dependencies: [] + }, + { + id: 3, + title: 'Test Task 3', + priority: 'low', + description: 'Test description 3', + status: 'pending', + dependencies: [] + } + ] + }, + telemetryData: {} + }); + + // Call the function with reportProgress to trigger streaming path + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + reportProgress: mockReportProgress + }); + + // Verify streamObjectService was called first (streaming attempt) + expect(streamObjectService).toHaveBeenCalled(); + + // Verify generateObjectService was called as fallback + expect(generateObjectService).toHaveBeenCalled(); + + // Verify result structure (should succeed via fallback) + expect(result).toEqual({ + success: true, + tasksPath: 'tasks/tasks.json', + telemetryData: {} + }); + }); + + test('should use non-streaming when reportProgress is not provided', async () => { + // Setup mocks to simulate normal conditions (no existing output file) + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Call the function without reportProgress but with mcpLog to force non-streaming path + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify generateObjectService was called (non-streaming path) + expect(generateObjectService).toHaveBeenCalled(); + + // Verify streamObjectService was NOT called (streaming path) + expect(streamObjectService).not.toHaveBeenCalled(); + + // Verify result structure + expect(result).toEqual({ + success: true, + tasksPath: 'tasks/tasks.json', + telemetryData: {} + }); + }); + + test('should handle research flag with streaming', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Call with streaming + research + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + reportProgress: mockReportProgress, + research: true + }); + + // Verify streaming path was used with research role + expect(streamObjectService).toHaveBeenCalledWith( + expect.objectContaining({ + role: 'research' + }) + ); + expect(generateObjectService).not.toHaveBeenCalled(); + }); + + test('should handle research flag with non-streaming', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Call without reportProgress but with mcpLog (non-streaming) + research + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + research: true, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify non-streaming path was used with research role + expect(generateObjectService).toHaveBeenCalledWith( + expect.objectContaining({ + role: 'research' + }) + ); + expect(streamObjectService).not.toHaveBeenCalled(); + }); + + test('should use streaming for CLI text mode even without reportProgress', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Call without mcpLog and without reportProgress (CLI text mode) + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify streaming path was used (no mcpLog means CLI text mode, which should use streaming) + expect(streamObjectService).toHaveBeenCalled(); + expect(generateObjectService).not.toHaveBeenCalled(); + + // Verify progress tracker components were called for CLI mode + expect(createParsePrdTracker).toHaveBeenCalled(); + expect(displayParsePrdStart).toHaveBeenCalled(); + + expect(result).toEqual({ + success: true, + tasksPath: 'tasks/tasks.json', + telemetryData: {} + }); + }); + + test.skip('should handle parseStream with usedFallback flag - needs rewrite for streamObject', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Mock parseStream to return usedFallback: true + parseStream.mockResolvedValueOnce({ + items: [{ id: 1, title: 'Test Task', priority: 'high' }], + accumulatedText: + '{"tasks":[{"id":1,"title":"Test Task","priority":"high"}]}', + estimatedTokens: 50, + usedFallback: true // This triggers fallback reporting + }); + + // Call with streaming + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + reportProgress: mockReportProgress + }); + + // Verify that usedFallback scenario was handled + expect(parseStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + jsonPaths: ['$.tasks.*'], + onProgress: expect.any(Function), + onError: expect.any(Function), + estimateTokens: expect.any(Function), + expectedTotal: 3, + fallbackItemExtractor: expect.any(Function) + }) + ); + }); + + test.skip('should handle StreamingError types for fallback - needs rewrite for streamObject', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Test different StreamingError types that should trigger fallback + const streamingErrors = [ + { + message: 'Stream object is not iterable', + code: STREAMING_ERROR_CODES.STREAM_NOT_ITERABLE + }, + { + message: 'Failed to process AI text stream', + code: STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED + }, + { + message: 'textStream is not async iterable', + code: STREAMING_ERROR_CODES.NOT_ASYNC_ITERABLE + } + ]; + + for (const errorConfig of streamingErrors) { + // Clear mocks for each iteration + jest.clearAllMocks(); + + // Setup mocks again + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; + if (path === 'tasks') return true; + return false; + }); + fs.default.readFileSync.mockReturnValue(samplePRDContent); + generateObjectService.mockResolvedValue({ + mainResult: { object: sampleClaudeResponse }, + telemetryData: {} + }); + + // Mock streamTextService to fail with StreamingError + const error = new StreamingError(errorConfig.message, errorConfig.code); + streamTextService.mockRejectedValueOnce(error); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Call with streaming (should fallback to non-streaming) + const result = await parsePRD( + 'path/to/prd.txt', + 'tasks/tasks.json', + 3, + { + reportProgress: mockReportProgress + } + ); + + // Verify streaming was attempted first + expect(streamTextService).toHaveBeenCalled(); + + // Verify fallback to non-streaming occurred + expect(generateObjectService).toHaveBeenCalled(); + + // Verify successful result despite streaming failure + expect(result).toEqual({ + success: true, + tasksPath: 'tasks/tasks.json', + telemetryData: {} + }); + } + }); + + test.skip('should handle progress tracker integration in CLI streaming mode - needs rewrite for streamObject', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress tracker methods + const mockProgressTracker = { + start: jest.fn(), + stop: jest.fn(), + cleanup: jest.fn(), + addTaskLine: jest.fn(), + updateTokens: jest.fn(), + getSummary: jest.fn().mockReturnValue({ + taskPriorities: { high: 1, medium: 0, low: 0 }, + elapsedTime: 1000, + actionVerb: 'generated' + }) + }; + createParsePrdTracker.mockReturnValue(mockProgressTracker); + + // Call in CLI text mode (no mcpLog, no reportProgress) + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify progress tracker was created and used + expect(createParsePrdTracker).toHaveBeenCalledWith({ + numUnits: 3, + unitName: 'task', + append: false + }); + expect(mockProgressTracker.start).toHaveBeenCalled(); + expect(mockProgressTracker.cleanup).toHaveBeenCalled(); + + // Verify UI display functions were called + expect(displayParsePrdStart).toHaveBeenCalled(); + expect(displayParsePrdSummary).toHaveBeenCalled(); + }); + + test.skip('should handle onProgress callback during streaming - needs rewrite for streamObject', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Mock parseStream to call onProgress + parseStream.mockImplementation(async (stream, options) => { + // Simulate calling onProgress during parsing + if (options.onProgress) { + await options.onProgress( + { title: 'Test Task', priority: 'high' }, + { currentCount: 1, estimatedTokens: 50 } + ); + } + return { + items: [{ id: 1, title: 'Test Task', priority: 'high' }], + accumulatedText: + '{"tasks":[{"id":1,"title":"Test Task","priority":"high"}]}', + estimatedTokens: 50, + usedFallback: false + }; + }); + + // Call with streaming + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + reportProgress: mockReportProgress + }); + + // Verify parseStream was called with correct onProgress callback + expect(parseStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onProgress: expect.any(Function) + }) + ); + + // Verify progress was reported during streaming + expect(mockReportProgress).toHaveBeenCalled(); + }); + + test.skip('should not re-throw non-streaming errors during fallback - needs rewrite for streamObject', async () => { + // Setup mocks to simulate normal conditions + fs.default.existsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock progress reporting function + const mockReportProgress = jest.fn(() => Promise.resolve()); + + // Mock streamTextService to fail with NON-streaming error + streamTextService.mockRejectedValueOnce( + new Error('AI API rate limit exceeded') + ); + + // Call with streaming - should re-throw non-streaming errors + await expect( + parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + reportProgress: mockReportProgress + }) + ).rejects.toThrow('AI API rate limit exceeded'); + + // Verify streaming was attempted + expect(streamTextService).toHaveBeenCalled(); + + // Verify fallback was NOT attempted (error was re-thrown) + expect(generateObjectService).not.toHaveBeenCalled(); + }); + }); + describe('Dynamic Task Generation', () => { test('should use dynamic prompting when numTasks is 0', async () => { // Setup mocks to simulate normal conditions (no existing output file) @@ -472,9 +1272,16 @@ describe('parsePRD', () => { return false; }); - // Call the function with numTasks=0 for dynamic generation + // Call the function with numTasks=0 for dynamic generation and mcpLog to force non-streaming mode await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify generateObjectService was called @@ -494,9 +1301,16 @@ describe('parsePRD', () => { return false; }); - // Call the function with specific numTasks + // Call the function with specific numTasks and mcpLog to force non-streaming mode await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5, { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify generateObjectService was called @@ -516,9 +1330,16 @@ describe('parsePRD', () => { return false; }); - // Call the function with numTasks=0 - should not throw error + // Call the function with numTasks=0 and mcpLog to force non-streaming mode - should not throw error const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify it completed successfully @@ -537,10 +1358,17 @@ describe('parsePRD', () => { return false; }); - // Call the function with negative numTasks + // Call the function with negative numTasks and mcpLog to force non-streaming mode // Note: The main parse-prd.js module doesn't validate numTasks - validation happens at CLI/MCP level await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5, { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify generateObjectService was called @@ -561,9 +1389,16 @@ describe('parsePRD', () => { return false; }); - // Call the function with null numTasks + // Call the function with null numTasks and mcpLog to force non-streaming mode await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null, { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify generateObjectService was called with dynamic prompting @@ -580,9 +1415,16 @@ describe('parsePRD', () => { return false; }); - // Call the function with invalid numTasks (string that's not a number) + // Call the function with invalid numTasks (string that's not a number) and mcpLog to force non-streaming mode await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid', { - tag: 'master' + tag: 'master', + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } }); // Verify generateObjectService was called with dynamic prompting diff --git a/tests/unit/ui/indicators.test.js b/tests/unit/ui/indicators.test.js new file mode 100644 index 00000000..5644448b --- /dev/null +++ b/tests/unit/ui/indicators.test.js @@ -0,0 +1,169 @@ +/** + * Unit tests for indicators module (priority and complexity indicators) + */ +import { jest } from '@jest/globals'; + +// Mock chalk using unstable_mockModule for ESM compatibility +jest.unstable_mockModule('chalk', () => ({ + default: { + red: jest.fn((str) => str), + yellow: jest.fn((str) => str), + green: jest.fn((str) => str), + white: jest.fn((str) => str), + hex: jest.fn(() => jest.fn((str) => str)) + } +})); + +// Import after mocking +const { + getMcpPriorityIndicators, + getCliPriorityIndicators, + getPriorityIndicators, + getPriorityIndicator, + getStatusBarPriorityIndicators, + getPriorityColors, + getCliComplexityIndicators, + getStatusBarComplexityIndicators, + getComplexityColors, + getComplexityIndicator +} = await import('../../../src/ui/indicators.js'); + +describe('Priority Indicators', () => { + describe('getMcpPriorityIndicators', () => { + it('should return emoji indicators for MCP context', () => { + const indicators = getMcpPriorityIndicators(); + expect(indicators).toEqual({ + high: '🔴', + medium: '🟠', + low: '🟢' + }); + }); + }); + + describe('getCliPriorityIndicators', () => { + it('should return colored dot indicators for CLI context', () => { + const indicators = getCliPriorityIndicators(); + expect(indicators).toHaveProperty('high'); + expect(indicators).toHaveProperty('medium'); + expect(indicators).toHaveProperty('low'); + // Since chalk is mocked, we're just verifying structure + expect(indicators.high).toContain('●'); + }); + }); + + describe('getPriorityIndicators', () => { + it('should return MCP indicators when isMcp is true', () => { + const indicators = getPriorityIndicators(true); + expect(indicators).toEqual({ + high: '🔴', + medium: '🟠', + low: '🟢' + }); + }); + + it('should return CLI indicators when isMcp is false', () => { + const indicators = getPriorityIndicators(false); + expect(indicators).toHaveProperty('high'); + expect(indicators).toHaveProperty('medium'); + expect(indicators).toHaveProperty('low'); + }); + + it('should default to CLI indicators when no parameter provided', () => { + const indicators = getPriorityIndicators(); + expect(indicators).toHaveProperty('high'); + expect(indicators.high).toContain('●'); + }); + }); + + describe('getPriorityIndicator', () => { + it('should return correct MCP indicator for valid priority', () => { + expect(getPriorityIndicator('high', true)).toBe('🔴'); + expect(getPriorityIndicator('medium', true)).toBe('🟠'); + expect(getPriorityIndicator('low', true)).toBe('🟢'); + }); + + it('should return correct CLI indicator for valid priority', () => { + const highIndicator = getPriorityIndicator('high', false); + const mediumIndicator = getPriorityIndicator('medium', false); + const lowIndicator = getPriorityIndicator('low', false); + + expect(highIndicator).toContain('●'); + expect(mediumIndicator).toContain('●'); + expect(lowIndicator).toContain('●'); + }); + + it('should return medium indicator for invalid priority', () => { + expect(getPriorityIndicator('invalid', true)).toBe('🟠'); + expect(getPriorityIndicator(null, true)).toBe('🟠'); + expect(getPriorityIndicator(undefined, true)).toBe('🟠'); + }); + + it('should default to CLI context when isMcp not provided', () => { + const indicator = getPriorityIndicator('high'); + expect(indicator).toContain('●'); + }); + }); +}); + +describe('Complexity Indicators', () => { + describe('getCliComplexityIndicators', () => { + it('should return colored dot indicators for complexity levels', () => { + const indicators = getCliComplexityIndicators(); + expect(indicators).toHaveProperty('high'); + expect(indicators).toHaveProperty('medium'); + expect(indicators).toHaveProperty('low'); + expect(indicators.high).toContain('●'); + }); + }); + + describe('getStatusBarComplexityIndicators', () => { + it('should return single character indicators for status bars', () => { + const indicators = getStatusBarComplexityIndicators(); + // Since chalk is mocked, we need to check for the actual characters + expect(indicators.high).toContain('⋮'); + expect(indicators.medium).toContain(':'); + expect(indicators.low).toContain('.'); + }); + }); + + describe('getComplexityColors', () => { + it('should return complexity color functions', () => { + const colors = getComplexityColors(); + expect(colors).toHaveProperty('high'); + expect(colors).toHaveProperty('medium'); + expect(colors).toHaveProperty('low'); + // Verify they are functions (mocked chalk functions) + expect(typeof colors.high).toBe('function'); + }); + }); + + describe('getComplexityIndicator', () => { + it('should return high indicator for scores >= 7', () => { + const cliIndicators = getCliComplexityIndicators(); + expect(getComplexityIndicator(7)).toBe(cliIndicators.high); + expect(getComplexityIndicator(8)).toBe(cliIndicators.high); + expect(getComplexityIndicator(10)).toBe(cliIndicators.high); + }); + + it('should return low indicator for scores <= 3', () => { + const cliIndicators = getCliComplexityIndicators(); + expect(getComplexityIndicator(1)).toBe(cliIndicators.low); + expect(getComplexityIndicator(2)).toBe(cliIndicators.low); + expect(getComplexityIndicator(3)).toBe(cliIndicators.low); + }); + + it('should return medium indicator for scores 4-6', () => { + const cliIndicators = getCliComplexityIndicators(); + expect(getComplexityIndicator(4)).toBe(cliIndicators.medium); + expect(getComplexityIndicator(5)).toBe(cliIndicators.medium); + expect(getComplexityIndicator(6)).toBe(cliIndicators.medium); + }); + + it('should return status bar indicators when statusBar is true', () => { + const statusBarIndicators = getStatusBarComplexityIndicators(); + expect(getComplexityIndicator(8, true)).toBe(statusBarIndicators.high); + expect(getComplexityIndicator(5, true)).toBe(statusBarIndicators.medium); + expect(getComplexityIndicator(2, true)).toBe(statusBarIndicators.low); + }); + }); +});