a573d33d0a
Replace the pending-actions preview flow with immediate execution and undo support. ToolExecutor now accepts an UndoManager and records create, modify, rename, and trash operations so users can roll back batches. Other fixes included: - Deep-merge nested config objects on settings load to preserve new default fields - Increase retry backoff from 10ms to 1000ms and widen the "invalid response format" check to handle prefixed messages - Fix semantic cache clear to null out the collection reference - Tighten memory regex to require "please always/never" - Increase vault indexing batch size from 1 to 5 - Remove unused modeRequiresPreview helper
1596 lines
54 KiB
TypeScript
Executable File
1596 lines
54 KiB
TypeScript
Executable File
import { ToolExecutor } from '../src/tool-executor';
|
|
import { TFile, TFolder } 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>;
|
|
createFolder: (path: 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>;
|
|
trash: (file: any, system: boolean) => 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,
|
|
TFolder: class TFolder {},
|
|
};
|
|
});
|
|
|
|
// 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),
|
|
createFolder: 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),
|
|
trash: 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 () => {
|
|
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
|
|
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.createFolder).toHaveBeenCalledWith('subdirectory');
|
|
expect(mockVault.create).toHaveBeenCalledWith(
|
|
'subdirectory/test-file.md',
|
|
'Subdir content'
|
|
);
|
|
});
|
|
|
|
it('should not recreate existing parent folders', async () => {
|
|
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new TFolder());
|
|
const call: ToolCall = {
|
|
id: 'call_existing_folder',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: JSON.stringify({
|
|
path: 'existing/test-file.md',
|
|
content: 'Subdir content',
|
|
}),
|
|
},
|
|
};
|
|
const result = await executor.handleToolCall(call);
|
|
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
|
expect(mockVault.createFolder).not.toHaveBeenCalled();
|
|
expect(mockVault.create).toHaveBeenCalledWith('existing/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 exact forbidden directory paths', async () => {
|
|
const call: ToolCall = {
|
|
id: 'call_forbidden_exact',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: JSON.stringify({
|
|
path: '.obsidian',
|
|
content: 'Test content',
|
|
}),
|
|
},
|
|
};
|
|
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
|
expect(mockVault.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject nested forbidden directory paths', async () => {
|
|
const call: ToolCall = {
|
|
id: 'call_forbidden_nested',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: JSON.stringify({
|
|
path: 'Notes/.git',
|
|
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()
|
|
.mockImplementation((path: string) => (path === 'Projects/old.md' ? file : null));
|
|
|
|
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.createFolder).toHaveBeenCalledWith('Archive');
|
|
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
|
|
});
|
|
|
|
it('should reject moving a note into a forbidden folder', async () => {
|
|
const call: ToolCall = {
|
|
id: 'call_mn_forbidden',
|
|
type: 'function',
|
|
function: {
|
|
name: 'move_note',
|
|
arguments: JSON.stringify({
|
|
path: 'Projects/old.md',
|
|
folder: '.obsidian',
|
|
}),
|
|
},
|
|
};
|
|
await expect(executor.handleToolCall(call)).rejects.toThrow('Invalid folder path detected');
|
|
expect(mockVault.rename).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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.trash).toHaveBeenCalledWith(file, true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|