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:
2026-05-26 09:50:23 +02:00
parent d92a46ed8b
commit a573d33d0a
12 changed files with 720 additions and 344 deletions
+48 -42
View File
@@ -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();
+3 -3
View File
@@ -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 });
+3 -3
View File
@@ -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);
});
});