Files
obsidian_ollama/tests/tool-executor.test.ts
T
fegger af9f0d165a Add list_vault_tags and get_vault_stats tools to all agent modes
Extend the tool registry to support vault-wide tag listing and statistical
overview tools, adding them to READ, EDIT, ORGANIZE, and RESEARCH agent
modes. Includes implementations for tag aggregation, folder structure
reporting, and metadataCache integration in VaultIndexer.
2026-05-21 10:08:05 +02:00

1521 lines
51 KiB
TypeScript
Executable File

import { ToolExecutor } from '../src/tool-executor';
import { TFile } from 'obsidian';
import { ToolCall, ToolResult } from '../src/types';
import { ErrorHandler } from '../src/error-handler';
import { TelemetryManager } from '../src/tool-telemetry';
// Mock Obsidian types
interface MockVault {
create: (path: string, content: string) => Promise<any>;
getAbstractFileByPath: (path: string) => any;
cachedRead: (file: any) => Promise<string>;
getMarkdownFiles: () => any[];
modify: (file: any, content: string) => Promise<void>;
rename: (file: any, newPath: string) => Promise<void>;
delete: (file: any) => Promise<void>;
}
interface MockApp {
metadataCache: {
getFileCache: jest.Mock;
};
// Mock app properties if needed
}
interface MockNotice {
(message: string): void;
}
// Mock Obsidian module
jest.mock('obsidian', () => {
class TFile {}
return {
Vault: jest.fn(),
App: jest.fn(),
Notice: jest.fn(),
TFile,
};
});
// Mock ErrorHandler
jest.mock('../src/error-handler', () => ({
ErrorHandler: {
handleError: jest.fn(),
},
}));
describe('ToolExecutor', () => {
let executor: ToolExecutor;
let mockVault: MockVault;
let mockApp: MockApp;
beforeEach(() => {
mockVault = {
create: jest.fn().mockResolvedValue(null),
getAbstractFileByPath: jest.fn(),
cachedRead: jest.fn().mockResolvedValue(''),
getMarkdownFiles: jest.fn().mockReturnValue([]),
modify: jest.fn().mockResolvedValue(undefined),
rename: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
mockApp = {
metadataCache: {
getFileCache: jest.fn().mockReturnValue(null),
},
} as MockApp;
executor = new ToolExecutor(mockVault as unknown as any, mockApp as unknown as any);
jest.clearAllMocks();
});
describe('handleToolCall', () => {
describe('create_file tool', () => {
it('should successfully create a file with valid arguments', async () => {
const call: ToolCall = {
id: 'call_1',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test-file.md',
content: 'Test content',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content');
});
it('should handle object arguments directly', async () => {
const call: ToolCall = {
id: 'call_2',
type: 'function',
function: {
name: 'create_file',
arguments: {
path: 'obj-args-file.md',
content: 'Object args content',
} as unknown as string,
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('obj-args-file.md', 'Object args content');
});
it('should successfully create a file in a subdirectory', async () => {
const call: ToolCall = {
id: 'call_3',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'subdirectory/test-file.md',
content: 'Subdir content',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith(
'subdirectory/test-file.md',
'Subdir content'
);
});
it('should handle multiple slashes gracefully by normalizing path', async () => {
const call: ToolCall = {
id: 'call_4',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test//file.md',
content: 'Test content',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content');
});
it('should handle empty content gracefully', async () => {
const call: ToolCall = {
id: 'call_5',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'empty-file.md',
content: '',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', '');
});
it('should allow filenames with consecutive dots', async () => {
const call: ToolCall = {
id: 'call_6',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'project..notes.md',
content: 'Test content',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content');
});
it('should reject path traversal attempts with ..', async () => {
const call: ToolCall = {
id: 'call_7',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '../test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path traversal attempts with .\\', async () => {
const call: ToolCall = {
id: 'call_8',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '.\\test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path traversal attempts with /..', async () => {
const call: ToolCall = {
id: 'call_9',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '/../test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject absolute paths starting with /', async () => {
const call: ToolCall = {
id: 'call_10',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '/var/test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject absolute paths starting with \\', async () => {
const call: ToolCall = {
id: 'call_11',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '\\var\\test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject Windows drive letters', async () => {
const call: ToolCall = {
id: 'call_12',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'C:\\test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject empty path', async () => {
const call: ToolCall = {
id: 'call_13',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject undefined path', async () => {
const call: ToolCall = {
id: 'call_14',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with invalid characters <', async () => {
const call: ToolCall = {
id: 'call_15',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test<file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with invalid characters >', async () => {
const call: ToolCall = {
id: 'call_16',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test>file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with invalid characters :', async () => {
const call: ToolCall = {
id: 'call_17',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test:file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with invalid characters |', async () => {
const call: ToolCall = {
id: 'call_18',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test|file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with invalid characters ?', async () => {
const call: ToolCall = {
id: 'call_19',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test?file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with invalid characters *', async () => {
const call: ToolCall = {
id: 'call_20',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test*file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path longer than 200 characters', async () => {
const call: ToolCall = {
id: 'call_21',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'a'.repeat(201) + '.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path with ~ character', async () => {
const call: ToolCall = {
id: 'call_22',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test~file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject non-string content', async () => {
const call: ToolCall = {
id: 'call_23',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test-file.md',
content: 123,
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject non-string path', async () => {
const call: ToolCall = {
id: 'call_24',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 123,
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should handle vault.create rejection gracefully', async () => {
mockVault.create = jest.fn().mockRejectedValue(new Error('Permission denied'));
const call: ToolCall = {
id: 'call_25',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'test-file.md',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
});
it('should handle invalid JSON in arguments', async () => {
const call: ToolCall = {
id: 'call_26',
type: 'function',
function: {
name: 'create_file',
arguments: 'invalid json',
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
});
describe('read_vault_file tool', () => {
it('should successfully read an existing file', async () => {
const mockFile = {
path: 'test-file.md',
basename: 'test-file.md',
};
// Create a proper mock TFile class for instanceof checks
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('test-file.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('File content');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]);
const call: ToolCall = {
id: 'call_28',
type: 'function',
function: {
name: 'read_vault_file',
arguments: JSON.stringify({
path: 'test-file.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.message).toBe('File read successfully');
expect(result.data).toEqual({ path: 'test-file.md', content: 'File content' });
});
it('should reject path traversal attempts', async () => {
const call: ToolCall = {
id: 'call_29',
type: 'function',
function: {
name: 'read_vault_file',
arguments: JSON.stringify({
path: '../test-file.md',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
});
it('should reject invalid characters in path', async () => {
const call: ToolCall = {
id: 'call_30',
type: 'function',
function: {
name: 'read_vault_file',
arguments: JSON.stringify({
path: 'test<file.md',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
});
it('should throw error when file not found', async () => {
// Return null to simulate file not found
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
mockVault.cachedRead = jest.fn().mockResolvedValue('');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]);
const call: ToolCall = {
id: 'call_31',
type: 'function',
function: {
name: 'read_vault_file',
arguments: JSON.stringify({
path: 'nonexistent.md',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow(
'File not found: nonexistent.md'
);
});
it('should reject non-string path', async () => {
const call: ToolCall = {
id: 'call_32',
type: 'function',
function: {
name: 'read_vault_file',
arguments: JSON.stringify({
path: 123,
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow('Path must be a string');
});
});
describe('search_vault_files tool', () => {
it('should successfully search vault files', async () => {
const mockFiles = [
{ path: 'file1.md', basename: 'file1.md' },
{ path: 'file2.md', basename: 'file2.md' },
];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([
{ path: 'query.md', basename: 'query.md' },
{ path: 'other.md', basename: 'other.md' },
{ path: 'query2.md', basename: 'query2.md' },
{ path: 'unrelated.md', basename: 'unrelated.md' },
] as unknown as any[]);
const call: ToolCall = {
id: 'call_33',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'query',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.message).toContain('Found 2 matching files');
expect(result.data).toHaveLength(2);
expect(result.data).toContainEqual({ path: 'query.md', basename: 'query.md' });
expect(result.data).toContainEqual({ path: 'query2.md', basename: 'query2.md' });
});
it('should limit results based on limit parameter', async () => {
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([
{ path: 'result1.md', basename: 'result1.md' },
{ path: 'result2.md', basename: 'result2.md' },
{ path: 'result3.md', basename: 'result3.md' },
{ path: 'result4.md', basename: 'result4.md' },
{ path: 'result5.md', basename: 'result5.md' },
] as unknown as any[]);
const call: ToolCall = {
id: 'call_34',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'result',
limit: 3,
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.data).toHaveLength(3);
});
it('should use default limit of 10 when no limit specified', async () => {
const mockFiles = Array.from({ length: 15 }, (_, i) => ({
path: `match${i}.md`,
basename: `match${i}.md`,
}));
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(mockFiles as unknown as any[]);
const call: ToolCall = {
id: 'call_35',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'match',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.data).toHaveLength(10);
});
it('should handle case-insensitive search', async () => {
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([
{ path: 'QUERY.md', basename: 'QUERY.md' },
{ path: 'Query.md', basename: 'Query.md' },
{ path: 'query.md', basename: 'query.md' },
{ path: 'other.md', basename: 'other.md' },
] as unknown as any[]);
const call: ToolCall = {
id: 'call_36',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'query',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.data).toHaveLength(3);
});
it('should reject non-string query', async () => {
const call: ToolCall = {
id: 'call_37',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 123,
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow('Query must be a string');
});
it('should return empty array when no matches found', async () => {
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([
{ path: 'unrelated1.md', basename: 'unrelated1.md' },
{ path: 'unrelated2.md', basename: 'unrelated2.md' },
] as unknown as any[]);
const call: ToolCall = {
id: 'call_38',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'nomatches',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.data).toHaveLength(0);
expect(result.message).toContain('Found 0 matching files');
});
it('should use VaultIndexer for rich search when available', async () => {
const mockIndexer = {
searchVault: jest.fn().mockResolvedValue([
{
path: 'Projects/AI/ml-basics.md',
title: 'Machine Learning Basics',
content: 'Intro to ML...',
score: 0.95,
tags: 'ai, ml, tutorial',
},
{
path: 'Projects/AI/deep-learning.md',
title: 'Deep Learning',
content: 'Neural networks...',
score: 0.88,
tags: 'ai, neural-networks',
},
]),
};
const indexedExecutor = new ToolExecutor(
mockVault as unknown as any,
mockApp as unknown as any,
undefined,
mockIndexer as unknown as any
);
const call: ToolCall = {
id: 'call_39',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'machine learning',
limit: 5,
}),
},
};
const result = await indexedExecutor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.data).toHaveLength(2);
expect(mockIndexer.searchVault).toHaveBeenCalledWith('machine learning', 5);
const firstResult = (result.data as any[])[0];
expect(firstResult).toMatchObject({
path: 'Projects/AI/ml-basics.md',
basename: 'ml-basics.md',
title: 'Machine Learning Basics',
score: 0.95,
tags: 'ai, ml, tutorial',
});
});
});
describe('executeTool method', () => {
it('should execute create_file tool successfully', async () => {
const result = await executor.executeTool('create_file', {
path: 'test-file.md',
content: 'Test content',
});
expect(result.success).toBe(true);
expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content');
});
it('should execute read_vault_file tool successfully', async () => {
// Create a proper mock TFile class for instanceof checks
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('test-file.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('File content');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]);
const result = await executor.executeTool('read_vault_file', {
path: 'test-file.md',
});
expect(result.success).toBe(true);
});
it('should execute search_vault_files tool successfully', async () => {
mockVault.getMarkdownFiles = jest
.fn()
.mockReturnValue([{ path: 'match.md', basename: 'match.md' }] as unknown as any[]);
const result = await executor.executeTool('search_vault_files', {
query: 'match',
});
expect(result.success).toBe(true);
expect(result.data).toHaveLength(1);
});
it('should handle unknown tool', async () => {
const result = await executor.executeTool('unknown_tool', {});
expect(result.success).toBe(false);
expect(result.message).toContain('Unknown tool: unknown_tool');
});
});
describe('unknown tool', () => {
it('should return failure for unknown tool', async () => {
const call: ToolCall = {
id: 'call_39',
type: 'function',
function: {
name: 'unknown_tool',
arguments: JSON.stringify({}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: false, message: 'Unknown tool: unknown_tool' });
});
});
describe('create_note tool', () => {
it('should create a note successfully', async () => {
const call: ToolCall = {
id: 'call_cn1',
type: 'function',
function: {
name: 'create_note',
arguments: JSON.stringify({
path: 'New Note.md',
content: '# Hello\nWorld',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result).toEqual({ success: true, message: 'Note created successfully' });
expect(mockVault.create).toHaveBeenCalledWith('New Note.md', '# Hello\nWorld');
});
});
describe('append_to_note tool', () => {
it('should append content to an existing note', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Existing content');
const call: ToolCall = {
id: 'call_an1',
type: 'function',
function: {
name: 'append_to_note',
arguments: JSON.stringify({
path: 'note.md',
content: 'Appended text',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.message).toBe('Content appended successfully');
expect(mockVault.modify).toHaveBeenCalled();
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toBe('Existing content\nAppended text');
});
it('should append without extra newline if content already ends with newline', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Existing content\n');
const call: ToolCall = {
id: 'call_an2',
type: 'function',
function: {
name: 'append_to_note',
arguments: JSON.stringify({
path: 'note.md',
content: 'Appended text',
}),
},
};
await executor.handleToolCall(call);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toBe('Existing content\nAppended text');
});
});
describe('replace_note_section tool', () => {
it('should replace a section under a heading', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const originalContent = `# Title\n\n## Section A\nOld content\n\n## Section B\nOther content`;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
const call: ToolCall = {
id: 'call_rs1',
type: 'function',
function: {
name: 'replace_note_section',
arguments: JSON.stringify({
path: 'note.md',
heading: 'Section A',
content: 'New content',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.message).toBe('Section "Section A" replaced successfully');
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('New content');
expect(modifiedContent).not.toContain('Old content');
expect(modifiedContent).toContain('## Section B');
});
it('should throw error when heading not found', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('# Title\nBody');
const call: ToolCall = {
id: 'call_rs2',
type: 'function',
function: {
name: 'replace_note_section',
arguments: JSON.stringify({
path: 'note.md',
heading: 'Missing Section',
content: 'New content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow(
'Heading "Missing Section" not found'
);
});
});
describe('update_frontmatter tool', () => {
it('should update existing frontmatter fields', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const originalContent = '---\ntitle: Old Title\ntags: idea\n---\nBody';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
const call: ToolCall = {
id: 'call_uf1',
type: 'function',
function: {
name: 'update_frontmatter',
arguments: JSON.stringify({
path: 'note.md',
fields: { title: 'New Title', status: 'done' },
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('title: New Title');
expect(modifiedContent).toContain('tags: idea');
expect(modifiedContent).toContain('status: done');
});
it('should create frontmatter if none exists', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Just body content');
const call: ToolCall = {
id: 'call_uf2',
type: 'function',
function: {
name: 'update_frontmatter',
arguments: JSON.stringify({
path: 'note.md',
fields: { title: 'New Note' },
}),
},
};
await executor.handleToolCall(call);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('---');
expect(modifiedContent).toContain('title: New Note');
expect(modifiedContent).toContain('Just body content');
});
it('should remove a field when set to null', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const originalContent = '---\ntitle: Note\ndraft: true\n---\nBody';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
const call: ToolCall = {
id: 'call_uf3',
type: 'function',
function: {
name: 'update_frontmatter',
arguments: JSON.stringify({
path: 'note.md',
fields: { draft: null },
}),
},
};
await executor.handleToolCall(call);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('title: Note');
expect(modifiedContent).not.toContain('draft: true');
});
});
describe('rename_note tool', () => {
it('should rename a note', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const file = new MockTFile('old.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
const call: ToolCall = {
id: 'call_rn1',
type: 'function',
function: {
name: 'rename_note',
arguments: JSON.stringify({
oldPath: 'old.md',
newPath: 'new.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(mockVault.rename).toHaveBeenCalledWith(file, 'new.md');
});
});
describe('move_note tool', () => {
it('should move a note into a folder', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const file = new MockTFile('Projects/old.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
const call: ToolCall = {
id: 'call_mn1',
type: 'function',
function: {
name: 'move_note',
arguments: JSON.stringify({
path: 'Projects/old.md',
folder: 'Archive',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
});
});
describe('delete_note tool', () => {
it('should delete a note', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const file = new MockTFile('note.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
const call: ToolCall = {
id: 'call_dn1',
type: 'function',
function: {
name: 'delete_note',
arguments: JSON.stringify({
path: 'note.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect(mockVault.delete).toHaveBeenCalledWith(file);
});
});
describe('insert_link tool', () => {
it('should insert a wikilink without anchor text', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('source.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Source content');
const call: ToolCall = {
id: 'call_il1',
type: 'function',
function: {
name: 'insert_link',
arguments: JSON.stringify({
sourcePath: 'source.md',
targetPath: 'target.md',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('[[target.md]]');
});
it('should insert a wikilink with anchor text', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('source.md'));
mockVault.cachedRead = jest.fn().mockResolvedValue('Source content');
const call: ToolCall = {
id: 'call_il2',
type: 'function',
function: {
name: 'insert_link',
arguments: JSON.stringify({
sourcePath: 'source.md',
targetPath: 'target.md',
anchorText: 'My Target',
}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
expect(modifiedContent).toContain('[[target.md|My Target]]');
});
});
});
describe('list_vault_tags tool', () => {
it('should list all tags sorted by name', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const files = [new MockTFile('a.md'), new MockTFile('b.md'), new MockTFile('c.md')];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
if (f.path === 'a.md') return { frontmatter: { tags: ['project', 'alpha'] } };
if (f.path === 'b.md') return { frontmatter: { tags: 'project, beta' } };
return { frontmatter: {} };
});
const call: ToolCall = {
id: 'call_lt1',
type: 'function',
function: {
name: 'list_vault_tags',
arguments: JSON.stringify({ sortBy: 'name' }),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
expect((result.data as any[]).length).toBe(3);
expect((result.data as any[])[0].tag).toBe('alpha');
expect((result.data as any[])[1].tag).toBe('beta');
expect((result.data as any[])[2].tag).toBe('project');
expect((result.data as any[])[2].count).toBe(2);
});
it('should sort tags by count', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
}
}
const files = [new MockTFile('a.md'), new MockTFile('b.md')];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
if (f.path === 'a.md') return { frontmatter: { tags: ['common', 'rare'] } };
if (f.path === 'b.md') return { frontmatter: { tags: ['common'] } };
return { frontmatter: {} };
});
const call: ToolCall = {
id: 'call_lt2',
type: 'function',
function: {
name: 'list_vault_tags',
arguments: JSON.stringify({ sortBy: 'count' }),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const data = result.data as any[];
expect(data[0].tag).toBe('common');
expect(data[0].count).toBe(2);
expect(data[1].tag).toBe('rare');
expect(data[1].count).toBe(1);
});
});
describe('get_vault_stats tool', () => {
it('should return vault overview stats', async () => {
class MockTFile extends TFile {
path: string;
basename: string;
extension: string;
name: string;
constructor(path: string, mtime?: number) {
super();
this.path = path;
this.basename = path.split('/').pop() || path;
this.extension = this.basename.split('.').pop() || '';
this.name = this.basename;
if (mtime) {
(this as any).stat = { mtime, ctime: mtime, size: 100 };
}
}
}
const files = [
new MockTFile('Projects/alpha.md', 1000),
new MockTFile('Projects/beta.md', 2000),
new MockTFile('notes/daily.md', 1500),
];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
mockVault.cachedRead = jest.fn().mockResolvedValue('content');
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
if (f.path === 'Projects/alpha.md') return { frontmatter: { tags: ['project'] } };
if (f.path === 'Projects/beta.md') return { frontmatter: { tags: ['project', 'done'] } };
return { frontmatter: {} };
});
const call: ToolCall = {
id: 'call_vs1',
type: 'function',
function: {
name: 'get_vault_stats',
arguments: JSON.stringify({}),
},
};
const result = await executor.handleToolCall(call);
expect(result.success).toBe(true);
const data = result.data as any;
expect(data.totalNotes).toBe(3);
expect(data.totalFolders).toBe(2);
expect(data.folders).toContain('Projects');
expect(data.folders).toContain('notes');
expect(data.taggedNotes).toBe(2);
expect(data.untaggedNotes).toBe(1);
expect(data.topTags).toHaveLength(2);
expect(data.topTags[0].tag).toBe('project');
expect(data.topTags[0].count).toBe(2);
expect(data.recentFiles[0]).toBe('Projects/beta.md');
});
});
describe('telemetry integration', () => {
let telemetryManager: TelemetryManager;
let telemetryExecutor: ToolExecutor;
beforeEach(() => {
telemetryManager = new TelemetryManager({ enabled: true, maxEntries: 100 });
telemetryExecutor = new ToolExecutor(
mockVault as unknown as any,
mockApp as unknown as any,
telemetryManager
);
});
it('should record successful tool calls in telemetry', async () => {
mockVault.create = jest.fn().mockResolvedValue(null);
const call: ToolCall = {
id: 'call_t1',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({ path: 'test.md', content: 'hello' }),
},
};
await telemetryExecutor.handleToolCall(call);
const entries = telemetryManager.getEntriesByType('tool_call');
expect(entries).toHaveLength(1);
expect((entries[0] as any).toolName).toBe('create_file');
expect((entries[0] as any).success).toBe(true);
expect((entries[0] as any).durationMs).toBeGreaterThanOrEqual(0);
});
it('should record failed tool calls in telemetry', async () => {
const call: ToolCall = {
id: 'call_t2',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({ path: '/invalid/path.md', content: 'hello' }),
},
};
await expect(telemetryExecutor.handleToolCall(call)).rejects.toThrow();
const entries = telemetryManager.getEntriesByType('tool_call');
expect(entries).toHaveLength(1);
expect((entries[0] as any).toolName).toBe('create_file');
expect((entries[0] as any).success).toBe(false);
});
it('should include parsed args in telemetry', async () => {
mockVault.create = jest.fn().mockResolvedValue(null);
const call: ToolCall = {
id: 'call_t3',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({ path: 'note.md', content: 'data' }),
},
};
await telemetryExecutor.handleToolCall(call);
const entries = telemetryManager.getEntriesByType('tool_call');
expect((entries[0] as any).args).toEqual({ path: 'note.md', content: 'data' });
});
it('should not record telemetry when telemetry manager is undefined', async () => {
const noTelemetryExecutor = new ToolExecutor(
mockVault as unknown as any,
mockApp as unknown as any
);
mockVault.create = jest.fn().mockResolvedValue(null);
const call: ToolCall = {
id: 'call_t4',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({ path: 'x.md', content: 'y' }),
},
};
// Should not throw
await noTelemetryExecutor.handleToolCall(call);
});
});
});