Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions apps/mcp/src/shared/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Tests for handleApiResult tag field behavior
*
* Regression test for https://github.com/eyaltoledano/claude-task-master/issues/1638
* Bug: handleApiResult returns the active tag from state.json instead of the
* resolved target tag when a tag is explicitly provided to the operation.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleApiResult } from './utils.js';

// Mock fs and path to control getCurrentTag behavior
vi.mock('node:fs', () => ({
default: {
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() =>
JSON.stringify({ currentTag: 'active-tag-from-state' })
)
}
}));

// Mock the package.json import
vi.mock('../../../../package.json', () => ({
default: { version: '0.0.0-test', name: 'task-master-ai' }
}));

describe('handleApiResult tag field', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should use provided tag instead of reading from state.json', async () => {
const result = await handleApiResult({
result: {
success: true,
data: { taskId: 5, message: 'Success' }
},
projectRoot: '/mock/project',
tag: 'target-tag'
});
Comment on lines +32 to +40
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description/title indicates the add_task response tag bug is fixed by passing the resolved target tag into handleApiResult, but in the current codebase the add_task tool (mcp-server/src/tools/add-task.js) still calls handleApiResult without supplying tag: resolvedTag, so responses will continue to fall back to state.json. These tests only validate handleApiResult’s precedence rules, not that add_task actually passes the resolved tag—please add the call-site change (and ideally a tool-level regression test) so the reported bug is truly fixed.

Copilot uses AI. Check for mistakes.

const responseText = (result.content[0] as { type: 'text'; text: string }).text;
const parsed = JSON.parse(responseText);

// The tag field should be the explicitly provided tag, not the active tag
expect(parsed.tag).toBe('target-tag');
});

it('should fall back to state.json tag when no tag is provided', async () => {
const result = await handleApiResult({
result: {
success: true,
data: { taskId: 5, message: 'Success' }
},
projectRoot: '/mock/project'
});

const responseText = (result.content[0] as { type: 'text'; text: string }).text;
const parsed = JSON.parse(responseText);

// Without explicit tag, it should read from state.json
expect(parsed.tag).toBe('active-tag-from-state');
});

it('should not include tag field when neither tag nor projectRoot is provided', async () => {
const result = await handleApiResult({
result: {
success: true,
data: { taskId: 5, message: 'Success' }
}
});

const responseText = (result.content[0] as { type: 'text'; text: string }).text;
const parsed = JSON.parse(responseText);

expect(parsed.tag).toBeUndefined();
});

