f98afcd6b0
Introduces TelemetryManager to record tool calls, LLM token usage (prompt_eval_count, eval_count), and vault search queries with timing. Wires telemetry through ChatView, ToolExecutor, and OllamaClient with configurable limits and enable/disable toggle.
1028 lines
37 KiB
TypeScript
Executable File
1028 lines
37 KiB
TypeScript
Executable File
import { ChatView } from '../src/chat-view';
|
|
import { PluginSettings, OllamaMessage, ChatMessage, OllamaTool, ToolCall } from '../src/types';
|
|
import { ErrorHandler } from '../src/error-handler';
|
|
|
|
// Mock Obsidian types
|
|
interface MockVault {
|
|
getMarkdownFiles: () => any[];
|
|
read: () => Promise<string>;
|
|
cachedRead: () => Promise<string>;
|
|
getAbstractFileByPath: () => any;
|
|
create: () => Promise<any>;
|
|
modify: () => Promise<void>;
|
|
rename: () => Promise<void>;
|
|
delete: () => Promise<void>;
|
|
}
|
|
interface MockWorkspace {
|
|
getLeaf: () => any;
|
|
revealLeaf: () => void;
|
|
}
|
|
interface MockApp {
|
|
vault: MockVault;
|
|
workspace: MockWorkspace;
|
|
}
|
|
|
|
// Mock Obsidian module - ItemView must set this.app from the leaf
|
|
jest.mock('obsidian', () => ({
|
|
ItemView: jest.fn().mockImplementation(function (this: any, leaf: any) {
|
|
this.app = leaf.app;
|
|
}),
|
|
WorkspaceLeaf: jest.fn(),
|
|
Notice: jest.fn(),
|
|
}));
|
|
|
|
const mockSettings: PluginSettings = {
|
|
ollamaUrl: 'http://localhost:11434',
|
|
model: 'llama3',
|
|
vaultSearchLimit: 3,
|
|
maxMessageHistory: 50,
|
|
maxContextLength: 8000,
|
|
lastIndexTime: 0,
|
|
agentMode: 'ask',
|
|
cacheConfig: {
|
|
enabled: false,
|
|
similarityThreshold: 0.9,
|
|
collectionName: 'test-cache',
|
|
embeddingModel: 'nomic-embed-text',
|
|
chromaURL: 'http://localhost:8000',
|
|
},
|
|
vaultIndexConfig: {
|
|
enabled: false,
|
|
similarityThreshold: 0.75,
|
|
collectionName: 'test-vault-index',
|
|
embeddingModel: 'nomic-embed-text',
|
|
chromaURL: 'http://localhost:8000',
|
|
},
|
|
autoTagConfig: {
|
|
enabled: false,
|
|
maxTagsPerNote: 5,
|
|
minNoteLength: 50,
|
|
maxNoteLength: 8000,
|
|
tagPromptTemplate: 'Tags: {{content}}',
|
|
dryRun: false,
|
|
targetFolder: '',
|
|
normalizeTags: true,
|
|
},
|
|
autoLinkConfig: {
|
|
enabled: false,
|
|
maxLinksPerNote: 3,
|
|
similarityThreshold: 0.6,
|
|
targetFolder: '',
|
|
dryRun: false,
|
|
},
|
|
structuredMemoryConfig: {
|
|
enabled: true,
|
|
maxSummaries: 10,
|
|
maxPreferences: 20,
|
|
maxFacts: 50,
|
|
},
|
|
toolTelemetryConfig: {
|
|
enabled: true,
|
|
maxEntries: 100,
|
|
},
|
|
};
|
|
|
|
describe('ChatView', () => {
|
|
let view: ChatView;
|
|
let mockLeaf: any;
|
|
let mockApp: MockApp;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockApp = {
|
|
vault: {
|
|
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
|
read: jest.fn().mockResolvedValue(''),
|
|
cachedRead: jest.fn().mockResolvedValue(''),
|
|
getAbstractFileByPath: jest.fn().mockReturnValue(null),
|
|
create: jest.fn().mockResolvedValue(null),
|
|
modify: jest.fn().mockResolvedValue(undefined),
|
|
rename: jest.fn().mockResolvedValue(undefined),
|
|
delete: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
workspace: {
|
|
getLeaf: jest.fn(),
|
|
revealLeaf: jest.fn(),
|
|
},
|
|
};
|
|
|
|
mockLeaf = {
|
|
view: null,
|
|
setViewState: jest.fn(),
|
|
app: mockApp,
|
|
};
|
|
|
|
view = new ChatView(mockLeaf as unknown as any, mockSettings);
|
|
// Obsidian's contentEl has a createEl helper that standard DOM lacks
|
|
// Unlike standard DOM, Obsidian elements can create nested elements with createEl
|
|
const contentDiv = document.createElement('div') as any;
|
|
|
|
// Create a factory function that captures the parent element
|
|
const createElementWithCreateEl = function (parent: any) {
|
|
return function (tag: string, options?: { cls?: string }) {
|
|
const el = document.createElement(tag);
|
|
if (options?.cls) {
|
|
el.classList.add(...options.cls.split(' '));
|
|
}
|
|
parent.appendChild(el);
|
|
// Add createEl to the new element so it can create nested elements
|
|
(el as any).createEl = createElementWithCreateEl(el);
|
|
return el;
|
|
};
|
|
};
|
|
|
|
contentDiv.createEl = createElementWithCreateEl(contentDiv);
|
|
view.contentEl = contentDiv;
|
|
|
|
// Mock NoteContextBuilder to avoid needing full Obsidian API mocks
|
|
jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
|
|
explicitMentions: [],
|
|
openNote: undefined,
|
|
selectedText: undefined,
|
|
backlinks: [],
|
|
outlinks: [],
|
|
relatedNotes: [],
|
|
searchResults: [],
|
|
});
|
|
jest.spyOn(view['noteContextBuilder'], 'formatContext').mockReturnValue('');
|
|
});
|
|
|
|
describe('getViewType', () => {
|
|
it('should return the correct view type', () => {
|
|
expect(view.getViewType()).toBe('ollama-chat-view');
|
|
});
|
|
});
|
|
|
|
describe('getDisplayText', () => {
|
|
it('should return the correct display text', () => {
|
|
expect(view.getDisplayText()).toBe('Ollama Chat');
|
|
});
|
|
});
|
|
|
|
describe('getIcon', () => {
|
|
it('should return the bot icon', () => {
|
|
expect(view.getIcon()).toBe('bot');
|
|
});
|
|
});
|
|
|
|
describe('onOpen', () => {
|
|
it('should call render and setup event listeners', async () => {
|
|
const renderSpy = jest.spyOn(view, 'render');
|
|
const setupSpy = jest.spyOn(view, 'setupEventListeners' as any);
|
|
await view.onOpen();
|
|
expect(renderSpy).toHaveBeenCalled();
|
|
expect(setupSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('onClose', () => {
|
|
it('should clean up resources and remove event listeners', async () => {
|
|
view['lastMessageEl'] = document.createElement('div');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
|
await view.onClose();
|
|
expect(view['lastMessageEl']).toBeNull();
|
|
expect(view['sendButton']).toBeNull();
|
|
expect(view['inputEl']).toBeNull();
|
|
expect(removeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('render', () => {
|
|
it('should render the chat interface', async () => {
|
|
await view.render();
|
|
expect(view.contentEl.querySelector('.ollama-chat-container')).not.toBeNull();
|
|
expect(view.contentEl.querySelector('.ollama-input-container')).not.toBeNull();
|
|
});
|
|
|
|
it('should not duplicate elements on re-render', async () => {
|
|
// First render
|
|
await view.render();
|
|
const firstRenderCount = view.contentEl.querySelectorAll('.ollama-message').length;
|
|
|
|
// Second render with no changes
|
|
await view.render();
|
|
const secondRenderCount = view.contentEl.querySelectorAll('.ollama-message').length;
|
|
|
|
expect(secondRenderCount).toBe(firstRenderCount);
|
|
});
|
|
|
|
it('should only render non-streaming messages', async () => {
|
|
view['messages'] = [
|
|
{ id: '1', role: 'user', content: 'test', timestamp: Date.now() },
|
|
{
|
|
id: '2',
|
|
role: 'assistant',
|
|
content: 'response',
|
|
timestamp: Date.now(),
|
|
isStreaming: true,
|
|
},
|
|
];
|
|
await view.render();
|
|
const messages = view.contentEl.querySelectorAll('.ollama-message');
|
|
expect(messages.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('handleUserInput', () => {
|
|
it('should handle user input and call ollamaClient', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'test' };
|
|
})()
|
|
);
|
|
await (view as any).handleUserInput('test');
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// Verify that messages were added to conversation history
|
|
expect((view as any).messages.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle empty user input gracefully', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = ' ';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat');
|
|
await (view as any).handleUserInput(' ');
|
|
expect(chatSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle streaming responses and update UI', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'First ' };
|
|
yield { role: 'assistant', content: 'chunk ' };
|
|
yield { role: 'assistant', content: 'of response' };
|
|
})()
|
|
);
|
|
await (view as any).handleUserInput('test');
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// Should have processed all chunks
|
|
const messages = (view as any).messages;
|
|
const lastMessage = messages[messages.length - 1];
|
|
expect(lastMessage.isStreaming).toBe(false);
|
|
});
|
|
|
|
it('should handle streaming re-attach when existing streaming element is found', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).lastMessageEl = document.createElement('div');
|
|
(view as any).lastMessageEl.setAttribute('data-msg-id', 'streaming-123');
|
|
|
|
// Mock querySelector to return existing element
|
|
const container = document.createElement('div');
|
|
const existingEl = document.createElement('div');
|
|
existingEl.setAttribute('data-msg-id', 'streaming-123');
|
|
container.appendChild(existingEl);
|
|
|
|
// Mock the contentEl to return our container
|
|
const originalContentEl = (view as any).contentEl;
|
|
(view as any).contentEl = container;
|
|
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'response' };
|
|
})()
|
|
);
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
expect(container.contains((view as any).lastMessageEl)).toBe(true);
|
|
|
|
// Restore original contentEl
|
|
(view as any).contentEl = originalContentEl;
|
|
});
|
|
|
|
it('should append streaming element to container when no existing element found', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
|
|
// Mock querySelector to return null (no existing element)
|
|
const container = document.createElement('div');
|
|
const originalQuerySelector = container.querySelector;
|
|
container.querySelector = jest.fn().mockReturnValue(null);
|
|
|
|
// Mock the contentEl to return our container
|
|
const originalContentEl = (view as any).contentEl;
|
|
(view as any).contentEl = container;
|
|
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'response' };
|
|
})()
|
|
);
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
expect(container.childElementCount).toBeGreaterThan(0);
|
|
|
|
// Restore original contentEl and querySelector
|
|
(view as any).contentEl = originalContentEl;
|
|
container.querySelector = originalQuerySelector;
|
|
});
|
|
|
|
it('should limit conversation history to maxMessageHistory', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
|
|
// Mock a stream that returns quickly
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'response' };
|
|
})()
|
|
);
|
|
|
|
// Add enough messages to exceed maxMessageHistory
|
|
for (let i = 0; i < 60; i++) {
|
|
(view as any).messages.push({
|
|
id: `msg-${i}`,
|
|
role: 'user',
|
|
content: `message ${i}`,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
// Should be limited to maxMessageHistory
|
|
expect((view as any).messages.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it('should call noteContextBuilder with user input', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
|
|
|
|
const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
|
|
explicitMentions: [],
|
|
openNote: undefined,
|
|
selectedText: undefined,
|
|
backlinks: [],
|
|
outlinks: [],
|
|
relatedNotes: [],
|
|
searchResults: [],
|
|
});
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'response' };
|
|
})()
|
|
);
|
|
|
|
await (view as any).handleUserInput('search query');
|
|
|
|
expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle errors during user input gracefully', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
|
|
// Mock a stream that throws an error
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
throw new Error('Network error');
|
|
})()
|
|
);
|
|
|
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
'Ollama Plugin Error [ChatView.handleUserInput]: Network error'
|
|
);
|
|
});
|
|
|
|
it('should handle empty user input gracefully', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = ' ';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat');
|
|
await (view as any).handleUserInput(' ');
|
|
expect(chatSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle streaming responses and update UI', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'First ' };
|
|
yield { role: 'assistant', content: 'chunk ' };
|
|
yield { role: 'assistant', content: 'of response' };
|
|
})()
|
|
);
|
|
await (view as any).handleUserInput('test');
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// Should have processed all chunks
|
|
const messages = (view as any).messages;
|
|
const lastMessage = messages[messages.length - 1];
|
|
expect(lastMessage.isStreaming).toBe(false);
|
|
});
|
|
|
|
it('should show preview for write tool calls and defer follow-up', async () => {
|
|
view.setAgentMode('edit');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content: 'test',
|
|
tool_calls: [
|
|
{
|
|
id: 'tool_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: '{"path":"test/file.md","content":"Test content"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
})()
|
|
);
|
|
const followUpSpy = jest
|
|
.spyOn(view['ollamaClient'], 'chat')
|
|
.mockResolvedValue({ role: 'assistant', content: ' follow-up' });
|
|
|
|
// Mock preview builder
|
|
jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({
|
|
id: 'tool_1',
|
|
toolCall: {
|
|
id: 'tool_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: '{"path":"test/file.md","content":"Test content"}',
|
|
},
|
|
} as unknown as any,
|
|
operation: 'create',
|
|
path: 'test/file.md',
|
|
description: 'Create note: test/file.md',
|
|
preview: { after: 'Test content' },
|
|
status: 'pending',
|
|
});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// With write tools, follow-up should be deferred until apply
|
|
expect(followUpSpy).not.toHaveBeenCalled();
|
|
expect((view as any).pendingActions.length).toBe(1);
|
|
expect((view as any).messages.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('should apply pending actions and trigger follow-up', async () => {
|
|
view.setAgentMode('edit');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const assistantMessageId = 'test-assistant-id';
|
|
view['messages'] = [
|
|
{
|
|
id: 'user-id',
|
|
role: 'user',
|
|
content: 'test',
|
|
timestamp: Date.now(),
|
|
},
|
|
{
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: 'test',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
},
|
|
];
|
|
|
|
view['pendingActions'] = [
|
|
{
|
|
id: 'tool_1',
|
|
toolCall: {
|
|
id: 'tool_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: '{"path":"test/file.md","content":"Test content"}',
|
|
},
|
|
} as unknown as any,
|
|
operation: 'create',
|
|
path: 'test/file.md',
|
|
description: 'Create note: test/file.md',
|
|
status: 'pending',
|
|
},
|
|
];
|
|
view['pendingReadResults'] = [];
|
|
view['pendingFollowUpContext'] = {
|
|
messages: [],
|
|
tools: [],
|
|
assistantMessageId,
|
|
};
|
|
|
|
const followUpSpy = jest
|
|
.spyOn(view['ollamaClient'], 'chat')
|
|
.mockResolvedValue({ role: 'assistant', content: ' follow-up' });
|
|
|
|
const toolExecutor = view['toolExecutor'];
|
|
jest.spyOn(toolExecutor, 'handleToolCall').mockResolvedValue({
|
|
success: true,
|
|
message: 'Note created successfully',
|
|
});
|
|
|
|
await view.applyPendingActions();
|
|
expect(followUpSpy).toHaveBeenCalled();
|
|
expect((view as any).pendingActions.length).toBe(0);
|
|
});
|
|
|
|
it('should cancel pending actions', async () => {
|
|
view.setAgentMode('edit');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const assistantMessageId = 'test-assistant-id';
|
|
view['messages'] = [
|
|
{
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: 'Proposed actions...',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
},
|
|
];
|
|
view['pendingActions'] = [
|
|
{
|
|
id: 'tool_1',
|
|
toolCall: {
|
|
id: 'tool_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: '{"path":"test.md","content":"test"}',
|
|
},
|
|
} as unknown as any,
|
|
operation: 'create',
|
|
path: 'test.md',
|
|
description: 'Create note: test.md',
|
|
status: 'pending',
|
|
},
|
|
];
|
|
view['pendingFollowUpContext'] = {
|
|
messages: [],
|
|
tools: [],
|
|
assistantMessageId,
|
|
};
|
|
|
|
view.cancelPendingActions();
|
|
const msg = (view as any).messages.find((m: any) => m.id === assistantMessageId);
|
|
expect(msg.content).toContain('cancelled');
|
|
expect((view as any).pendingActions.length).toBe(0);
|
|
});
|
|
|
|
it('should handle tool call errors gracefully and continue with partial results', async () => {
|
|
view.setAgentMode('edit');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content: 'Response with tools',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'create_file', arguments: '{"path":"test.md","content":"test"}' },
|
|
},
|
|
{
|
|
id: 'call_2',
|
|
type: 'function',
|
|
function: { name: 'nonexistent_tool', arguments: '{}' },
|
|
},
|
|
],
|
|
};
|
|
})()
|
|
);
|
|
const followUpSpy = jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({
|
|
role: 'assistant',
|
|
content: 'Follow-up response',
|
|
tool_calls: [],
|
|
});
|
|
|
|
// Mock preview builder for write tool
|
|
jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({
|
|
id: 'call_1',
|
|
toolCall: {
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_file',
|
|
arguments: '{"path":"test.md","content":"test"}',
|
|
},
|
|
} as unknown as any,
|
|
operation: 'create',
|
|
path: 'test.md',
|
|
description: 'Create note: test.md',
|
|
preview: { after: 'test' },
|
|
status: 'pending',
|
|
});
|
|
|
|
// Mock tool executor — read tool fails
|
|
const toolExecutor = view['toolExecutor'];
|
|
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => {
|
|
if (call.function.name === 'nonexistent_tool') {
|
|
throw new Error('Tool not found');
|
|
}
|
|
return { success: true, message: 'Done' };
|
|
});
|
|
|
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// Write tools trigger preview, not immediate follow-up
|
|
expect(followUpSpy).not.toHaveBeenCalled();
|
|
expect(errorHandlerSpy).toHaveBeenCalledWith(expect.any(Error), 'ChatView.handleUserInput');
|
|
expect((view as any).messages.length).toBeGreaterThan(1);
|
|
consoleSpy.mockRestore();
|
|
errorHandlerSpy.mockRestore();
|
|
});
|
|
|
|
it('should continue processing even when all tool calls fail', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content: 'Response with tools',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'nonexistent_tool_1', arguments: '{}' },
|
|
},
|
|
{
|
|
id: 'call_2',
|
|
type: 'function',
|
|
function: { name: 'nonexistent_tool_2', arguments: '{}' },
|
|
},
|
|
],
|
|
};
|
|
})()
|
|
);
|
|
const followUpSpy = jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({
|
|
role: 'assistant',
|
|
content: 'Final response despite tool failures',
|
|
tool_calls: [],
|
|
});
|
|
|
|
// Mock tool executor to fail for all calls
|
|
const toolExecutor = view['toolExecutor'];
|
|
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async () => {
|
|
throw new Error('Tool not found');
|
|
});
|
|
|
|
const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
expect(followUpSpy).not.toHaveBeenCalled(); // Should skip follow-up when no tool results
|
|
expect(errorHandlerSpy).toHaveBeenCalledTimes(2); // Should be called for each failed tool call
|
|
expect((view as any).messages.length).toBeGreaterThan(1);
|
|
errorHandlerSpy.mockRestore();
|
|
});
|
|
|
|
it('should skip follow-up when tool calls result in no successful results', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content: 'Response with tools',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'nonexistent_tool', arguments: '{}' },
|
|
},
|
|
],
|
|
};
|
|
})()
|
|
);
|
|
const followUpSpy = jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({
|
|
role: 'assistant',
|
|
content: 'Final response',
|
|
tool_calls: [],
|
|
});
|
|
|
|
// Mock tool executor to fail for the call
|
|
const toolExecutor = view['toolExecutor'];
|
|
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async () => {
|
|
throw new Error('Tool not found');
|
|
});
|
|
|
|
const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
expect(followUpSpy).not.toHaveBeenCalled(); // Should skip follow-up when no tool results
|
|
expect(errorHandlerSpy).toHaveBeenCalledTimes(1); // Should be called for each failed tool call
|
|
errorHandlerSpy.mockRestore();
|
|
});
|
|
|
|
it('should handle /workflow command', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = '/workflow find notes and summarize';
|
|
|
|
const workflowSpy = jest
|
|
.spyOn(view['workflowEngine'], 'executeWorkflowFromQuery')
|
|
.mockResolvedValue({
|
|
workflowId: 'wf-1',
|
|
workflowName: 'Test Workflow',
|
|
success: true,
|
|
stepResults: [
|
|
{
|
|
stepId: 's1',
|
|
stepName: 'Search',
|
|
success: true,
|
|
data: ['note1'],
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
finalOutput: 'Summary result',
|
|
});
|
|
|
|
await (view as any).handleUserInput('/workflow find notes and summarize');
|
|
|
|
expect(workflowSpy).toHaveBeenCalledWith('find notes and summarize', expect.any(Array));
|
|
const assistantMsg = (view as any).messages.find((m: any) => m.role === 'assistant');
|
|
expect(assistantMsg.content).toContain('Test Workflow');
|
|
expect(assistantMsg.content).toContain('Summary result');
|
|
});
|
|
|
|
it('should limit conversation history to maxMessageHistory', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
|
|
// Mock a stream that returns quickly
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'response' };
|
|
})()
|
|
);
|
|
|
|
// Add enough messages to exceed maxMessageHistory
|
|
for (let i = 0; i < 60; i++) {
|
|
(view as any).messages.push({
|
|
id: `msg-${i}`,
|
|
role: 'user',
|
|
content: `message ${i}`,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
// Should be limited to maxMessageHistory
|
|
expect((view as any).messages.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it('should call noteContextBuilder with user input', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
|
|
|
|
const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
|
|
explicitMentions: [],
|
|
openNote: undefined,
|
|
selectedText: undefined,
|
|
backlinks: [],
|
|
outlinks: [],
|
|
relatedNotes: [],
|
|
searchResults: [],
|
|
});
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield { role: 'assistant', content: 'response' };
|
|
})()
|
|
);
|
|
|
|
await (view as any).handleUserInput('search query');
|
|
|
|
expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle errors during user input gracefully', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
|
|
// Mock a stream that throws an error
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
throw new Error('Network error');
|
|
})()
|
|
);
|
|
|
|
const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
expect(errorHandlerSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: 'Network error' }),
|
|
'ChatView.handleUserInput'
|
|
);
|
|
errorHandlerSpy.mockRestore();
|
|
});
|
|
|
|
describe('event handlers', () => {
|
|
it('should handle send button click', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).setupEventListeners();
|
|
|
|
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
|
|
const handler = view.getSendButtonClickHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
await handler();
|
|
|
|
expect(handleSpy).toHaveBeenCalledWith('test');
|
|
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
|
|
});
|
|
|
|
it('should handle Enter key press in input', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).setupEventListeners();
|
|
|
|
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
|
|
const event = new KeyboardEvent('keydown', { key: 'Enter' }) as any;
|
|
const handler = view.getInputKeyDownHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
await handler(event);
|
|
|
|
expect(handleSpy).toHaveBeenCalledWith('test');
|
|
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
|
|
});
|
|
|
|
it('should not handle Shift+Enter in input', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).setupEventListeners();
|
|
|
|
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
|
|
const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }) as any;
|
|
const handler = view.getInputKeyDownHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
await handler(event);
|
|
|
|
expect(handleSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle new chat button click', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
view['newChatButton'] = document.createElement('button');
|
|
(view as any).setupEventListeners();
|
|
|
|
const clearSpy = jest.spyOn(view as any, 'clearConversation');
|
|
const handler = view.getNewChatButtonClickHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
handler();
|
|
|
|
expect(clearSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('event listeners', () => {
|
|
it('should setup event listeners on open', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
await view.onOpen();
|
|
expect(view['sendButtonClickHandler']).not.toBeNull();
|
|
expect(view['inputKeyDownHandler']).not.toBeNull();
|
|
});
|
|
|
|
it('should remove event listeners on close', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
await view.onOpen();
|
|
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
|
await view.onClose();
|
|
expect(removeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('event handlers', () => {
|
|
it('should handle send button click', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).setupEventListeners();
|
|
|
|
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
|
|
const handler = view.getSendButtonClickHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
await handler();
|
|
|
|
expect(handleSpy).toHaveBeenCalledWith('test');
|
|
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
|
|
});
|
|
|
|
it('should handle Enter key press in input', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).setupEventListeners();
|
|
|
|
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
|
|
const event = new KeyboardEvent('keydown', { key: 'Enter' }) as any;
|
|
const handler = view.getInputKeyDownHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
await handler(event);
|
|
|
|
expect(handleSpy).toHaveBeenCalledWith('test');
|
|
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
|
|
});
|
|
|
|
it('should not handle Shift+Enter in input', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
(view as any).setupEventListeners();
|
|
|
|
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
|
|
const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }) as any;
|
|
const handler = view.getInputKeyDownHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
await handler(event);
|
|
|
|
expect(handleSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle new chat button click', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
view['newChatButton'] = document.createElement('button');
|
|
(view as any).setupEventListeners();
|
|
|
|
const clearSpy = jest.spyOn(view as any, 'clearConversation');
|
|
const handler = view.getNewChatButtonClickHandler();
|
|
if (!handler) throw new Error('Handler not available');
|
|
handler();
|
|
|
|
expect(clearSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('event listeners', () => {
|
|
it('should setup event listeners on open', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
await view.onOpen();
|
|
expect(view['sendButtonClickHandler']).not.toBeNull();
|
|
expect(view['inputKeyDownHandler']).not.toBeNull();
|
|
});
|
|
|
|
it('should remove event listeners on close', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
await view.onOpen();
|
|
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
|
await view.onClose();
|
|
expect(removeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|