refactor: convert ChatMode enum to string literals and improve state management

- Change ChatMode enum values from numeric (0,1,2) to string literals ("read_only", "edit", "planning")
- Move chat mode state management from ChatWindow to SettingsService with persistence
- Add chat mode awareness to system prompts and tool validation
- Replace Map with WeakMap for settings subscribers to prevent memory leaks
- Add type-safe event handlers to EventService
- Update esbuild and TypeScript targets from ES2018/ES2020 to ES2022
- Add requiresEditModeEnabled() validation to prevent tool hallucination in read-only mode
- Update all test fixtures to include chatMode in mock settings
This commit is contained in:
Andrew Beal 2026-05-05 20:54:34 +01:00
parent 31f0484114
commit 8823e3cf23
20 changed files with 161 additions and 91 deletions

View file

@ -23,6 +23,7 @@ import { GetWebViewerContent } from "./Tools/GetWebViewerContent";
import { DeleteVaultFolder } from "./Tools/DeleteVaultFolder";
import { MoveVaultFolder } from "./Tools/MoveVaultFolder";
import { ChatMode, chatModeAllowsEdits } from "Enums/ChatMode";
import type { AITool } from "Enums/AITool";
export abstract class AIToolDefinitions {
@ -30,6 +31,9 @@ export abstract class AIToolDefinitions {
private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, GetWebViewerContent,
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
public static readonly editModeDefinitionsList = [WriteVaultFile, PatchVaultFile, DeleteVaultFiles,
MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
public static agentDefinitions(chatMode: ChatMode, memories: boolean, updateMemories: boolean, webViewer: boolean): IAIToolDefinition[] {
if (chatMode === ChatMode.Planning) {
@ -55,15 +59,7 @@ export abstract class AIToolDefinitions {
}
if (chatModeAllowsEdits(chatMode)) {
actions = actions.concat([
WriteVaultFile,
PatchVaultFile,
DeleteVaultFiles,
MoveVaultFiles,
CreateVaultFolder,
DeleteVaultFolder,
MoveVaultFolder
]);
actions = actions.concat(this.editModeDefinitionsList);
}
return actions;
@ -111,6 +107,10 @@ export abstract class AIToolDefinitions {
return [...this.agentDefinitions(ChatMode.Edit, false, false, false), CompleteTask];
}
public static requiresEditModeEnabled(definition: AITool): boolean {
return this.editModeDefinitionsList.map(definition => definition.name).includes(definition);
}
public static compactSummaryForPlanningAgent(): string {
return this.definitionsList.map(definition => {
// Extract first line of description as brief purpose

View file

@ -8,6 +8,7 @@ import { ExecutionPrompt } from "./ExecutionPrompt";
import { OrchestrationPrompt } from "./OrchestrationPrompt";
import type { MemoriesService } from "Services/MemoriesService";
import { Copy, replaceCopy } from "Enums/Copy";
import { ChatMode } from "Enums/ChatMode";
export interface IPrompt {
systemInstruction(): Promise<string>;
@ -63,6 +64,12 @@ export class AIPrompt implements IPrompt {
private buildActiveDirectives(): string {
const s = this.settingsService.settings;
const chatModeDirective = s.chatMode === ChatMode.ReadOnly
? Copy.DirectiveChatModeReadOnly
: s.chatMode === ChatMode.Edit
? Copy.DirectiveChatModeEdit
: Copy.DirectiveChatModePlanning;
const memoriesDirective = !s.enableMemories
? Copy.DirectiveMemoriesDisabled
: s.allowUpdatingMemories
@ -77,7 +84,7 @@ export class AIPrompt implements IPrompt {
? Copy.DirectiveWebViewerEnabled
: Copy.DirectiveWebViewerDisabled;
const directives = [memoriesDirective, webSearchDirective, webViewerDirective].join("\n");
const directives = [chatModeDirective, memoriesDirective, webSearchDirective, webViewerDirective].join("\n");
return replaceCopy(Copy.ActiveCapabilitiesHeader, [directives]);
}

View file

@ -30,7 +30,23 @@ User: "Create a note about today's meeting with Sarah"
---
### 2. PLAN EXECUTION PROTOCOL
### 2. CHAT MODE
**Your available actions are determined by the current chat mode, shown in the Active Capabilities section at the end of this prompt.**
| Mode | What you can do |
|------|----------------|
| **Read-Only** | Search, read, and list vault files only no writes, moves, or deletes |
| **Edit** | Full access create, edit, move, and delete files |
| **Planning** | Full access via planned workflow execution |
**In Read-Only mode:**
- Do not attempt to create, edit, move, or delete files the tools are not available
- If the user asks you to make changes, acknowledge that chat mode is currently set to Read-Only and let them know they can change it using the chat mode button in the input bar
---
### 3. PLAN EXECUTION PROTOCOL
**When the user has enabled planning mode and you receive a plan, follow this protocol.**
@ -137,7 +153,7 @@ When asking questions during execution:
---
### 3. HISTORICAL CONTEXT INTERPRETATION
### 4. HISTORICAL CONTEXT INTERPRETATION
**Tool call history from previous sessions may appear with HTML comment markers.**
@ -151,7 +167,7 @@ If you see JSON preceded by "Historical tool call/result", it documents a past a
---
### 4. WIKI-LINK EVERYTHING FROM THE VAULT
### 5. WIKI-LINK EVERYTHING FROM THE VAULT
**ALWAYS use [[double-bracket]] wiki-link syntax when referencing anything from the user's vault.**
@ -194,7 +210,7 @@ This is a hard requirement, not a suggestion. The format is: \`[[Note Name]]\`
---
### 5. VAULT-FIRST DECISION FRAMEWORK
### 6. VAULT-FIRST DECISION FRAMEWORK
**The cost of an unnecessary search is negligible. Missing relevant information is costly.**
@ -218,7 +234,7 @@ Acknowledge the search, then provide general assistance:
---
### 6. MEMORY SYSTEM
### 7. MEMORY SYSTEM
**Memory gives you continuity across sessions. It is your record of how this user works in this vault.**

View file

@ -30,10 +30,11 @@
export let hasNoApiKey: boolean;
export let isSubmitting: boolean;
export let chatMode: ChatMode;
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
export let onStop: () => void;
const componentToken = {};
const inputService: InputService = Resolve<InputService>(Services.InputService);
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
@ -59,6 +60,7 @@
let userRequest: string = "";
let chatMode: ChatMode = settingsService.settings.chatMode;
let inputMode: InputMode = InputMode.Normal;
let questionResolver: ((answer: string) => void) | null = null;
@ -70,6 +72,8 @@
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
settingsService.subscribeToSettingsChanged(componentToken, () => chatMode = settingsService.settings.chatMode);
onMount(async () => {
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
inputInitialHeight = textareaElement.innerHeight;
@ -290,8 +294,9 @@
}
function toggleWebSearch() {
settingsService.settings.enableWebSearch = !settingsService.settings.enableWebSearch;
settingsService.saveSettings();
settingsService.updateSettings(settings => {
settings.enableWebSearch = !settingsService.settings.enableWebSearch;
});
}
function toggleChatModeSelectionArea() {
@ -541,7 +546,7 @@
</div>
<div id="chat-mode-selector-container" style:padding-top={chatModeSelectionAreaActive ? "var(--size-4-2)" : 0}>
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} bind:currentChatMode={chatMode} />
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} />
</div>
<button

View file

@ -3,24 +3,34 @@
import { Copy } from "Enums/Copy";
import { tick } from "svelte";
import { setIcon } from "obsidian";
import type { SettingsService } from "Services/SettingsService";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
export let focusInput: () => void;
export let chatModeSelectionAreaActive: boolean;
export let currentChatMode: ChatMode;
const componentToken = {};
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
settingsService.subscribeToSettingsChanged(componentToken, () => currentChatMode = settingsService.settings.chatMode);
let height = 0;
let ChatModeSelectionContentDiv: HTMLDivElement;
let chatModeSelectionContainer: HTMLDivElement;
let selectedChatMode: number = ChatMode.ReadOnly;
let currentChatMode: ChatMode = settingsService.settings.chatMode;
let selectedChatMode: ChatMode = ChatMode.ReadOnly;
let iconElements: (HTMLDivElement | null)[] = [null, null, null];
let indexedModes: ChatMode[] = [ChatMode.ReadOnly, ChatMode.Edit, ChatMode.Planning];
$: if (iconElements.some(element => element !== null)) {
iconElements.forEach((element, index) => {
if (element) {
setIcon(element, iconForChatMode(index));
setIcon(element, iconForChatMode(indexedModes[index]));
}
});
}
@ -59,10 +69,14 @@
}
}
function handleChatModeSelect(e?: MouseEvent) {
async function handleChatModeSelect(e?: MouseEvent) {
currentChatMode = selectedChatMode;
chatModeSelectionAreaActive = false;
await settingsService.updateSettings(settings => {
settings.chatMode = currentChatMode
});
e?.preventDefault();
focusInput();
}
@ -74,11 +88,12 @@
e.preventDefault();
if (e.key.startsWith("Arrow")) {
const index = indexedModes.indexOf(selectedChatMode);
if (e.key === "ArrowUp") {
selectedChatMode = selectedChatMode <= 0 ? 2 : selectedChatMode - 1;
selectedChatMode = index === 0 ? indexedModes[2] : indexedModes[index - 1];
}
if (e.key === "ArrowDown") {
selectedChatMode = selectedChatMode >= 2 ? 0 : selectedChatMode + 1;
selectedChatMode = index >= 2 ? indexedModes[0] : indexedModes[index + 1];
}
return;
}
@ -111,7 +126,7 @@
on:mouseenter={() => selectedChatMode = ChatMode.ReadOnly}
on:mousedown={handleChatModeSelect}
on:keydown={() => {}}>
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.ReadOnly]}></div>
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.ReadOnly)]}></div>
<div class="chat-mode-selection-title">{Copy.ChatModeReadOnlyTitle}</div>
<div class="chat-mode-selection-subtitle">{Copy.ChatModeReadOnlyDesc}</div>
</div>
@ -124,7 +139,7 @@
on:mouseenter={() => selectedChatMode = ChatMode.Edit}
on:mousedown={handleChatModeSelect}
on:keydown={() => {}}>
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Edit]}></div>
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.Edit)]}></div>
<div class="chat-mode-selection-title">{Copy.ChatModeEditTitle}</div>
<div class="chat-mode-selection-subtitle">{Copy.ChatModeEditDesc}</div>
</div>
@ -137,7 +152,7 @@
on:mouseenter={() => selectedChatMode = ChatMode.Planning}
on:mousedown={handleChatModeSelect}
on:keydown={() => {}}>
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Planning]}></div>
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.Planning)]}></div>
<div class="chat-mode-selection-title">{Copy.ChatModePlanningTitle}</div>
<div class="chat-mode-selection-subtitle">{Copy.ChatModePlanningDesc}</div>
</div>

View file

@ -21,7 +21,6 @@
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import { HTMLService } from "Services/HTMLService";
import { AITool, fromString } from "Enums/AITool";
import { ChatMode } from "Enums/ChatMode";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
@ -40,7 +39,6 @@
let hasNoApiKey = false;
let isSubmitting = false;
let busyPlanning = false;
let chatMode: ChatMode = ChatMode.ReadOnly;
let currentStreamingMessageId: string | null = null;
let conversation: Conversation = new Conversation();
@ -103,7 +101,7 @@
const currentRequest = userRequest;
await chatService.submit(conversation, chatMode, currentRequest, formattedRequest, attachments, {
await chatService.submit(conversation, settingsService.settings.chatMode, currentRequest, formattedRequest, attachments, {
onSubmit: () => {
isSubmitting = true;
attachments = [];
@ -206,10 +204,6 @@
});
}
$: if ($conversationStore.shouldDeactivateEditMode) {
conversationStore.clearEditModeFlag();
chatMode = ChatMode.ReadOnly;
}
</script>
<main class="container">
@ -223,7 +217,6 @@
<ChatInput
bind:this={chatInput}
bind:attachments
bind:chatMode={chatMode}
{hasNoApiKey}
{isSubmitting}
onSubmit={handleSubmit}

View file

@ -1,7 +1,7 @@
export enum ChatMode {
ReadOnly = 0,
Edit = 1,
Planning = 2
ReadOnly = "read_only",
Edit = "edit",
Planning = "planning"
}
export function chatModeAllowsEdits(mode: ChatMode) {

View file

@ -182,6 +182,9 @@ The following context explains why you are doing the task. It is NOT an instruct
// Active Capabilities
ActiveCapabilitiesHeader = `\n\n---\n\n## Active Capabilities\n\nThe following reflects your current configuration. Follow these directives exactly.\n\n{directives}`,
DirectiveChatModeReadOnly = "- **Chat Mode**: READ-ONLY — you can read and search the vault but cannot create, edit, move, or delete files; if the user asks you to make changes, inform them that edit mode is currently off",
DirectiveChatModeEdit = "- **Chat Mode**: EDIT — you may create, edit, move, and delete files in the vault",
DirectiveChatModePlanning = "- **Chat Mode**: PLANNING — you should request a planned workflow before executing tasks in the vault",
DirectiveMemoriesDisabled = "- **Memory**: DISABLED — all memory tools are currently unavailable",
DirectiveMemoriesEnabled = "- **Memory**: ENABLED — memories are injected above; you must always read and update them",
DirectiveMemoriesReadOnly = "- **Memory**: ENABLED (read-only) — memories are injected above; you must read them but updating them is not possible",

View file

@ -3,7 +3,7 @@ import { Services } from "../Services";
import type { FileSystemService } from "../FileSystemService";
import { AITool, fromString } from "Enums/AITool";
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
import type { AIToolCall } from "AIClasses/AIToolCall";
import { AIToolCall } from "AIClasses/AIToolCall";
import type { ISearchMatch } from "../../Types/SearchTypes";
import { AbortService } from "../AbortService";
import { normalizePath, TAbstractFile, TFile } from "obsidian";
@ -19,6 +19,8 @@ import { isDocumentMimeType, MimeType } from "Enums/MimeType";
import { isTextFile, toFileType } from "Enums/FileType";
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
import { StringTools } from "Helpers/StringTools";
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
import { chatModeAllowsEdits } from "Enums/ChatMode";
import {
SearchVaultFilesArgsSchema,
ReadVaultFilesArgsSchema,
@ -54,6 +56,16 @@ export class AIToolService {
}
public async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
// This can happen if the agent hallucinates a legitimate tool call it doesn't currently have access to
if (!chatModeAllowsEdits(this.settingsService.settings.chatMode) && AIToolDefinitions.requiresEditModeEnabled(toolCall.name)) {
Exception.log(`Invalid tool call: ${toolCall.name}. Agent has hallucinated a legitimate tool call that requires edit mode`);
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid tool call: ${toolCall.name}. The user has not allowed edits to be made.` }),
toolCall.toolId
);
}
if (toolCall.name !== AITool.ReadMemories && toolCall.name !== AITool.UpdateMemories) {
this.lastToolReadMemories = false;

View file

@ -1,8 +1,17 @@
import type { Event } from "Enums/Event";
import { Events } from "obsidian";
import { Events, type EventRef } from "obsidian";
export class EventService extends Events {
public on(name: Event.DiffOpened, callback: () => void): EventRef;
public on(name: Event.DiffClosed, callback: () => void): EventRef;
public on(name: Event.RateLimitCountdown, callback: (delayMs: number) => void): EventRef;
public on(name: Event.QuickActionsSettingsChanged, callback: (data?: unknown) => void): EventRef;
public on<T extends unknown[]>(name: string, callback: (...data: T) => unknown): EventRef {
return super.on(name, callback as (...data: unknown[]) => unknown);
}
public trigger(name: Event.DiffOpened, data?: unknown): void;
public trigger(name: Event.DiffClosed, data?: unknown): void;
public trigger(name: Event.RateLimitCountdown, delayMs: number): void;

View file

@ -86,7 +86,8 @@ export class SettingsService {
public readonly settings: Readonly<IVaultkeeperAISettings>;
private readonly plugin: VaultkeeperAIPlugin;
private readonly subscribers: Map<object, (() => void) | (() => Promise<void>)> = new Map();
private readonly subscribers: WeakMap<object, (() => void) | (() => Promise<void>)> = new WeakMap();
private readonly subscriberRefs: Set<WeakRef<object>> = new Set();
private settingsSnapshot: string;
@ -99,6 +100,7 @@ export class SettingsService {
public subscribeToSettingsChanged(subscriber: object, callback: (() => void) | (() => Promise<void>)): void {
this.subscribers.set(subscriber, callback);
this.subscriberRefs.add(new WeakRef(subscriber));
}
public unsubscribe(subscriber: object): void {
@ -150,8 +152,13 @@ export class SettingsService {
const snapshot = JSON.stringify(this.settings);
if (this.settingsSnapshot !== snapshot) {
this.settingsSnapshot = snapshot;
for (const callback of this.subscribers.values()) {
await callback();
for (const ref of this.subscriberRefs) {
const subscriber = ref.deref();
if (!subscriber) {
this.subscriberRefs.delete(ref);
continue;
}
await this.subscribers.get(subscriber)?.();
}
}
}

View file

@ -4,24 +4,21 @@ import type { Conversation } from 'Conversations/Conversation';
interface IConversationStoreState {
shouldReset: boolean;
conversationToLoad: { conversation: Conversation; filePath: string } | null;
shouldDeactivateEditMode: boolean;
}
function createConversationStore() {
const { subscribe, set, update } = writable<IConversationStoreState>({
shouldReset: false,
conversationToLoad: null,
shouldDeactivateEditMode: false
});
return {
subscribe,
reset: () => set({ shouldReset: true, conversationToLoad: null, shouldDeactivateEditMode: true }),
reset: () => set({ shouldReset: true, conversationToLoad: null }),
clearResetFlag: () => update(state => ({ ...state, shouldReset: false })),
loadConversation: (conversation: Conversation, filePath: string) =>
set({ shouldReset: false, conversationToLoad: { conversation, filePath }, shouldDeactivateEditMode: true }),
set({ shouldReset: false, conversationToLoad: { conversation, filePath } }),
clearLoadFlag: () => update(state => ({ ...state, conversationToLoad: null })),
clearEditModeFlag: () => update(state => ({ ...state, shouldDeactivateEditMode: false }))
};
}

View file

@ -6,6 +6,7 @@ import { AITool } from '../../Enums/AITool';
import { TFile } from 'obsidian';
import { Exception } from '../../Helpers/Exception';
import { AbortService } from '../../Services/AbortService';
import { ChatMode } from '../../Enums/ChatMode';
/**
* INTEGRATION TESTS - AIToolService
@ -51,7 +52,7 @@ describe('AIToolService - Integration Tests', () => {
updateMemories: vi.fn().mockResolvedValue(undefined)
});
RegisterSingleton(Services.SettingsService, {
settings: { enableMemories: false, allowUpdatingMemories: false }
settings: { enableMemories: false, allowUpdatingMemories: false, chatMode: ChatMode.Edit }
});
RegisterSingleton(Services.WebViewerService, {
getWebViewContent: vi.fn(),

View file

@ -305,11 +305,14 @@ describe('SettingsService', () => {
});
it('should not affect other provider keys when updating one', () => {
settingsService.settings.apiKeys = {
claude: 'existing-claude',
openai: 'existing-openai',
gemini: 'existing-gemini', mistral: ''
};
settingsService = new SettingsService({
apiKeys: {
claude: 'existing-claude',
openai: 'existing-openai',
gemini: 'existing-gemini',
mistral: ''
}
});
settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-claude');
@ -455,17 +458,17 @@ describe('SettingsService', () => {
expect(settingsRef.apiKeys.claude).toBe('new-key');
});
it('should allow direct modification of settings properties', () => {
it('should allow modification of settings properties via updateSettings', async () => {
settingsService = new SettingsService({
model: AIProviderModel.ClaudeSonnet_4_5,
apiKeys: { claude: '', openai: '', gemini: '', mistral: '' },
exclusions: []
});
settingsService.settings.exclusions.push('test-exclusion');
await settingsService.updateSettings(s => { s.exclusions.push('test-exclusion'); });
expect(settingsService.settings.exclusions).toContain('test-exclusion');
settingsService.settings.userInstruction = 'Direct modification';
await settingsService.updateSettings(s => { s.userInstruction = 'Direct modification'; });
expect(settingsService.settings.userInstruction).toBe('Direct modification');
});
});
@ -509,15 +512,15 @@ describe('SettingsService', () => {
expect(settingsService.settings.snippetSizeLimit).toBe(0);
});
it('should allow direct modification of searchResultsLimit', () => {
it('should allow modification of searchResultsLimit via updateSettings', async () => {
settingsService = new SettingsService({});
settingsService.settings.searchResultsLimit = 50;
await settingsService.updateSettings(s => { s.searchResultsLimit = 50; });
expect(settingsService.settings.searchResultsLimit).toBe(50);
});
it('should allow direct modification of snippetSizeLimit', () => {
it('should allow modification of snippetSizeLimit via updateSettings', async () => {
settingsService = new SettingsService({});
settingsService.settings.snippetSizeLimit = 500;
await settingsService.updateSettings(s => { s.snippetSizeLimit = 500; });
expect(settingsService.settings.snippetSizeLimit).toBe(500);
});

View file

@ -8,6 +8,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import type VaultkeeperAIPlugin from '../../main';
import { ChatMode } from '../../Enums/ChatMode';
// Mock getAllTags from obsidian
vi.mock('obsidian', async () => {
@ -108,7 +109,8 @@ const mockSettings: IVaultkeeperAISettings = {
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
let settingsService: SettingsService;

View file

@ -9,6 +9,7 @@ import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';
import * as PDFHelper from '../../Helpers/DocumentHelper';
import type { IPageText } from '../../Types/SearchTypes';
import { ChatMode } from '../../Enums/ChatMode';
/**
* PDF-SPECIFIC TESTS FOR VAULTSERVICE
@ -68,7 +69,8 @@ const mockSettings: IVaultkeeperAISettings = {
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
const mockPlugin = {
@ -471,7 +473,7 @@ describe('VaultService - PDF Tests', () => {
});
it('should respect snippetSizeLimit for PDF content', async () => {
settingsService.settings.snippetSizeLimit = 20;
await settingsService.updateSettings(s => { s.snippetSizeLimit = 20; });
const pdfFile = createMockPDFFile('limited.pdf');
const folder = createMockFolder('/', [pdfFile]);

View file

@ -7,6 +7,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import type VaultkeeperAIPlugin from '../../main';
import { ChatMode } from '../../Enums/ChatMode';
/**
* Performance Test Suite for VaultService.searchVaultFiles()
@ -70,7 +71,8 @@ const mockSettings: IVaultkeeperAISettings = {
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
let settingsService: SettingsService;

View file

@ -8,6 +8,7 @@ import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';
import { ChatMode } from '../../Enums/ChatMode';
/**
* INTEGRATION TESTS
@ -67,7 +68,8 @@ const mockSettings: IVaultkeeperAISettings = {
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
const mockPlugin = {
@ -116,7 +118,7 @@ describe('VaultService - Integration Tests', () => {
// Reset all mocks
vi.clearAllMocks();
// Reset settings to defaults
// Reset settings to defaults (mutating before SettingsService construction is fine)
mockSettings.exclusions = [];
mockSettings.searchResultsLimit = 15;
mockSettings.snippetSizeLimit = 300;
@ -201,7 +203,7 @@ describe('VaultService - Integration Tests', () => {
expect(result).toHaveLength(2);
});
it('should filter out user-defined exclusions', () => {
it('should filter out user-defined exclusions', async () => {
const files = [
createMockFile('public/note1.md'),
createMockFile('private/secret.md'),
@ -209,8 +211,7 @@ describe('VaultService - Integration Tests', () => {
];
mockVault.getMarkdownFiles.mockReturnValue(files);
// Update settings to include exclusion
settingsService.settings.exclusions = ['private/**'];
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
const result = vaultService.getMarkdownFiles();
@ -799,7 +800,7 @@ describe('VaultService - Integration Tests', () => {
const folder = createMockFolder('folder', [file1, file2]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
settingsService.settings.exclusions = ['**/private.md'];
await settingsService.updateSettings(s => { s.exclusions = ['**/private.md']; });
const result = await vaultService.listFilesInDirectory('folder', false);
@ -910,7 +911,7 @@ describe('VaultService - Integration Tests', () => {
const parentFolder = createMockFolder('parent', [publicFolder, privateFolder]);
mockVault.getAbstractFileByPath.mockReturnValue(parentFolder);
settingsService.settings.exclusions = ['**/private'];
await settingsService.updateSettings(s => { s.exclusions = ['**/private']; });
const result = await vaultService.listFoldersInDirectory('parent', false);
@ -979,8 +980,7 @@ describe('VaultService - Integration Tests', () => {
return null;
});
// Use pattern that matches the folder itself and its contents
settingsService.settings.exclusions = ['parent/excluded/**', 'parent/excluded'];
await settingsService.updateSettings(s => { s.exclusions = ['parent/excluded/**', 'parent/excluded']; });
const result = await vaultService.listFoldersInDirectory('parent', true);
@ -1092,8 +1092,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should respect custom searchResultsLimit setting', async () => {
// Set custom limit
settingsService.settings.searchResultsLimit = 5;
await settingsService.updateSettings(s => { s.searchResultsLimit = 5; });
// Create 10 files, each with a match
const files: TFile[] = [];
@ -1113,8 +1112,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should respect custom snippetSizeLimit setting for snippet extraction', async () => {
// Set custom snippet size
settingsService.settings.snippetSizeLimit = 20;
await settingsService.updateSettings(s => { s.snippetSizeLimit = 20; });
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
@ -1363,7 +1361,7 @@ describe('VaultService - Integration Tests', () => {
describe('isExclusion (private method behavior)', () => {
it('should exclude exact path matches', async () => {
settingsService.settings.exclusions = ['secret.md'];
await settingsService.updateSettings(s => { s.exclusions = ['secret.md']; });
const result = await vaultService.exists('secret.md');
@ -1371,7 +1369,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should handle wildcard * (matches any non-slash)', async () => {
settingsService.settings.exclusions = ['folder/*.md'];
await settingsService.updateSettings(s => { s.exclusions = ['folder/*.md']; });
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
@ -1381,7 +1379,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should handle double wildcard ** (matches anything including slashes)', async () => {
settingsService.settings.exclusions = ['private/**'];
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
@ -1392,7 +1390,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should handle patterns ending with / to match directory and contents', async () => {
settingsService.settings.exclusions = ['temp/'];
await settingsService.updateSettings(s => { s.exclusions = ['temp/']; });
expect(await vaultService.exists('temp/file.md')).toBe(false);
expect(await vaultService.exists('temp/sub/file.md')).toBe(false);
@ -1405,7 +1403,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should handle special regex characters in patterns', async () => {
settingsService.settings.exclusions = ['folder[test].md'];
await settingsService.updateSettings(s => { s.exclusions = ['folder[test].md']; });
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
@ -1416,7 +1414,7 @@ describe('VaultService - Integration Tests', () => {
});
it('should handle multiple exclusion patterns', async () => {
settingsService.settings.exclusions = ['private/**', 'temp/', '*.secret'];
await settingsService.updateSettings(s => { s.exclusions = ['private/**', 'temp/', '*.secret']; });
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
@ -1522,7 +1520,7 @@ describe('VaultService - Integration Tests', () => {
return null;
});
settingsService.settings.exclusions = ['private/**'];
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
const result = await vaultService.listDirectoryContents(Path.Root, true, false);

View file

@ -167,7 +167,7 @@ const buildOptions = {
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,

View file

@ -5,7 +5,7 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"target": "ES2022",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "bundler",
@ -14,9 +14,7 @@
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2022"
],
"paths": {
"main": ["./main.ts"],