a573d33d0a
Replace the pending-actions preview flow with immediate execution and undo support. ToolExecutor now accepts an UndoManager and records create, modify, rename, and trash operations so users can roll back batches. Other fixes included: - Deep-merge nested config objects on settings load to preserve new default fields - Increase retry backoff from 10ms to 1000ms and widen the "invalid response format" check to handle prefixed messages - Fix semantic cache clear to null out the collection reference - Tighten memory regex to require "please always/never" - Increase vault indexing batch size from 1 to 5 - Remove unused modeRequiresPreview helper
1118 lines
40 KiB
TypeScript
Executable File
1118 lines
40 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(),
|
|
undefined
|
|
);
|
|
// 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);
|
|
});
|
|
|
|
it('should update the system prompt when mode selector changes', async () => {
|
|
await view.render();
|
|
const selector = view.contentEl.querySelector('.ollama-mode-selector') as HTMLSelectElement;
|
|
selector.appendChild(new Option('Edit', 'edit'));
|
|
selector.value = 'edit';
|
|
selector.dispatchEvent(new Event('change'));
|
|
|
|
const systemPrompt = view['conversationStateManager'].getLongTermContext()[0].content;
|
|
expect(view.getAgentMode()).toBe('edit');
|
|
expect(systemPrompt).toContain('helps edit and manage notes');
|
|
});
|
|
});
|
|
|
|
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 finalize with a visible fallback if tool processing returns no output', async () => {
|
|
view.setAgentMode('research');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'find project notes';
|
|
|
|
jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
|
(async function* () {
|
|
yield {
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'search_vault_files',
|
|
arguments: '{"query":"project notes"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
})()
|
|
);
|
|
jest.spyOn(view as any, 'processToolCalls').mockResolvedValue(undefined);
|
|
|
|
await (view as any).handleUserInput('find project notes');
|
|
|
|
const messages = (view as any).messages;
|
|
const lastMessage = messages[messages.length - 1];
|
|
expect(lastMessage.isStreaming).toBe(false);
|
|
expect(lastMessage.isThinking).toBe(false);
|
|
expect(lastMessage.content).toBe('No response was returned.');
|
|
});
|
|
|
|
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 execute write tools immediately with CoW undo', 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 handleToolCall so the write executes without needing real vault
|
|
jest.spyOn(view['toolExecutor'], 'handleToolCall').mockResolvedValue({
|
|
success: true,
|
|
message: 'Note created successfully',
|
|
});
|
|
|
|
await (view as any).handleUserInput('test');
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// Write tools execute immediately — follow-up is called right away
|
|
expect(followUpSpy).toHaveBeenCalled();
|
|
// No pending actions queue in CoW mode
|
|
expect((view as any).pendingActions.length).toBe(0);
|
|
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,
|
|
allToolCalls: [],
|
|
assistantText: 'test',
|
|
};
|
|
|
|
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,
|
|
allToolCalls: [],
|
|
assistantText: 'Proposed actions...',
|
|
};
|
|
|
|
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 tool executor: read tool fails, write tool succeeds
|
|
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 execute immediately with CoW; follow-up is called once results are ready
|
|
expect(followUpSpy).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();
|
|
});
|
|
});
|
|
});
|