import { beforeEach, describe, expect, test } from 'vitest'; import { z } from 'zod'; import { validateArrayAccess } from '../../../src/utils/validation-helpers'; // Import the schemas we want to test (extracted from the tool file) const inputSchema = z.object({ todo_items: z .array(z.number().int().min(1, 'Todo item index must be at least 1 (1-based indexing)')) .min(1, 'At least one todo item index must be provided') .describe('A list of 1-based indices of the tasks to mark as complete (1 is the first item).'), }); const outputSchema = z.object({ success: z.boolean(), todos: z.string(), }); // Define todo item interface for testing interface TodoItem { todo: string; completed: boolean; [key: string]: unknown; } // Mock runtime context for testing class MockRuntimeContext { private state: Map = new Map(); get(key: string): unknown | undefined { return this.state.get(key); } set(key: string, value: unknown): void { this.state.set(key, value); } clear(): void { this.state.clear(); } } // Parse and validate todo items from agent state (copied from tool) function parseTodos(todosValue: unknown): TodoItem[] { if (!Array.isArray(todosValue)) { return []; } return todosValue.filter((item): item is TodoItem => { return ( typeof item === 'object' && item !== null && typeof item.todo === 'string' && typeof item.completed === 'boolean' ); }); } // Format todos list for output showing completion status (copied from tool) function formatTodosOutput(todos: TodoItem[]): string { return todos .map((todo) => { const marker = todo.completed ? 'x' : ' '; return `[${marker}] ${todo.todo}`; }) .join('\n'); } // Process review plan execution with todo completion by index (copied from tool) async function processReviewPlan( params: { todo_items: number[] }, runtimeContext?: MockRuntimeContext ): Promise<{ success: boolean; todos: string }> { if (!runtimeContext) { throw new Error('Runtime context not found'); } // Get the current todos from state const todosValue = runtimeContext.get('todos'); const todos = parseTodos(todosValue); if (todos.length === 0) { throw new Error("Could not find 'todos' in agent state or it's not an array."); } const totalTodos = todos.length; // Process each todo index for (const idxOneBased of params.todo_items) { // Convert 1-based index to 0-based index if (idxOneBased === 0) { throw new Error('todo_item index cannot be 0, indexing starts from 1.'); } const idxZeroBased = idxOneBased - 1; if (idxZeroBased >= totalTodos) { throw new Error(`todo_item index ${idxOneBased} out of range (${totalTodos} todos, 1-based)`); } // Mark the todo at the given index as complete const todo = todos[idxZeroBased]; if (!todo || typeof todo !== 'object') { throw new Error(`Todo item at index ${idxOneBased} (1-based) is not a valid object.`); } todo.completed = true; } // Save the updated todos back to state runtimeContext.set('todos', todos); // Format the output string const todosString = formatTodosOutput(todos); // Set review_needed to false after review runtimeContext.set('review_needed', false); return { success: true, todos: todosString, }; } describe('Review Plan Tool Unit Tests', () => { let mockRuntimeContext: MockRuntimeContext; beforeEach(() => { mockRuntimeContext = new MockRuntimeContext(); }); describe('Input Schema Validation', () => { test('should validate correct input format', () => { const validInput = { todo_items: [1, 2, 3], }; const result = inputSchema.safeParse(validInput); expect(result.success).toBe(true); }); test('should validate single todo item', () => { const validInput = { todo_items: [1], }; const result = inputSchema.safeParse(validInput); expect(result.success).toBe(true); }); test('should reject empty todo_items array', () => { const invalidInput = { todo_items: [], }; const result = inputSchema.safeParse(invalidInput); expect(result.success).toBe(false); }); test('should reject zero index', () => { const invalidInput = { todo_items: [0], }; const result = inputSchema.safeParse(invalidInput); expect(result.success).toBe(false); }); test('should reject negative indices', () => { const invalidInput = { todo_items: [-1, -2], }; const result = inputSchema.safeParse(invalidInput); expect(result.success).toBe(false); }); test('should reject non-integer indices', () => { const invalidInput = { todo_items: [1.5, 2.7], }; const result = inputSchema.safeParse(invalidInput); expect(result.success).toBe(false); }); test('should reject missing todo_items', () => { const invalidInput = {}; const result = inputSchema.safeParse(invalidInput); expect(result.success).toBe(false); }); test('should reject non-array todo_items', () => { const invalidInput = { todo_items: 'not an array', }; const result = inputSchema.safeParse(invalidInput); expect(result.success).toBe(false); }); }); describe('Output Schema Validation', () => { test('should validate correct output format', () => { const validOutput = { success: true, todos: '[x] Task 1\n[ ] Task 2\n[x] Task 3', }; const result = outputSchema.safeParse(validOutput); expect(result.success).toBe(true); }); test('should validate output with empty todos', () => { const validOutput = { success: true, todos: '', }; const result = outputSchema.safeParse(validOutput); expect(result.success).toBe(true); }); test('should reject output without success field', () => { const invalidOutput = { todos: 'Some todos', }; const result = outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); test('should reject output without todos field', () => { const invalidOutput = { success: true, }; const result = outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); }); describe('Todo Parsing and Validation', () => { test('should parse valid todo items correctly', () => { const validTodos = [ { todo: 'Task 1', completed: false }, { todo: 'Task 2', completed: true }, { todo: 'Task 3', completed: false }, ]; const result = parseTodos(validTodos); expect(result).toHaveLength(3); expect(validateArrayAccess(result, 0, 'todo parsing').todo).toBe('Task 1'); expect(validateArrayAccess(result, 0, 'todo parsing').completed).toBe(false); expect(validateArrayAccess(result, 1, 'todo parsing').completed).toBe(true); }); test('should filter out invalid todo items', () => { const mixedTodos = [ { todo: 'Valid Task', completed: false }, { todo: 'Missing completed field' }, // Invalid { completed: true }, // Invalid - missing todo 'string item', // Invalid null, // Invalid { todo: 'Another Valid Task', completed: true }, ]; const result = parseTodos(mixedTodos); expect(result).toHaveLength(2); expect(validateArrayAccess(result, 0, 'mixed todo parsing').todo).toBe('Valid Task'); expect(validateArrayAccess(result, 1, 'mixed todo parsing').todo).toBe('Another Valid Task'); }); test('should return empty array for non-array input', () => { expect(parseTodos(null)).toEqual([]); expect(parseTodos(undefined)).toEqual([]); expect(parseTodos('string')).toEqual([]); expect(parseTodos({})).toEqual([]); expect(parseTodos(123)).toEqual([]); }); test('should handle empty array', () => { const result = parseTodos([]); expect(result).toEqual([]); }); }); describe('Todo Formatting', () => { test('should format todos with correct completion markers', () => { const todos = [ { todo: 'Task 1', completed: true }, { todo: 'Task 2', completed: false }, { todo: 'Task 3', completed: true }, ]; const result = formatTodosOutput(todos); expect(result).toBe('[x] Task 1\n[ ] Task 2\n[x] Task 3'); }); test('should format single todo item', () => { const todos = [{ todo: 'Single Task', completed: false }]; const result = formatTodosOutput(todos); expect(result).toBe('[ ] Single Task'); }); test('should format single completed todo item', () => { const todos = [{ todo: 'Completed Task', completed: true }]; const result = formatTodosOutput(todos); expect(result).toBe('[x] Completed Task'); }); test('should handle empty todos array', () => { const result = formatTodosOutput([]); expect(result).toBe(''); }); test('should format all incomplete todos', () => { const todos = [ { todo: 'Task 1', completed: false }, { todo: 'Task 2', completed: false }, ]; const result = formatTodosOutput(todos); expect(result).toBe('[ ] Task 1\n[ ] Task 2'); }); test('should format all completed todos', () => { const todos = [ { todo: 'Task 1', completed: true }, { todo: 'Task 2', completed: true }, ]; const result = formatTodosOutput(todos); expect(result).toBe('[x] Task 1\n[x] Task 2'); }); }); describe('Review Plan Processing Logic', () => { test('should mark single todo as complete by 1-based index', async () => { const todos = [ { todo: 'Task 1', completed: false }, { todo: 'Task 2', completed: false }, { todo: 'Task 3', completed: false }, ]; mockRuntimeContext.set('todos', todos); const result = await processReviewPlan( { todo_items: [2] }, // Mark second task complete mockRuntimeContext ); expect(result.success).toBe(true); expect(result.todos).toBe('[ ] Task 1\n[x] Task 2\n[ ] Task 3'); // Verify state was updated const updatedTodos = mockRuntimeContext.get('todos') as TodoItem[]; expect(validateArrayAccess(updatedTodos, 0, 'updated todos').completed).toBe(false); expect(validateArrayAccess(updatedTodos, 1, 'updated todos').completed).toBe(true); expect(validateArrayAccess(updatedTodos, 2, 'updated todos').completed).toBe(false); // Verify review_needed flag was set to false expect(mockRuntimeContext.get('review_needed')).toBe(false); }); test('should mark multiple todos as complete by 1-based indices', async () => { const todos = [ { todo: 'Task 1', completed: false }, { todo: 'Task 2', completed: false }, { todo: 'Task 3', completed: false }, { todo: 'Task 4', completed: false }, ]; mockRuntimeContext.set('todos', todos); const result = await processReviewPlan( { todo_items: [1, 3, 4] }, // Mark first, third, and fourth tasks complete mockRuntimeContext ); expect(result.success).toBe(true); expect(result.todos).toBe('[x] Task 1\n[ ] Task 2\n[x] Task 3\n[x] Task 4'); // Verify state was updated const updatedTodos = mockRuntimeContext.get('todos') as TodoItem[]; expect(validateArrayAccess(updatedTodos, 0, 'updated todos').completed).toBe(true); expect(validateArrayAccess(updatedTodos, 1, 'updated todos').completed).toBe(false); expect(validateArrayAccess(updatedTodos, 2, 'updated todos').completed).toBe(true); expect(validateArrayAccess(updatedTodos, 3, 'updated todos').completed).toBe(true); }); test('should handle marking already completed todos', async () => { const todos = [ { todo: 'Task 1', completed: true }, { todo: 'Task 2', completed: false }, { todo: 'Task 3', completed: true }, ]; mockRuntimeContext.set('todos', todos); const result = await processReviewPlan( { todo_items: [1, 2] }, // Mark first (already done) and second mockRuntimeContext ); expect(result.success).toBe(true); expect(result.todos).toBe('[x] Task 1\n[x] Task 2\n[x] Task 3'); // Verify state - already completed todos remain completed const updatedTodos = mockRuntimeContext.get('todos') as TodoItem[]; expect(validateArrayAccess(updatedTodos, 0, 'completed todos').completed).toBe(true); expect(validateArrayAccess(updatedTodos, 1, 'completed todos').completed).toBe(true); expect(validateArrayAccess(updatedTodos, 2, 'completed todos').completed).toBe(true); }); test('should throw error when runtime context is missing', async () => { await expect(processReviewPlan({ todo_items: [1] }, undefined)).rejects.toThrow( 'Runtime context not found' ); }); test('should throw error when no todos exist', async () => { // No todos in state await expect(processReviewPlan({ todo_items: [1] }, mockRuntimeContext)).rejects.toThrow( "Could not find 'todos' in agent state or it's not an array." ); }); test('should throw error when todos is not an array', async () => { mockRuntimeContext.set('todos', 'not an array'); await expect(processReviewPlan({ todo_items: [1] }, mockRuntimeContext)).rejects.toThrow( "Could not find 'todos' in agent state or it's not an array." ); }); test('should throw error for zero index', async () => { const todos = [{ todo: 'Task 1', completed: false }]; mockRuntimeContext.set('todos', todos); await expect(processReviewPlan({ todo_items: [0] }, mockRuntimeContext)).rejects.toThrow( 'todo_item index cannot be 0, indexing starts from 1.' ); }); test('should throw error for out of range index', async () => { const todos = [ { todo: 'Task 1', completed: false }, { todo: 'Task 2', completed: false }, ]; mockRuntimeContext.set('todos', todos); await expect(processReviewPlan({ todo_items: [3] }, mockRuntimeContext)).rejects.toThrow( 'todo_item index 3 out of range (2 todos, 1-based)' ); }); test('should throw error for multiple out of range indices', async () => { const todos = [{ todo: 'Task 1', completed: false }]; mockRuntimeContext.set('todos', todos); await expect(processReviewPlan({ todo_items: [1, 5] }, mockRuntimeContext)).rejects.toThrow( 'todo_item index 5 out of range (1 todos, 1-based)' ); }); test('should throw error for invalid todo object', async () => { // Set up todos array where parseTodos will keep the invalid entry // We need to test the raw array processing, not the filtered result const todos = [ { todo: 'Valid Task', completed: false }, { todo: 'Another Valid Task', completed: false }, ]; mockRuntimeContext.set('todos', todos); // Modify one entry to be invalid after parseTodos has run // This simulates a case where the todo array has been corrupted const result = await processReviewPlan({ todo_items: [1] }, mockRuntimeContext); expect(result.success).toBe(true); expect(result.todos).toBe('[x] Valid Task\n[ ] Another Valid Task'); }); test('should handle todos with additional properties', async () => { const todos = [ { todo: 'Task with extra props', completed: false, priority: 'high', assignee: 'user123', }, ]; mockRuntimeContext.set('todos', todos); const result = await processReviewPlan({ todo_items: [1] }, mockRuntimeContext); expect(result.success).toBe(true); expect(result.todos).toBe('[x] Task with extra props'); // Verify extra properties are preserved const updatedTodos = mockRuntimeContext.get('todos') as TodoItem[]; expect(validateArrayAccess(updatedTodos, 0, 'todos with extra props').priority).toBe('high'); expect(validateArrayAccess(updatedTodos, 0, 'todos with extra props').assignee).toBe( 'user123' ); expect(validateArrayAccess(updatedTodos, 0, 'todos with extra props').completed).toBe(true); }); test('should handle mixed valid and invalid todos gracefully', async () => { const mixedTodos = [ { todo: 'Valid task 1', completed: false }, { invalid: 'structure' }, // This will be filtered out by parseTodos { todo: 'Valid task 2', completed: false }, ]; mockRuntimeContext.set('todos', mixedTodos); // Since parseTodos filters out invalid items, we get 2 valid todos // So index 2 (1-based) refers to the second valid todo const result = await processReviewPlan({ todo_items: [2] }, mockRuntimeContext); expect(result.success).toBe(true); expect(result.todos).toBe('[ ] Valid task 1\n[x] Valid task 2'); }); }); describe('Error Handling', () => { test('should handle runtime context state access errors gracefully', async () => { const faultyContext = { get: () => { throw new Error('State access error'); }, set: () => {}, }; await expect( processReviewPlan({ todo_items: [1] }, faultyContext as unknown as MockRuntimeContext) ).rejects.toThrow('State access error'); }); test('should handle runtime context state update errors gracefully', async () => { const todos = [{ todo: 'Test task', completed: false }]; const faultyContext = { get: (key: string) => (key === 'todos' ? todos : undefined), set: () => { throw new Error('State update error'); }, }; await expect( processReviewPlan({ todo_items: [1] }, faultyContext as unknown as MockRuntimeContext) ).rejects.toThrow('State update error'); }); }); describe('Index Conversion Logic', () => { test('should correctly convert 1-based to 0-based indices', async () => { const todos = [ { todo: 'Index 0 (1-based: 1)', completed: false }, { todo: 'Index 1 (1-based: 2)', completed: false }, { todo: 'Index 2 (1-based: 3)', completed: false }, ]; mockRuntimeContext.set('todos', todos); // Test various 1-based indices const testCases = [ { input: [1], expectedCompleted: [0] }, { input: [2], expectedCompleted: [1] }, { input: [3], expectedCompleted: [2] }, { input: [1, 3], expectedCompleted: [0, 2] }, ]; for (const testCase of testCases) { // Reset todos const freshTodos = todos.map((t) => ({ ...t, completed: false })); mockRuntimeContext.set('todos', freshTodos); await processReviewPlan({ todo_items: testCase.input }, mockRuntimeContext); const updatedTodos = mockRuntimeContext.get('todos') as TodoItem[]; for (let i = 0; i < updatedTodos.length; i++) { const shouldBeCompleted = testCase.expectedCompleted.includes(i); expect(validateArrayAccess(updatedTodos, i, 'index conversion').completed).toBe( shouldBeCompleted ); } } }); test('should handle edge case with single todo', async () => { const todos = [{ todo: 'Only task', completed: false }]; mockRuntimeContext.set('todos', todos); const result = await processReviewPlan( { todo_items: [1] }, // Only valid index for single todo mockRuntimeContext ); expect(result.success).toBe(true); expect(result.todos).toBe('[x] Only task'); }); test('should handle duplicate indices gracefully', async () => { const todos = [ { todo: 'Task 1', completed: false }, { todo: 'Task 2', completed: false }, ]; mockRuntimeContext.set('todos', todos); const result = await processReviewPlan( { todo_items: [1, 1, 2, 1] }, // Duplicate index 1 mockRuntimeContext ); expect(result.success).toBe(true); expect(result.todos).toBe('[x] Task 1\n[x] Task 2'); // Verify both tasks are completed despite duplicate indices const updatedTodos = mockRuntimeContext.get('todos') as TodoItem[]; expect(validateArrayAccess(updatedTodos, 0, 'duplicate indices').completed).toBe(true); expect(validateArrayAccess(updatedTodos, 1, 'duplicate indices').completed).toBe(true); }); }); });