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',
|
'insert_link',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const RESEARCH_TOOLS = new Set([
|
const RESEARCH_TOOLS = READ_TOOLS;
|
||||||
'read_vault_file',
|
|
||||||
'search_vault_files',
|
|
||||||
'list_vault_tags',
|
|
||||||
'get_vault_stats',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const AGENT_MODE_CONFIGS: Record<AgentMode, AgentModeConfig> = {
|
export const AGENT_MODE_CONFIGS: Record<AgentMode, AgentModeConfig> = {
|
||||||
ask: {
|
ask: {
|
||||||
|
|||||||
+76
-57
@@ -4,8 +4,8 @@ import {
|
|||||||
getAgentModeLabel,
|
getAgentModeLabel,
|
||||||
getSystemPromptForMode,
|
getSystemPromptForMode,
|
||||||
filterToolsForMode,
|
filterToolsForMode,
|
||||||
modeRequiresPreview,
|
|
||||||
} from './agent-modes';
|
} from './agent-modes';
|
||||||
|
import { UndoManager } from './undo-manager';
|
||||||
import { OllamaClient } from './ollama-client';
|
import { OllamaClient } from './ollama-client';
|
||||||
import { VaultIndexer } from './vault-indexer';
|
import { VaultIndexer } from './vault-indexer';
|
||||||
import { VaultVectorStore } from './vault-vector-store';
|
import { VaultVectorStore } from './vault-vector-store';
|
||||||
@@ -33,6 +33,9 @@ import { Logger, LogEntry } from './utils';
|
|||||||
|
|
||||||
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
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 {
|
export class ChatView extends ItemView {
|
||||||
// Getters for testing
|
// Getters for testing
|
||||||
getSendButtonClickHandler() {
|
getSendButtonClickHandler() {
|
||||||
@@ -71,9 +74,6 @@ export class ChatView extends ItemView {
|
|||||||
this.sendButtonClickHandler = null;
|
this.sendButtonClickHandler = null;
|
||||||
this.inputKeyDownHandler = null;
|
this.inputKeyDownHandler = null;
|
||||||
this.newChatButtonClickHandler = null;
|
this.newChatButtonClickHandler = null;
|
||||||
this.sendButtonClickWrapper = null;
|
|
||||||
this.inputKeyDownWrapper = null;
|
|
||||||
this.newChatButtonClickWrapper = null;
|
|
||||||
this.listenersAttached = false;
|
this.listenersAttached = false;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.currentAgentMode = settings.agentMode ?? 'ask';
|
this.currentAgentMode = settings.agentMode ?? 'ask';
|
||||||
@@ -88,7 +88,8 @@ export class ChatView extends ItemView {
|
|||||||
this.app.vault,
|
this.app.vault,
|
||||||
this.app,
|
this.app,
|
||||||
telemetryManager,
|
telemetryManager,
|
||||||
this.vaultIndexer
|
this.vaultIndexer,
|
||||||
|
this.undoManager
|
||||||
);
|
);
|
||||||
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
|
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
|
||||||
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
||||||
@@ -652,7 +653,7 @@ export class ChatView extends ItemView {
|
|||||||
this.historySelectEl.innerHTML = '';
|
this.historySelectEl.innerHTML = '';
|
||||||
|
|
||||||
// New Chat option
|
// New Chat option
|
||||||
const newOption = this.historySelectEl.createEl('option', {
|
this.historySelectEl.createEl('option', {
|
||||||
text: 'New Chat',
|
text: 'New Chat',
|
||||||
attr: { value: '__new__' },
|
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[] {
|
getTools(): OllamaTool[] {
|
||||||
const allTools: OllamaTool[] = [
|
const allTools: OllamaTool[] = [
|
||||||
{
|
{
|
||||||
@@ -919,7 +938,7 @@ export class ChatView extends ItemView {
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'delete_note',
|
name: 'delete_note',
|
||||||
description: 'Deletes a note from the vault',
|
description: 'Moves a note to the system trash (recoverable)',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -1039,54 +1058,27 @@ export class ChatView extends ItemView {
|
|||||||
)
|
)
|
||||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
).filter((result): result is NonNullable<typeof result> => result !== null);
|
||||||
|
|
||||||
// Build previews for write tools
|
// Execute write tools sequentially with CoW snapshotting for undo
|
||||||
const writePreviews: ProposedAction[] = [];
|
const writeResults: (ToolResult & { id?: string })[] = [];
|
||||||
|
if (writeToolCalls.length > 0) {
|
||||||
|
const undoBatchId = this.undoManager.startBatch();
|
||||||
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
|
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
|
||||||
try {
|
try {
|
||||||
const preview = await this.actionPreviewBuilder.buildPreview(toolCall);
|
const toolResult = await this.toolExecutor.handleToolCall(toolCall, undoBatchId);
|
||||||
writePreviews.push(preview);
|
writeResults.push({ ...toolResult, id: toolCall.id });
|
||||||
} catch (error) {
|
|
||||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
||||||
return {
|
writeResults.push({
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
id: action.toolCall.id,
|
id: toolCall.id,
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.undoManager.hasBatch(undoBatchId)) {
|
||||||
|
const count = this.undoManager.getBatch(undoBatchId)!.operations.length;
|
||||||
|
this.renderUndoButton(assistantMessageId, undoBatchId, count);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No write tools (or they were already executed) — proceed with follow-up
|
// 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());
|
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: {
|
private formatWorkflowResult(result: {
|
||||||
workflowName: string;
|
workflowName: string;
|
||||||
success: boolean;
|
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
|
// 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 toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
|
||||||
const isToolCapable = toolCapableModes.includes(this.currentAgentMode);
|
const isToolCapable = toolCapableModes.includes(this.currentAgentMode);
|
||||||
|
|
||||||
@@ -1572,8 +1597,6 @@ export class ChatView extends ItemView {
|
|||||||
|
|
||||||
if (isToolCapable && toolCalls.length === 0) {
|
if (isToolCapable && toolCalls.length === 0) {
|
||||||
if (modelMentionedActions || userWantsVaultOps) {
|
if (modelMentionedActions || userWantsVaultOps) {
|
||||||
shouldFallbackToReadTools = true;
|
|
||||||
|
|
||||||
// Suppress the model's "Let me..." text — clear it from the DOM immediately
|
// Suppress the model's "Let me..." text — clear it from the DOM immediately
|
||||||
const priorResponse = fullResponse;
|
const priorResponse = fullResponse;
|
||||||
fullResponse = '';
|
fullResponse = '';
|
||||||
@@ -1719,6 +1742,7 @@ export class ChatView extends ItemView {
|
|||||||
} finally {
|
} finally {
|
||||||
// Clean up streaming resources regardless of outcome
|
// Clean up streaming resources regardless of outcome
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
|
this.ensureAssistantMessageFinalized(assistantMessageId);
|
||||||
this.hideActivityIndicator();
|
this.hideActivityIndicator();
|
||||||
this.cleanupStreamingResources();
|
this.cleanupStreamingResources();
|
||||||
}
|
}
|
||||||
@@ -1871,9 +1895,6 @@ export class ChatView extends ItemView {
|
|||||||
private sendButtonClickHandler: (() => void) | null = null;
|
private sendButtonClickHandler: (() => void) | null = null;
|
||||||
private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null;
|
private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||||
private newChatButtonClickHandler: (() => 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 listenersAttached: boolean = false;
|
||||||
private isCancelled: boolean = false;
|
private isCancelled: boolean = false;
|
||||||
private settings: PluginSettings;
|
private settings: PluginSettings;
|
||||||
@@ -1897,6 +1918,7 @@ export class ChatView extends ItemView {
|
|||||||
private historySelectEl: HTMLSelectElement | null = null;
|
private historySelectEl: HTMLSelectElement | null = null;
|
||||||
private historyDeleteButton: HTMLElement | null = null;
|
private historyDeleteButton: HTMLElement | null = null;
|
||||||
private currentAgentMode: AgentMode;
|
private currentAgentMode: AgentMode;
|
||||||
|
private undoManager: UndoManager = new UndoManager();
|
||||||
private pendingActions: ProposedAction[] = [];
|
private pendingActions: ProposedAction[] = [];
|
||||||
private pendingReadResults: (ToolResult & { id?: string })[] = [];
|
private pendingReadResults: (ToolResult & { id?: string })[] = [];
|
||||||
private pendingFollowUpContext: {
|
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 { VaultVectorStore } from './vault-vector-store';
|
||||||
import { VaultIndexer } from './vault-indexer';
|
import { VaultIndexer } from './vault-indexer';
|
||||||
import { AutoTagger, AutoLinker } from './auto-organizer';
|
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 { Logger } from './utils';
|
||||||
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
||||||
import { AgentMode } from './types';
|
|
||||||
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
|
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
|
||||||
import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry';
|
import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry';
|
||||||
import { ChatHistoryManager, createDefaultChatHistoryData } from './chat-history';
|
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>;
|
const data = ((await this.loadData()) ?? {}) as Record<string, unknown>;
|
||||||
// Backward compatibility: old flat format vs new nested format
|
// Backward compatibility: old flat format vs new nested format
|
||||||
const loadedSettings = (data.settings ?? data) as Partial<PluginSettings>;
|
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;
|
const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model;
|
||||||
this.settings.chatModel = loadedSettings.chatModel ?? legacyModel;
|
this.settings.chatModel = loadedSettings.chatModel ?? legacyModel;
|
||||||
this.settings.agentModel = loadedSettings.agentModel ?? 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');
|
Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main');
|
||||||
|
|
||||||
let indexed = 0;
|
let indexed = 0;
|
||||||
const BATCH_SIZE = 1;
|
const BATCH_SIZE = 5;
|
||||||
const DELAY_MS = 500;
|
const DELAY_MS = 500;
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export class OllamaClient {
|
|||||||
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
||||||
'ollama-client'
|
'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);
|
yield* this.streamChatWithRetry(messages, tools, retryCount + 1);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -302,7 +302,7 @@ export class OllamaClient {
|
|||||||
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
||||||
'ollama-client'
|
'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);
|
return this.chatWithRetry(messages, tools, retryCount + 1);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -393,7 +393,7 @@ export class OllamaClient {
|
|||||||
error.message.startsWith('Ollama error:') ||
|
error.message.startsWith('Ollama error:') ||
|
||||||
error.message.includes('Too many malformed chunks') ||
|
error.message.includes('Too many malformed chunks') ||
|
||||||
error.message === 'No response body' ||
|
error.message === 'No response body' ||
|
||||||
error.message === 'Invalid response format'
|
error.message.startsWith('Invalid response format')
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export class SemanticCacheService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.client.deleteCollection({ name: this.config.collectionName });
|
await this.client.deleteCollection({ name: this.config.collectionName });
|
||||||
|
this.collection = null;
|
||||||
Logger.info('Semantic cache cleared', 'semantic-cache');
|
Logger.info('Semantic cache cleared', 'semantic-cache');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(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(?:'d| would)?\s+prefer\s+(?:that\s+)?(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||||
{ regex: /i\s+(?:like|love|enjoy)\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: /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,
|
regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i,
|
||||||
keyPrefix: 'favorite',
|
keyPrefix: 'favorite',
|
||||||
|
|||||||
+93
-16
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { Vault, App, TFile, TFolder } from 'obsidian';
|
import { Vault, App, TFile, TFolder } from 'obsidian';
|
||||||
import type { ToolCall, ToolResult, VaultIndexEntry } from './types';
|
import type { ToolCall, ToolResult, VaultIndexEntry } from './types';
|
||||||
import { safeParseJson } from './utils';
|
import { safeParseJson, Logger } from './utils';
|
||||||
import { TelemetryManager } from './tool-telemetry';
|
import { TelemetryManager } from './tool-telemetry';
|
||||||
import { VaultIndexer } from './vault-indexer';
|
import { VaultIndexer } from './vault-indexer';
|
||||||
|
import { UndoManager } from './undo-manager';
|
||||||
|
|
||||||
// Disallow characters that are invalid in file paths
|
// Disallow characters that are invalid in file paths
|
||||||
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
||||||
@@ -16,17 +17,20 @@ export class ToolExecutor {
|
|||||||
private app: App;
|
private app: App;
|
||||||
private telemetryManager?: TelemetryManager;
|
private telemetryManager?: TelemetryManager;
|
||||||
private vaultIndexer?: VaultIndexer;
|
private vaultIndexer?: VaultIndexer;
|
||||||
|
private undoManager?: UndoManager;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
app: App,
|
app: App,
|
||||||
telemetryManager?: TelemetryManager,
|
telemetryManager?: TelemetryManager,
|
||||||
vaultIndexer?: VaultIndexer
|
vaultIndexer?: VaultIndexer,
|
||||||
|
undoManager?: UndoManager
|
||||||
) {
|
) {
|
||||||
this.vault = vault;
|
this.vault = vault;
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.telemetryManager = telemetryManager;
|
this.telemetryManager = telemetryManager;
|
||||||
this.vaultIndexer = vaultIndexer;
|
this.vaultIndexer = vaultIndexer;
|
||||||
|
this.undoManager = undoManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSafePath(path: string): boolean {
|
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 startTime = Date.now();
|
||||||
const toolName = toolCall.function?.name ?? 'unknown';
|
const toolName = toolCall.function?.name ?? 'unknown';
|
||||||
let parsedArgs: Record<string, unknown> = {};
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
@@ -149,6 +153,11 @@ export class ToolExecutor {
|
|||||||
throw new Error('Arguments must be an object or JSON string');
|
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
|
// Process the tool call based on its type
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'create_file':
|
case 'create_file':
|
||||||
@@ -227,14 +236,9 @@ export class ToolExecutor {
|
|||||||
throw new Error('Invalid file path detected');
|
throw new Error('Invalid file path detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await this.ensureFolderExists(this.getParentFolderPath(path));
|
await this.ensureFolderExists(this.getParentFolderPath(path));
|
||||||
await this.vault.create(path, content);
|
await this.vault.create(path, content);
|
||||||
return { success: true, message: 'Note created successfully' };
|
return { success: true, message: 'Note created successfully' };
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeTool(name: string, args: string | Record<string, unknown>): Promise<ToolResult> {
|
async executeTool(name: string, args: string | Record<string, unknown>): Promise<ToolResult> {
|
||||||
@@ -243,7 +247,7 @@ export class ToolExecutor {
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name,
|
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);
|
const file = this.getFile(path);
|
||||||
await this.vault.delete(file);
|
await this.vault.trash(file, true);
|
||||||
return { success: true, message: `Note ${path} deleted successfully` };
|
return { success: true, message: `Note ${path} moved to trash` };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleListVaultTags(args: Record<string, unknown>): Promise<ToolResult> {
|
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 files = this.vault.getMarkdownFiles();
|
||||||
const folderSet = new Set<string>();
|
const folderSet = new Set<string>();
|
||||||
let totalLength = 0;
|
let totalSize = 0;
|
||||||
let taggedCount = 0;
|
let taggedCount = 0;
|
||||||
let untaggedCount = 0;
|
let untaggedCount = 0;
|
||||||
const tagMap = new Map<string, number>();
|
const tagMap = new Map<string, number>();
|
||||||
@@ -636,8 +640,7 @@ export class ToolExecutor {
|
|||||||
const folder = file.path.split('/').slice(0, -1).join('/') || '(root)';
|
const folder = file.path.split('/').slice(0, -1).join('/') || '(root)';
|
||||||
folderSet.add(folder);
|
folderSet.add(folder);
|
||||||
|
|
||||||
const content = await this.vault.cachedRead(file);
|
if (file.stat?.size) totalSize += file.stat.size;
|
||||||
totalLength += content.length;
|
|
||||||
|
|
||||||
const cache = this.app.metadataCache.getFileCache(file);
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
const rawTags: unknown = cache?.frontmatter?.tags;
|
const rawTags: unknown = cache?.frontmatter?.tags;
|
||||||
@@ -690,12 +693,86 @@ export class ToolExecutor {
|
|||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
.map(([tag, count]) => ({ tag, count })),
|
.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),
|
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> {
|
private async handleInsertLink(args: Record<string, unknown>): Promise<ToolResult> {
|
||||||
const sourcePath = args.sourcePath;
|
const sourcePath = args.sourcePath;
|
||||||
const targetPath = args.targetPath;
|
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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-41
@@ -292,6 +292,41 @@ describe('ChatView', () => {
|
|||||||
expect(lastMessage.isStreaming).toBe(false);
|
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 () => {
|
it('should handle streaming re-attach when existing streaming element is found', async () => {
|
||||||
view['sendButton'] = document.createElement('button');
|
view['sendButton'] = document.createElement('button');
|
||||||
view['inputEl'] = document.createElement('textarea');
|
view['inputEl'] = document.createElement('textarea');
|
||||||
@@ -515,7 +550,7 @@ describe('ChatView', () => {
|
|||||||
expect(lastMessage.isStreaming).toBe(false);
|
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.setAgentMode('edit');
|
||||||
view['sendButton'] = document.createElement('button');
|
view['sendButton'] = document.createElement('button');
|
||||||
view['inputEl'] = document.createElement('textarea');
|
view['inputEl'] = document.createElement('textarea');
|
||||||
@@ -542,29 +577,18 @@ describe('ChatView', () => {
|
|||||||
.spyOn(view['ollamaClient'], 'chat')
|
.spyOn(view['ollamaClient'], 'chat')
|
||||||
.mockResolvedValue({ role: 'assistant', content: 'follow-up' });
|
.mockResolvedValue({ role: 'assistant', content: 'follow-up' });
|
||||||
|
|
||||||
// Mock preview builder
|
// Mock handleToolCall so the write executes without needing real vault
|
||||||
jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({
|
jest.spyOn(view['toolExecutor'], 'handleToolCall').mockResolvedValue({
|
||||||
id: 'tool_1',
|
success: true,
|
||||||
toolCall: {
|
message: 'Note created successfully',
|
||||||
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');
|
await (view as any).handleUserInput('test');
|
||||||
expect(chatSpy).toHaveBeenCalled();
|
expect(chatSpy).toHaveBeenCalled();
|
||||||
// With write tools, follow-up should be deferred until apply
|
// Write tools execute immediately — follow-up is called right away
|
||||||
expect(followUpSpy).not.toHaveBeenCalled();
|
expect(followUpSpy).toHaveBeenCalled();
|
||||||
expect((view as any).pendingActions.length).toBe(1);
|
// No pending actions queue in CoW mode
|
||||||
|
expect((view as any).pendingActions.length).toBe(0);
|
||||||
expect((view as any).messages.length).toBeGreaterThan(1);
|
expect((view as any).messages.length).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -708,25 +732,7 @@ describe('ChatView', () => {
|
|||||||
tool_calls: [],
|
tool_calls: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock preview builder for write tool
|
// Mock tool executor: read tool fails, write tool succeeds
|
||||||
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'];
|
const toolExecutor = view['toolExecutor'];
|
||||||
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => {
|
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => {
|
||||||
if (call.function.name === 'nonexistent_tool') {
|
if (call.function.name === 'nonexistent_tool') {
|
||||||
@@ -741,8 +747,8 @@ describe('ChatView', () => {
|
|||||||
await (view as any).handleUserInput('test');
|
await (view as any).handleUserInput('test');
|
||||||
|
|
||||||
expect(chatSpy).toHaveBeenCalled();
|
expect(chatSpy).toHaveBeenCalled();
|
||||||
// Write tools trigger preview, not immediate follow-up
|
// Write tools execute immediately with CoW; follow-up is called once results are ready
|
||||||
expect(followUpSpy).not.toHaveBeenCalled();
|
expect(followUpSpy).toHaveBeenCalled();
|
||||||
expect(errorHandlerSpy).toHaveBeenCalledWith(expect.any(Error), 'ChatView.handleUserInput');
|
expect(errorHandlerSpy).toHaveBeenCalledWith(expect.any(Error), 'ChatView.handleUserInput');
|
||||||
expect((view as any).messages.length).toBeGreaterThan(1);
|
expect((view as any).messages.length).toBeGreaterThan(1);
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('OllamaClient', () => {
|
|||||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||||
|
|
||||||
await expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500');
|
await expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500');
|
||||||
});
|
}, 15000);
|
||||||
|
|
||||||
it('should handle missing message content gracefully', async () => {
|
it('should handle missing message content gracefully', async () => {
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
@@ -391,7 +391,7 @@ describe('OllamaClient', () => {
|
|||||||
|
|
||||||
expect(callCount).toBe(3);
|
expect(callCount).toBe(3);
|
||||||
expect(chunks.length).toBe(0);
|
expect(chunks.length).toBe(0);
|
||||||
});
|
}, 15000);
|
||||||
|
|
||||||
it('should give up after maxRetries attempts', async () => {
|
it('should give up after maxRetries attempts', async () => {
|
||||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||||
@@ -405,7 +405,7 @@ describe('OllamaClient', () => {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
).rejects.toThrow('Ollama API error: 500');
|
).rejects.toThrow('Ollama API error: 500');
|
||||||
});
|
}, 15000);
|
||||||
|
|
||||||
it('should not retry on 4xx errors', async () => {
|
it('should not retry on 4xx errors', async () => {
|
||||||
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface MockVault {
|
|||||||
getMarkdownFiles: () => any[];
|
getMarkdownFiles: () => any[];
|
||||||
modify: (file: any, content: string) => Promise<void>;
|
modify: (file: any, content: string) => Promise<void>;
|
||||||
rename: (file: any, newPath: string) => Promise<void>;
|
rename: (file: any, newPath: string) => Promise<void>;
|
||||||
delete: (file: any) => Promise<void>;
|
trash: (file: any, system: boolean) => Promise<void>;
|
||||||
}
|
}
|
||||||
interface MockApp {
|
interface MockApp {
|
||||||
metadataCache: {
|
metadataCache: {
|
||||||
@@ -58,7 +58,7 @@ describe('ToolExecutor', () => {
|
|||||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||||
modify: jest.fn().mockResolvedValue(undefined),
|
modify: jest.fn().mockResolvedValue(undefined),
|
||||||
rename: jest.fn().mockResolvedValue(undefined),
|
rename: jest.fn().mockResolvedValue(undefined),
|
||||||
delete: jest.fn().mockResolvedValue(undefined),
|
trash: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
mockApp = {
|
mockApp = {
|
||||||
metadataCache: {
|
metadataCache: {
|
||||||
@@ -1294,7 +1294,7 @@ describe('ToolExecutor', () => {
|
|||||||
};
|
};
|
||||||
const result = await executor.handleToolCall(call);
|
const result = await executor.handleToolCall(call);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(mockVault.delete).toHaveBeenCalledWith(file);
|
expect(mockVault.trash).toHaveBeenCalledWith(file, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user