Files
obsidian_ollama/tests/tool-executor.test.ts
T
fegger f1afba70ff Add structured memory injection and telemetry tracking
- Introduce buildMessagesWithMemory() to prepend memory context as a system
  message before LLM calls
- Record LLM call telemetry (tokens, duration) for follow-up requests in
  both streaming and non-streaming paths
- Add telemetry coverage for tool execution (success/failure, args,
  duration)
- Update tool-executor tests to verify telemetry integration with
  TelemetryManager
2026-05-20 22:46:42 +02:00

1335 lines
44 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');
});
});
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('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);
});
});
});