287 lines
8.5 KiB
TypeScript
287 lines
8.5 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { TodoWriteTool, TodoWriteParams, TodoItem } from './todoWrite.js';
|
|
import * as fs from 'fs/promises';
|
|
import * as fsSync from 'fs';
|
|
import { Config } from '../config/config.js';
|
|
|
|
// Mock fs modules
|
|
vi.mock('fs/promises');
|
|
vi.mock('fs');
|
|
|
|
const mockFs = vi.mocked(fs);
|
|
const mockFsSync = vi.mocked(fsSync);
|
|
|
|
describe('TodoWriteTool', () => {
|
|
let tool: TodoWriteTool;
|
|
let mockAbortSignal: AbortSignal;
|
|
let mockConfig: Config;
|
|
|
|
beforeEach(() => {
|
|
mockConfig = {
|
|
getSessionId: () => 'test-session-123',
|
|
} as Config;
|
|
tool = new TodoWriteTool(mockConfig);
|
|
mockAbortSignal = new AbortController().signal;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('validateToolParams', () => {
|
|
it('should validate correct parameters', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'in_progress' },
|
|
],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should accept empty todos array', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should accept single todo', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [{ id: '1', content: 'Task 1', status: 'pending' }],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should reject todos with empty content', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '1', content: '', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toContain(
|
|
'Each todo must have a non-empty "content" string',
|
|
);
|
|
});
|
|
|
|
it('should reject todos with empty id', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toContain('non-empty "id" string');
|
|
});
|
|
|
|
it('should reject todos with invalid status', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{
|
|
id: '1',
|
|
content: 'Task 1',
|
|
status: 'invalid' as TodoItem['status'],
|
|
},
|
|
{ id: '2', content: 'Task 2', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toContain(
|
|
'Each todo must have a valid "status" (pending, in_progress, completed)',
|
|
);
|
|
});
|
|
|
|
it('should reject todos with duplicate IDs', () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '1', content: 'Task 2', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
const result = tool.validateToolParams(params);
|
|
expect(result).toContain('unique');
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should create new todos file when none exists', async () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'in_progress' },
|
|
],
|
|
};
|
|
|
|
// Mock file not existing
|
|
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.writeFile.mockResolvedValue(undefined);
|
|
|
|
const invocation = tool.build(params);
|
|
const result = await invocation.execute(mockAbortSignal);
|
|
|
|
expect(result.llmContent).toContain('success');
|
|
expect(result.returnDisplay).toEqual({
|
|
type: 'todo_list',
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'in_progress' },
|
|
],
|
|
});
|
|
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining('test-session-123.json'),
|
|
expect.stringContaining('"todos"'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
it('should replace todos with new ones', async () => {
|
|
const existingTodos = [
|
|
{ id: '1', content: 'Existing Task', status: 'completed' },
|
|
];
|
|
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '1', content: 'Updated Task', status: 'completed' },
|
|
{ id: '2', content: 'New Task', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
// Mock existing file
|
|
mockFs.readFile.mockResolvedValue(
|
|
JSON.stringify({ todos: existingTodos }),
|
|
);
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.writeFile.mockResolvedValue(undefined);
|
|
|
|
const invocation = tool.build(params);
|
|
const result = await invocation.execute(mockAbortSignal);
|
|
|
|
expect(result.llmContent).toContain('success');
|
|
expect(result.returnDisplay).toEqual({
|
|
type: 'todo_list',
|
|
todos: [
|
|
{ id: '1', content: 'Updated Task', status: 'completed' },
|
|
{ id: '2', content: 'New Task', status: 'pending' },
|
|
],
|
|
});
|
|
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining('test-session-123.json'),
|
|
expect.stringMatching(/"Updated Task"/),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
it('should handle file write errors', async () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.writeFile.mockRejectedValue(new Error('Write failed'));
|
|
|
|
const invocation = tool.build(params);
|
|
const result = await invocation.execute(mockAbortSignal);
|
|
|
|
expect(result.llmContent).toContain('"success":false');
|
|
expect(result.returnDisplay).toContain('Error writing todos');
|
|
});
|
|
|
|
it('should handle empty todos array', async () => {
|
|
const params: TodoWriteParams = {
|
|
todos: [],
|
|
};
|
|
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.writeFile.mockResolvedValue(undefined);
|
|
|
|
const invocation = tool.build(params);
|
|
const result = await invocation.execute(mockAbortSignal);
|
|
|
|
expect(result.llmContent).toContain('success');
|
|
expect(result.returnDisplay).toEqual({
|
|
type: 'todo_list',
|
|
todos: [],
|
|
});
|
|
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining('test-session-123.json'),
|
|
expect.stringContaining('"todos"'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('tool properties', () => {
|
|
it('should have correct tool name', () => {
|
|
expect(TodoWriteTool.Name).toBe('todo_write');
|
|
expect(tool.name).toBe('todo_write');
|
|
});
|
|
|
|
it('should have correct display name', () => {
|
|
expect(tool.displayName).toBe('Todo Write');
|
|
});
|
|
|
|
it('should have correct kind', () => {
|
|
expect(tool.kind).toBe('think');
|
|
});
|
|
|
|
it('should have schema with required properties', () => {
|
|
const schema = tool.schema;
|
|
expect(schema.name).toBe('todo_write');
|
|
expect(schema.parametersJsonSchema).toHaveProperty('properties.todos');
|
|
expect(schema.parametersJsonSchema).not.toHaveProperty(
|
|
'properties.merge',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getDescription', () => {
|
|
it('should return "Create todos" when no todos file exists', () => {
|
|
// Mock existsSync to return false (file doesn't exist)
|
|
mockFsSync.existsSync.mockReturnValue(false);
|
|
|
|
const params = {
|
|
todos: [{ id: '1', content: 'Test todo', status: 'pending' as const }],
|
|
};
|
|
const invocation = tool.build(params);
|
|
expect(invocation.getDescription()).toBe('Create todos');
|
|
});
|
|
|
|
it('should return "Update todos" when todos file exists', () => {
|
|
// Mock existsSync to return true (file exists)
|
|
mockFsSync.existsSync.mockReturnValue(true);
|
|
|
|
const params = {
|
|
todos: [
|
|
{ id: '1', content: 'Updated todo', status: 'completed' as const },
|
|
],
|
|
};
|
|
const invocation = tool.build(params);
|
|
expect(invocation.getDescription()).toBe('Update todos');
|
|
});
|
|
});
|
|
});
|