59df2f6856
Update error handling and improve streaming capabilities in the Ollama client and related components. Key changes include: - Simplify error types and improve error handling - Refactor streaming logic to use async generators - Update tool execution and vault indexing - Improve utility functions and types - Update test files to reflect changes
430 lines
14 KiB
TypeScript
Executable File
430 lines
14 KiB
TypeScript
Executable File
import { ToolExecutor } from '../src/tool-executor';
|
|
import { ToolCall, ToolResult } from '../src/types';
|
|
import { ErrorHandler } from '../src/error-handler';
|
|
|
|
// Mock Obsidian types
|
|
interface MockVault {
|
|
create: (path: string, content: string) => Promise<any>;
|
|
}
|
|
interface MockApp {
|
|
// Mock app properties if needed
|
|
}
|
|
interface MockNotice {
|
|
(message: string): void;
|
|
}
|
|
|
|
// Mock Obsidian module
|
|
jest.mock('obsidian', () => ({
|
|
Vault: jest.fn(),
|
|
App: jest.fn(),
|
|
Notice: jest.fn(),
|
|
}));
|
|
|
|
// 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),
|
|
};
|
|
mockApp = {} 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 = {
|
|
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: 'File created successfully' });
|
|
expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content');
|
|
});
|
|
|
|
it('should handle object arguments directly', async () => {
|
|
const call: ToolCall = {
|
|
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: 'File 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 = {
|
|
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: 'File 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 = {
|
|
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: 'File created successfully' });
|
|
expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content');
|
|
});
|
|
|
|
it('should handle empty content gracefully', async () => {
|
|
const call: ToolCall = {
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: JSON.stringify({
|
|
path: 'empty-file.md',
|
|
content: '',
|
|
}),
|
|
},
|
|
};
|
|
const result = await executor.handleToolCall(call);
|
|
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
|
expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', '');
|
|
});
|
|
|
|
it('should allow filenames with consecutive dots', async () => {
|
|
const call: ToolCall = {
|
|
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: 'File created successfully' });
|
|
expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content');
|
|
});
|
|
|
|
it('should reject path traversal attempts with ..', async () => {
|
|
const call: ToolCall = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: 'invalid json',
|
|
},
|
|
};
|
|
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
|
expect(mockVault.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('unknown tool', () => {
|
|
it('should return failure for unknown tool', async () => {
|
|
const call: ToolCall = {
|
|
function: {
|
|
name: 'unknown_tool',
|
|
arguments: JSON.stringify({}),
|
|
},
|
|
};
|
|
const result = await executor.handleToolCall(call);
|
|
expect(result).toEqual({ success: false, message: 'Unknown tool: unknown_tool' });
|
|
});
|
|
});
|
|
});
|
|
});
|