Add action preview builder for write tool confirmation

Introduces `ActionPreviewBuilder` to generate before/after previews for
destructive operations. Write tools are now deferred with apply/cancel
UI instead of executing immediately. Includes `ProposedAction` type,
CSS for diff views, and state management in `ChatView`.
This commit is contained in:
2026-05-20 18:36:00 +02:00
parent 95a6954b50
commit 158d5f68e6
6 changed files with 1078 additions and 18 deletions
+161 -8
View File
@@ -6,7 +6,12 @@ import { ErrorHandler } from '../src/error-handler';
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;
@@ -67,11 +72,17 @@ describe('ChatView', () => {
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(),
@@ -388,7 +399,7 @@ describe('ChatView', () => {
expect(lastMessage.isStreaming).toBe(false);
});
it('should process tool calls with follow-up context', async () => {
it('should show preview for write tool calls and defer follow-up', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
@@ -413,12 +424,136 @@ describe('ChatView', () => {
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(followUpSpy).toHaveBeenCalled();
// Verify that tool calls resulted in follow-up messages
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['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['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['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
@@ -449,14 +584,31 @@ describe('ChatView', () => {
tool_calls: [],
});
// Mock tool executor to return mixed results
// 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 === 'create_file') {
return { success: true, message: 'Note created successfully' };
} else {
if (call.function.name === 'nonexistent_tool') {
throw new Error('Tool not found');
}
return { success: true, message: 'Done' };
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@@ -465,7 +617,8 @@ describe('ChatView', () => {
await (view as any).handleUserInput('test');
expect(chatSpy).toHaveBeenCalled();
expect(followUpSpy).toHaveBeenCalled(); // Should still call follow-up with partial results
// 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();