it('should use provided tag in error responses too', async () => {
const result = await handleApiResult({
result: {
success: false,
error: { message: 'Something went wrong' }
},
projectRoot: '/mock/project',
tag: 'target-tag'
});

// Error responses include tag in text format
const responseText = (result.content[0] as { type: 'text'; text: string }).text;
expect(responseText).toContain('Current Tag: target-tag');
expect(responseText).not.toContain('active-tag-from-state');
});
});
8 changes: 4 additions & 4 deletions packages/tm-core/src/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export interface TaskImplementationMetadata {
* Placeholder task interface for temporary/minimal task objects
*/
export interface PlaceholderTask {
id: string;
id: number | string;
title: string;
status: TaskStatus;
priority: TaskPriority;
Expand All @@ -129,7 +129,7 @@ export interface PlaceholderTask {
* Base task interface
*/
export interface Task extends TaskImplementationMetadata {
id: string;
id: number | string;
title: string;
description: string;
status: TaskStatus;
Comment on lines 131 to 135
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task.id is changed to number | string, but the runtime type guard isTask further down in this same file still checks typeof task.id === 'string' (and isSubtask still assumes parentId is a string). With the new union types (and with file-storage/fixtures now coercing IDs to numbers), these guards will incorrectly reject valid tasks/subtasks. Please update the guards (and any other ID validators) to accept the new ID shapes, or keep IDs consistently as strings.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -171,7 +171,7 @@ export interface Task extends TaskImplementationMetadata {
*/
export interface Subtask extends Omit<Task, 'id' | 'subtasks'> {
id: number | string;
parentId: string;
parentId: number | string;
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtask.parentId is widened to number | string, but isSubtask later in this file still checks typeof subtask.parentId === 'string'. Please keep the type guard consistent with this new union (or revert this type change) to avoid runtime validation rejecting subtasks that were normalized to numeric parent IDs.

Suggested change
parentId: number | string;
parentId: string;

Copilot uses AI. Check for mistakes.
subtasks?: never; // Subtasks cannot have their own subtasks
}

Expand Down Expand Up @@ -225,7 +225,7 @@ export type CreateTask = Omit<
* Type for updating a task (all fields optional except ID)
*/
export type UpdateTask = Partial<Omit<Task, 'id'>> & {
id: string;
id: number | string;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,19 +282,19 @@ export class FileStorage implements IStorage {
}

/**
* Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers
* Normalize task IDs - Task IDs and Subtask IDs are both numbers for file storage
* Note: Uses spread operator to preserve all task properties including user-defined metadata
*/
private normalizeTaskIds(tasks: Task[]): Task[] {
Comment on lines 284 to 288
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is titled/described as a fix for the MCP add_task response tag field, but this file introduces a behavior change that normalizes stored task IDs/parentIds to numbers instead of strings. That’s a potentially breaking storage-format change and seems unrelated to issue #1638—please either split these ID-normalization changes into a separate PR (with migration/compat notes) or update the PR description to cover and justify this change.

Copilot uses AI. Check for mistakes.
return tasks.map((task) => ({
...task,
id: String(task.id), // Task IDs are strings
id: Number(task.id), // Task IDs are numbers
dependencies: task.dependencies?.map((dep) => String(dep)) || [],
subtasks:
task.subtasks?.map((subtask) => ({
...subtask,
id: Number(subtask.id), // Subtask IDs are numbers
parentId: String(subtask.parentId) // Parent ID is string (Task ID)
parentId: Number(subtask.parentId) // Parent ID is number (Task ID)
})) || []
}));
}
Expand Down Expand Up @@ -404,7 +404,7 @@ export class FileStorage implements IStorage {
...existingTask,
...updates,
...(mergedSubtasks && { subtasks: mergedSubtasks }),
id: String(taskId) // Keep consistent with normalizeTaskIds
id: Number(taskId) // Keep consistent with normalizeTaskIds
};
await this.saveTasks(tasks, tag);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,18 +217,18 @@ export class FormatHandler {
}

/**
* Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers
* Normalize task IDs - Task IDs and Subtask IDs are both numbers for file storage
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeTasks now coerces IDs to numbers, but convertToSaveFormat earlier in this file still has a comment saying “Normalize task IDs to strings”. Please update that nearby documentation to match the new numeric-ID normalization so the file doesn’t contain contradictory guidance.

Suggested change
* Normalize task IDs - Task IDs and Subtask IDs are both numbers for file storage
* Normalize task and subtask IDs to numbers for file storage (dependency IDs remain strings)

Copilot uses AI. Check for mistakes.
*/
private normalizeTasks(tasks: Task[]): Task[] {
return tasks.map((task) => ({
...task,
id: String(task.id), // Task IDs are strings
id: Number(task.id), // Task IDs are numbers
dependencies: task.dependencies?.map((dep) => String(dep)) || [],
subtasks:
task.subtasks?.map((subtask) => ({
...subtask,
id: Number(subtask.id), // Subtask IDs are numbers
parentId: String(subtask.parentId) // Parent ID is string (Task ID)
parentId: Number(subtask.parentId) // Parent ID is number (Task ID)
})) || []
}));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/tm-core/src/testing/task-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function createTask(
overrides: Partial<Omit<Task, 'id'>> & { id: number | string; title: string }
): Task {
return {
id: String(overrides.id),
id: Number(overrides.id),
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createTask now coerces overrides.id via Number(...) even though the input type allows string. If a caller passes a non-numeric ID (e.g., UUIDs for API-storage tests), the fixture will silently produce id: NaN and create invalid task objects. Either restrict this fixture to numeric IDs (update the parameter type + doc comment) or keep IDs as strings/leave them uncoerced so fixtures can represent both storage backends safely.

Suggested change
id: Number(overrides.id),
id: String(overrides.id),

Copilot uses AI. Check for mistakes.
title: overrides.title,
description: overrides.description ?? overrides.title,
status: overrides.status ?? 'pending',
Expand Down
Loading