qwen-code/packages/core/src/tools/todoWrite.test.ts

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');
});
});
});