Add undo manager and CoW semantics for tool execution
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
This commit is contained in:
+48
-42
@@ -292,6 +292,41 @@ describe('ChatView', () => {
|
||||
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');
|
||||
@@ -515,7 +550,7 @@ describe('ChatView', () => {
|
||||
expect(lastMessage.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should show preview for write tool calls and defer follow-up', async () => {
|
||||
it('should execute write tools immediately with CoW undo', async () => {
|
||||
view.setAgentMode('edit');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
@@ -540,31 +575,20 @@ describe('ChatView', () => {
|
||||
);
|
||||
const followUpSpy = jest
|
||||
.spyOn(view['ollamaClient'], 'chat')
|
||||
.mockResolvedValue({ role: 'assistant', content: ' follow-up' });
|
||||
.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',
|
||||
// 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();
|
||||
// With write tools, follow-up should be deferred until apply
|
||||
expect(followUpSpy).not.toHaveBeenCalled();
|
||||
expect((view as any).pendingActions.length).toBe(1);
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -708,25 +732,7 @@ describe('ChatView', () => {
|
||||
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
|
||||
// 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') {
|
||||
@@ -741,8 +747,8 @@ describe('ChatView', () => {
|
||||
await (view as any).handleUserInput('test');
|
||||
|
||||
expect(chatSpy).toHaveBeenCalled();
|
||||
// Write tools trigger preview, not immediate follow-up
|
||||
expect(followUpSpy).not.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();
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('OllamaClient', () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
await expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500');
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should handle missing message content gracefully', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
@@ -391,7 +391,7 @@ describe('OllamaClient', () => {
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
expect(chunks.length).toBe(0);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should give up after maxRetries attempts', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
@@ -405,7 +405,7 @@ describe('OllamaClient', () => {
|
||||
}
|
||||
})()
|
||||
).rejects.toThrow('Ollama API error: 500');
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should not retry on 4xx errors', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
@@ -13,7 +13,7 @@ interface MockVault {
|
||||
getMarkdownFiles: () => any[];
|
||||
modify: (file: any, content: string) => Promise<void>;
|
||||
rename: (file: any, newPath: string) => Promise<void>;
|
||||
delete: (file: any) => Promise<void>;
|
||||
trash: (file: any, system: boolean) => Promise<void>;
|
||||
}
|
||||
interface MockApp {
|
||||
metadataCache: {
|
||||
@@ -58,7 +58,7 @@ describe('ToolExecutor', () => {
|
||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||
modify: jest.fn().mockResolvedValue(undefined),
|
||||
rename: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
trash: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockApp = {
|
||||
metadataCache: {
|
||||
@@ -1294,7 +1294,7 @@ describe('ToolExecutor', () => {
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockVault.delete).toHaveBeenCalledWith(file);
|
||||
expect(mockVault.trash).toHaveBeenCalledWith(file, true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user