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:
+1
-6
@@ -58,12 +58,7 @@ const EDIT_TOOLS = new Set([
|
||||
'insert_link',
|
||||
]);
|
||||
|
||||
const RESEARCH_TOOLS = new Set([
|
||||
'read_vault_file',
|
||||
'search_vault_files',
|
||||
'list_vault_tags',
|
||||
'get_vault_stats',
|
||||
]);
|
||||
const RESEARCH_TOOLS = READ_TOOLS;
|
||||
|
||||
export const AGENT_MODE_CONFIGS: Record<AgentMode, AgentModeConfig> = {
|
||||
ask: {
|
||||
|
||||
+82
-63
@@ -4,8 +4,8 @@ import {
|
||||
getAgentModeLabel,
|
||||
getSystemPromptForMode,
|
||||
filterToolsForMode,
|
||||
modeRequiresPreview,
|
||||
} from './agent-modes';
|
||||
import { UndoManager } from './undo-manager';
|
||||
import { OllamaClient } from './ollama-client';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { VaultVectorStore } from './vault-vector-store';
|
||||
@@ -33,6 +33,9 @@ import { Logger, LogEntry } from './utils';
|
||||
|
||||
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
||||
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
const MAX_TOOL_CALL_DEPTH = 5;
|
||||
|
||||
export class ChatView extends ItemView {
|
||||
// Getters for testing
|
||||
getSendButtonClickHandler() {
|
||||
@@ -71,9 +74,6 @@ export class ChatView extends ItemView {
|
||||
this.sendButtonClickHandler = null;
|
||||
this.inputKeyDownHandler = null;
|
||||
this.newChatButtonClickHandler = null;
|
||||
this.sendButtonClickWrapper = null;
|
||||
this.inputKeyDownWrapper = null;
|
||||
this.newChatButtonClickWrapper = null;
|
||||
this.listenersAttached = false;
|
||||
this.settings = settings;
|
||||
this.currentAgentMode = settings.agentMode ?? 'ask';
|
||||
@@ -88,7 +88,8 @@ export class ChatView extends ItemView {
|
||||
this.app.vault,
|
||||
this.app,
|
||||
telemetryManager,
|
||||
this.vaultIndexer
|
||||
this.vaultIndexer,
|
||||
this.undoManager
|
||||
);
|
||||
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
|
||||
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
||||
@@ -652,7 +653,7 @@ export class ChatView extends ItemView {
|
||||
this.historySelectEl.innerHTML = '';
|
||||
|
||||
// New Chat option
|
||||
const newOption = this.historySelectEl.createEl('option', {
|
||||
this.historySelectEl.createEl('option', {
|
||||
text: 'New Chat',
|
||||
attr: { value: '__new__' },
|
||||
});
|
||||
@@ -741,6 +742,24 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAssistantMessageFinalized(assistantMessageId: string): void {
|
||||
const message = this.messages.find((msg) => msg.id === assistantMessageId);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.isStreaming || message.isThinking || message.content.trim().length === 0) {
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content:
|
||||
message.content.trim().length > 0
|
||||
? message.content
|
||||
: 'No response was returned.',
|
||||
isStreaming: false,
|
||||
isThinking: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getTools(): OllamaTool[] {
|
||||
const allTools: OllamaTool[] = [
|
||||
{
|
||||
@@ -919,7 +938,7 @@ export class ChatView extends ItemView {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_note',
|
||||
description: 'Deletes a note from the vault',
|
||||
description: 'Moves a note to the system trash (recoverable)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1039,54 +1058,27 @@ export class ChatView extends ItemView {
|
||||
)
|
||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
||||
|
||||
// Build previews for write tools
|
||||
const writePreviews: ProposedAction[] = [];
|
||||
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
|
||||
try {
|
||||
const preview = await this.actionPreviewBuilder.buildPreview(toolCall);
|
||||
writePreviews.push(preview);
|
||||
} catch (error) {
|
||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
// Execute write tools sequentially with CoW snapshotting for undo
|
||||
const writeResults: (ToolResult & { id?: string })[] = [];
|
||||
if (writeToolCalls.length > 0) {
|
||||
const undoBatchId = this.undoManager.startBatch();
|
||||
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
|
||||
try {
|
||||
const toolResult = await this.toolExecutor.handleToolCall(toolCall, undoBatchId);
|
||||
writeResults.push({ ...toolResult, id: toolCall.id });
|
||||
} catch (error) {
|
||||
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
||||
writeResults.push({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
id: toolCall.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.undoManager.hasBatch(undoBatchId)) {
|
||||
const count = this.undoManager.getBatch(undoBatchId)!.operations.length;
|
||||
this.renderUndoButton(assistantMessageId, undoBatchId, count);
|
||||
}
|
||||
}
|
||||
|
||||
if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) {
|
||||
// Store pending state for apply/cancel
|
||||
this.pendingActions = writePreviews;
|
||||
this.pendingReadResults = readResults;
|
||||
this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse };
|
||||
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`,
|
||||
isStreaming: false,
|
||||
isThinking: false,
|
||||
});
|
||||
|
||||
this.render();
|
||||
this.renderActionPreviews(assistantMessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If mode does not require preview, execute write tools immediately
|
||||
let writeResults: (ToolResult & { id?: string })[] = [];
|
||||
if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) {
|
||||
writeResults = (
|
||||
await Promise.all(
|
||||
writePreviews.map(async (action) => {
|
||||
try {
|
||||
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
||||
return { ...toolResult, id: action.toolCall.id };
|
||||
} catch (error) {
|
||||
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
id: action.toolCall.id,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No write tools (or they were already executed) — proceed with follow-up
|
||||
@@ -1332,6 +1324,40 @@ export class ChatView extends ItemView {
|
||||
this.chatContainer?.querySelectorAll('.ollama-proposed-actions').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
private renderUndoButton(assistantMessageId: string, batchId: string, count: number): void {
|
||||
const messageEl = this.chatContainer?.querySelector(
|
||||
`.ollama-message[data-msg-id="${assistantMessageId}"]`
|
||||
);
|
||||
if (!messageEl) return;
|
||||
|
||||
messageEl.querySelector('.ollama-undo-container')?.remove();
|
||||
|
||||
const container = messageEl.createEl('div', { cls: 'ollama-undo-container' });
|
||||
container.createEl('span', {
|
||||
cls: 'ollama-undo-label',
|
||||
text: `${count} file operation${count !== 1 ? 's' : ''} applied.`,
|
||||
});
|
||||
|
||||
const undoBtn = container.createEl('button', {
|
||||
cls: 'ollama-undo-button',
|
||||
text: 'Undo',
|
||||
});
|
||||
|
||||
undoBtn.addEventListener('click', async () => {
|
||||
undoBtn.disabled = true;
|
||||
undoBtn.textContent = 'Undoing…';
|
||||
const result = await this.undoManager.undo(batchId, this.app.vault);
|
||||
container.remove();
|
||||
if (result.restored > 0 || result.failed > 0) {
|
||||
new Notice(
|
||||
result.failed > 0
|
||||
? `Undo: ${result.restored} restored, ${result.failed} failed`
|
||||
: `Undo: ${result.restored} operation${result.restored !== 1 ? 's' : ''} reverted`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private formatWorkflowResult(result: {
|
||||
workflowName: string;
|
||||
success: boolean;
|
||||
@@ -1562,7 +1588,6 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
|
||||
// Auto-nudge for tool-capable modes if assistant didn't emit tools but seems to intend to
|
||||
let shouldFallbackToReadTools = false;
|
||||
const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
|
||||
const isToolCapable = toolCapableModes.includes(this.currentAgentMode);
|
||||
|
||||
@@ -1572,8 +1597,6 @@ export class ChatView extends ItemView {
|
||||
|
||||
if (isToolCapable && toolCalls.length === 0) {
|
||||
if (modelMentionedActions || userWantsVaultOps) {
|
||||
shouldFallbackToReadTools = true;
|
||||
|
||||
// Suppress the model's "Let me..." text — clear it from the DOM immediately
|
||||
const priorResponse = fullResponse;
|
||||
fullResponse = '';
|
||||
@@ -1719,6 +1742,7 @@ export class ChatView extends ItemView {
|
||||
} finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.isCancelled = false;
|
||||
this.ensureAssistantMessageFinalized(assistantMessageId);
|
||||
this.hideActivityIndicator();
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
@@ -1871,9 +1895,6 @@ export class ChatView extends ItemView {
|
||||
private sendButtonClickHandler: (() => void) | null = null;
|
||||
private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonClickHandler: (() => void) | null = null;
|
||||
private sendButtonClickWrapper: (() => void) | null = null;
|
||||
private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonClickWrapper: (() => void) | null = null;
|
||||
private listenersAttached: boolean = false;
|
||||
private isCancelled: boolean = false;
|
||||
private settings: PluginSettings;
|
||||
@@ -1897,6 +1918,7 @@ export class ChatView extends ItemView {
|
||||
private historySelectEl: HTMLSelectElement | null = null;
|
||||
private historyDeleteButton: HTMLElement | null = null;
|
||||
private currentAgentMode: AgentMode;
|
||||
private undoManager: UndoManager = new UndoManager();
|
||||
private pendingActions: ProposedAction[] = [];
|
||||
private pendingReadResults: (ToolResult & { id?: string })[] = [];
|
||||
private pendingFollowUpContext: {
|
||||
@@ -2015,6 +2037,3 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
const MAX_TOOL_CALL_DEPTH = 5;
|
||||
|
||||
+13
-4
@@ -5,10 +5,9 @@ import { SemanticCacheService } from './semantic-cache';
|
||||
import { VaultVectorStore } from './vault-vector-store';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { AutoTagger, AutoLinker } from './auto-organizer';
|
||||
import { PluginSettings, StructuredMemoryData, ToolTelemetryData, ChatHistoryData } from './types';
|
||||
import { PluginSettings, StructuredMemoryData, ToolTelemetryData, ChatHistoryData, AgentMode } from './types';
|
||||
import { Logger } from './utils';
|
||||
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
||||
import { AgentMode } from './types';
|
||||
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
|
||||
import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry';
|
||||
import { ChatHistoryManager, createDefaultChatHistoryData } from './chat-history';
|
||||
@@ -196,7 +195,17 @@ export default class OllamaPlugin extends Plugin {
|
||||
const data = ((await this.loadData()) ?? {}) as Record<string, unknown>;
|
||||
// Backward compatibility: old flat format vs new nested format
|
||||
const loadedSettings = (data.settings ?? data) as Partial<PluginSettings>;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
|
||||
// Deep merge nested config objects so new default fields are preserved
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...loadedSettings,
|
||||
cacheConfig: { ...DEFAULT_SETTINGS.cacheConfig, ...(loadedSettings.cacheConfig ?? {}) },
|
||||
vaultIndexConfig: { ...DEFAULT_SETTINGS.vaultIndexConfig, ...(loadedSettings.vaultIndexConfig ?? {}) },
|
||||
autoTagConfig: { ...DEFAULT_SETTINGS.autoTagConfig, ...(loadedSettings.autoTagConfig ?? {}) },
|
||||
autoLinkConfig: { ...DEFAULT_SETTINGS.autoLinkConfig, ...(loadedSettings.autoLinkConfig ?? {}) },
|
||||
structuredMemoryConfig: { ...DEFAULT_SETTINGS.structuredMemoryConfig, ...(loadedSettings.structuredMemoryConfig ?? {}) },
|
||||
toolTelemetryConfig: { ...DEFAULT_SETTINGS.toolTelemetryConfig, ...(loadedSettings.toolTelemetryConfig ?? {}) },
|
||||
};
|
||||
const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model;
|
||||
this.settings.chatModel = loadedSettings.chatModel ?? legacyModel;
|
||||
this.settings.agentModel = loadedSettings.agentModel ?? legacyModel;
|
||||
@@ -316,7 +325,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main');
|
||||
|
||||
let indexed = 0;
|
||||
const BATCH_SIZE = 1;
|
||||
const BATCH_SIZE = 5;
|
||||
const DELAY_MS = 500;
|
||||
|
||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
||||
|
||||
@@ -244,7 +244,7 @@ export class OllamaClient {
|
||||
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount)));
|
||||
yield* this.streamChatWithRetry(messages, tools, retryCount + 1);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -302,7 +302,7 @@ export class OllamaClient {
|
||||
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount)));
|
||||
return this.chatWithRetry(messages, tools, retryCount + 1);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -393,7 +393,7 @@ export class OllamaClient {
|
||||
error.message.startsWith('Ollama error:') ||
|
||||
error.message.includes('Too many malformed chunks') ||
|
||||
error.message === 'No response body' ||
|
||||
error.message === 'Invalid response format'
|
||||
error.message.startsWith('Invalid response format')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export class SemanticCacheService {
|
||||
|
||||
try {
|
||||
await this.client.deleteCollection({ name: this.config.collectionName });
|
||||
this.collection = null;
|
||||
Logger.info('Semantic cache cleared', 'semantic-cache');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -193,7 +193,7 @@ export class StructuredMemoryManager {
|
||||
{ regex: /i(?:'d| would)?\s+prefer\s+(?:that\s+)?(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /i\s+(?:like|love|enjoy)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /i\s+(?:dislike|hate|avoid)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /please\s+(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{
|
||||
regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i,
|
||||
keyPrefix: 'favorite',
|
||||
|
||||
+96
-19
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Vault, App, TFile, TFolder } from 'obsidian';
|
||||
import type { ToolCall, ToolResult, VaultIndexEntry } from './types';
|
||||
import { safeParseJson } from './utils';
|
||||
import { safeParseJson, Logger } from './utils';
|
||||
import { TelemetryManager } from './tool-telemetry';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { UndoManager } from './undo-manager';
|
||||
|
||||
// Disallow characters that are invalid in file paths
|
||||
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
||||
@@ -16,17 +17,20 @@ export class ToolExecutor {
|
||||
private app: App;
|
||||
private telemetryManager?: TelemetryManager;
|
||||
private vaultIndexer?: VaultIndexer;
|
||||
private undoManager?: UndoManager;
|
||||
|
||||
constructor(
|
||||
vault: Vault,
|
||||
app: App,
|
||||
telemetryManager?: TelemetryManager,
|
||||
vaultIndexer?: VaultIndexer
|
||||
vaultIndexer?: VaultIndexer,
|
||||
undoManager?: UndoManager
|
||||
) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
this.telemetryManager = telemetryManager;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
this.undoManager = undoManager;
|
||||
}
|
||||
|
||||
private isSafePath(path: string): boolean {
|
||||
@@ -122,7 +126,7 @@ export class ToolExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
|
||||
async handleToolCall(toolCall: ToolCall, undoBatchId?: string): Promise<ToolResult> {
|
||||
const startTime = Date.now();
|
||||
const toolName = toolCall.function?.name ?? 'unknown';
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
@@ -149,6 +153,11 @@ export class ToolExecutor {
|
||||
throw new Error('Arguments must be an object or JSON string');
|
||||
}
|
||||
|
||||
// Snapshot state before write operations for undo
|
||||
if (undoBatchId) {
|
||||
await this.snapshotForUndo(toolName, parsedArgs, undoBatchId);
|
||||
}
|
||||
|
||||
// Process the tool call based on its type
|
||||
switch (toolName) {
|
||||
case 'create_file':
|
||||
@@ -227,14 +236,9 @@ export class ToolExecutor {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureFolderExists(this.getParentFolderPath(path));
|
||||
await this.vault.create(path, content);
|
||||
return { success: true, message: 'Note created successfully' };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
await this.ensureFolderExists(this.getParentFolderPath(path));
|
||||
await this.vault.create(path, content);
|
||||
return { success: true, message: 'Note created successfully' };
|
||||
}
|
||||
|
||||
async executeTool(name: string, args: string | Record<string, unknown>): Promise<ToolResult> {
|
||||
@@ -243,7 +247,7 @@ export class ToolExecutor {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: args as string,
|
||||
arguments: typeof args === 'string' ? args : JSON.stringify(args),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -565,8 +569,8 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
const file = this.getFile(path);
|
||||
await this.vault.delete(file);
|
||||
return { success: true, message: `Note ${path} deleted successfully` };
|
||||
await this.vault.trash(file, true);
|
||||
return { success: true, message: `Note ${path} moved to trash` };
|
||||
}
|
||||
|
||||
private async handleListVaultTags(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
@@ -622,10 +626,10 @@ export class ToolExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
private async handleGetVaultStats(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
private handleGetVaultStats(args: Record<string, unknown>): ToolResult {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const folderSet = new Set<string>();
|
||||
let totalLength = 0;
|
||||
let totalSize = 0;
|
||||
let taggedCount = 0;
|
||||
let untaggedCount = 0;
|
||||
const tagMap = new Map<string, number>();
|
||||
@@ -636,8 +640,7 @@ export class ToolExecutor {
|
||||
const folder = file.path.split('/').slice(0, -1).join('/') || '(root)';
|
||||
folderSet.add(folder);
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
totalLength += content.length;
|
||||
if (file.stat?.size) totalSize += file.stat.size;
|
||||
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const rawTags: unknown = cache?.frontmatter?.tags;
|
||||
@@ -690,12 +693,86 @@ export class ToolExecutor {
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([tag, count]) => ({ tag, count })),
|
||||
avgNoteLength: files.length > 0 ? Math.round(totalLength / files.length) : 0,
|
||||
avgNoteSize: files.length > 0 ? Math.round(totalSize / files.length) : 0,
|
||||
recentFiles: recentFiles.slice(0, 10).map((f) => f.path),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async snapshotForUndo(
|
||||
toolName: string,
|
||||
parsedArgs: Record<string, unknown>,
|
||||
batchId: string
|
||||
): Promise<void> {
|
||||
if (!this.undoManager) return;
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'create_file':
|
||||
case 'create_note':
|
||||
this.undoManager.recordOperation(batchId, {
|
||||
type: 'create',
|
||||
path: parsedArgs.path as string,
|
||||
});
|
||||
break;
|
||||
case 'append_to_note':
|
||||
case 'replace_note_section':
|
||||
case 'update_frontmatter': {
|
||||
const path = parsedArgs.path as string;
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const originalContent = await this.vault.cachedRead(file);
|
||||
this.undoManager.recordOperation(batchId, { type: 'modify', path, originalContent });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'insert_link': {
|
||||
const path = parsedArgs.sourcePath as string;
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const originalContent = await this.vault.cachedRead(file);
|
||||
this.undoManager.recordOperation(batchId, { type: 'modify', path, originalContent });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rename_note':
|
||||
this.undoManager.recordOperation(batchId, {
|
||||
type: 'rename',
|
||||
originalPath: parsedArgs.oldPath as string,
|
||||
newPath: parsedArgs.newPath as string,
|
||||
});
|
||||
break;
|
||||
case 'move_note': {
|
||||
const path = parsedArgs.path as string;
|
||||
const folder = ((parsedArgs.folder as string) ?? '').replace(/\/$/, '').trim();
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const newPath = folder ? `${folder}/${file.name}` : file.name;
|
||||
this.undoManager.recordOperation(batchId, {
|
||||
type: 'rename',
|
||||
originalPath: path,
|
||||
newPath,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete_note': {
|
||||
const path = parsedArgs.path as string;
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const originalContent = await this.vault.cachedRead(file);
|
||||
this.undoManager.recordOperation(batchId, { type: 'trash', path, originalContent });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
`Failed to snapshot for undo (${toolName}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
'tool-executor'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInsertLink(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const sourcePath = args.sourcePath;
|
||||
const targetPath = args.targetPath;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Vault, TFile } from 'obsidian';
|
||||
import { Logger } from './utils';
|
||||
|
||||
export type UndoOperation =
|
||||
| { type: 'create'; path: string }
|
||||
| { type: 'modify'; path: string; originalContent: string }
|
||||
| { type: 'rename'; originalPath: string; newPath: string }
|
||||
| { type: 'trash'; path: string; originalContent: string };
|
||||
|
||||
export interface UndoBatch {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
operations: UndoOperation[];
|
||||
}
|
||||
|
||||
export class UndoManager {
|
||||
private batches: UndoBatch[] = [];
|
||||
private readonly maxBatches = 10;
|
||||
|
||||
startBatch(): string {
|
||||
const id = crypto.randomUUID?.() ?? `undo-${Date.now()}-${Math.random()}`;
|
||||
this.batches.push({ id, timestamp: Date.now(), operations: [] });
|
||||
if (this.batches.length > this.maxBatches) {
|
||||
this.batches = this.batches.slice(-this.maxBatches);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
recordOperation(batchId: string, op: UndoOperation): void {
|
||||
const batch = this.batches.find((b) => b.id === batchId);
|
||||
batch?.operations.push(op);
|
||||
}
|
||||
|
||||
getBatch(batchId: string): UndoBatch | undefined {
|
||||
return this.batches.find((b) => b.id === batchId);
|
||||
}
|
||||
|
||||
hasBatch(batchId: string): boolean {
|
||||
const batch = this.getBatch(batchId);
|
||||
return !!(batch && batch.operations.length > 0);
|
||||
}
|
||||
|
||||
async undo(batchId: string, vault: Vault): Promise<{ restored: number; failed: number }> {
|
||||
const batch = this.getBatch(batchId);
|
||||
if (!batch) return { restored: 0, failed: 0 };
|
||||
|
||||
let restored = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const op of [...batch.operations].reverse()) {
|
||||
try {
|
||||
switch (op.type) {
|
||||
case 'create': {
|
||||
const file = vault.getAbstractFileByPath(op.path);
|
||||
if (file instanceof TFile) {
|
||||
await vault.trash(file, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'modify': {
|
||||
const file = vault.getAbstractFileByPath(op.path);
|
||||
if (file instanceof TFile) {
|
||||
await vault.modify(file, op.originalContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
const file = vault.getAbstractFileByPath(op.newPath);
|
||||
if (file instanceof TFile) {
|
||||
await vault.rename(file, op.originalPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'trash': {
|
||||
const existing = vault.getAbstractFileByPath(op.path);
|
||||
if (existing instanceof TFile) {
|
||||
await vault.modify(existing, op.originalContent);
|
||||
} else {
|
||||
await vault.create(op.path, op.originalContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
restored++;
|
||||
} catch (error) {
|
||||
const path = 'path' in op ? op.path : 'originalPath' in op ? op.originalPath : '?';
|
||||
Logger.warn(
|
||||
`Undo failed for ${op.type} on ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'undo-manager'
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.batches = this.batches.filter((b) => b.id !== batchId);
|
||||
return { restored, failed };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.batches = [];
|
||||
}
|
||||
}
|
||||
+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