db96858222
Replace manual intent phrase matching with dedicated helpers that detect when the assistant wants to use tools but didn't emit tool calls. When detected, automatically build and execute read-only tool calls based on the user's original message instead of nudging the model again. This reduces back-and-forth latency for organize and research modes by directly querying vault stats, tags, and files when the assistant expresses intent like "let me read" or "I will check" but fails to actually call tools. Includes tests for the new auto-run behavior in organize mode.
1101 lines
39 KiB
TypeScript
Executable File
1101 lines
39 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';
|
|
import { ChatHistoryManager } from '../src/chat-history';
|
|
|
|
// 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',
|
|
chatModel: 'llama3',
|
|
agentModel: 'llama3',
|
|
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,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
new ChatHistoryManager()
|
|
);
|
|
// 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 auto-run read tools when organize mode returns a non-action response', async () => {
|
|
view.setAgentMode('organize');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value =
|
|
'please implement the suggested structure by creating folders and moving notes';
|
|
|
|
const streamSpy = jest.spyOn(view['ollamaClient'], 'streamChat');
|
|
streamSpy
|
|
.mockReturnValueOnce(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content:
|
|
'Let me look at the current note and previous conversation to understand what structure was suggested.',
|
|
};
|
|
})()
|
|
)
|
|
.mockReturnValueOnce(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content:
|
|
'Let me read the current Prompts note and look at the Vault Index for more context.',
|
|
};
|
|
})()
|
|
);
|
|
|
|
const handleToolSpy = jest
|
|
.spyOn(view['toolExecutor'], 'handleToolCall')
|
|
.mockResolvedValue({
|
|
success: true,
|
|
message: 'Found vault context',
|
|
data: [{ path: 'Prompts.md', title: 'Prompts' }],
|
|
});
|
|
jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({
|
|
role: 'assistant',
|
|
content: 'I found vault context and can now suggest the next organization step.',
|
|
});
|
|
|
|
await (view as any).handleUserInput(
|
|
'please implement the suggested structure by creating folders and moving notes'
|
|
);
|
|
|
|
expect(handleToolSpy).toHaveBeenCalled();
|
|
expect(
|
|
handleToolSpy.mock.calls.some(
|
|
([toolCall]) => toolCall.function.name === 'get_vault_stats'
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
handleToolSpy.mock.calls.some(
|
|
([toolCall]) => toolCall.function.name === 'list_vault_tags'
|
|
)
|
|
).toBe(true);
|
|
|
|
const messages = (view as any).messages;
|
|
const lastMessage = messages[messages.length - 1];
|
|
expect(lastMessage.content).toBe(
|
|
'I found vault context and can now suggest the next organization step.'
|
|
);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|