diff --git a/AIClasses/ToolDefinitions/AIToolResponse.ts b/AIClasses/ToolDefinitions/AIToolResponse.ts index 2b33821..48caf3a 100644 --- a/AIClasses/ToolDefinitions/AIToolResponse.ts +++ b/AIClasses/ToolDefinitions/AIToolResponse.ts @@ -10,25 +10,25 @@ export class AIToolResponse { public readonly toolId?: string; public static readonly UserRejectionMessage: string = `The user has explicitly rejected this change. - They may have changed their mind about the requested change. +They may have changed their mind about the requested change. - **CRITICAL:** Immediately stop all further actions and consult with the user`; +**CRITICAL:** Immediately stop all further actions and consult with the user`; public static readonly UserSuggestionMessage: string = `**USER MODIFICATION REQUEST:** - The user has reviewed your proposed action and provided a modification or alternative direction. +The user has reviewed your proposed action and provided a modification or alternative direction. - **Critical Instructions:** - 1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously - 2. The user may want to: - - Adjust the SAME action with different parameters (e.g., write to a different file) - - Change to a DIFFERENT action entirely (e.g., delete instead of write) - - Add context or constraints you didn't initially consider - 3. Carefully analyze the user's suggestion below to understand their true intent - 4. Acknowledge their feedback and explain how you'll adjust your approach - 5. Then proceed with the modified action that aligns with their guidance +**Critical Instructions:** +1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously +2. The user may want to: + - Adjust the SAME action with different parameters (e.g., write to a different file) + - Change to a DIFFERENT action entirely (e.g., delete instead of write) + - Add context or constraints you didn't initially consider +3. Carefully analyze the user's suggestion below to understand their true intent +4. Acknowledge their feedback and explain how you'll adjust your approach +5. Then proceed with the modified action that aligns with their guidance - **User's Suggestion:**`; +**User's Suggestion:**`; constructor(name: AITool, payload: AIToolResponsePayload, toolId?: string) { this.name = name; diff --git a/AIClasses/ToolDefinitions/AIToolResponsePayload.ts b/AIClasses/ToolDefinitions/AIToolResponsePayload.ts index 12ec6f1..85e3aa0 100644 --- a/AIClasses/ToolDefinitions/AIToolResponsePayload.ts +++ b/AIClasses/ToolDefinitions/AIToolResponsePayload.ts @@ -1,11 +1,14 @@ +import type { Artifact } from "Conversations/Artifact"; import type { Attachment } from "Conversations/Attachment"; export class AIToolResponsePayload { public readonly response: object; + public readonly artifacts: Artifact[]; public readonly attachments: Attachment[]; - constructor(response: object, attachments: Attachment[] = []) { + constructor(response: object, artifacts: Artifact[] = [], attachments: Attachment[] = []) { this.response = response; + this.artifacts = artifacts; this.attachments = attachments; } } \ No newline at end of file diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index c9404fd..49714c8 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -21,6 +21,9 @@ import { AITool, fromString } from "Enums/AITool"; import { AIProvider } from "Enums/ApiProvider"; import type { PlanApprovalService } from "Services/PlanApprovalService"; + import type { Artifact } from "Conversations/Artifact"; + import { ConversationContent } from "Conversations/ConversationContent"; + import { Role } from "Enums/Role"; const plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); const executionPlanStore: ExecutionPlanStore = Resolve(Services.ExecutionPlanStore); @@ -32,6 +35,8 @@ const streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService); const abortService: AbortService = Resolve(Services.AbortService); + let collectedArtifacts: Artifact[] = []; + let chatContainer: HTMLDivElement; let chatArea: ChatArea; let chatInput: ChatInput; @@ -98,6 +103,7 @@ if (handleNoApiKey()) { return; } + collectedArtifacts = []; const currentRequest = userRequest; @@ -132,6 +138,14 @@ break; } }, + onArtifactProduced: (artifact: Artifact) => { + const collectedArtifact = collectedArtifacts.find(a => a.filePath === artifact.filePath); + if (!collectedArtifact) { + collectedArtifacts.push(artifact); + return; + } + collectedArtifact.updatedContent = artifact.updatedContent; + }, onPlanningStarted: () => { busyPlanning = true; }, @@ -159,6 +173,8 @@ executionPlanStore.clearPlan(); }, onComplete: async () => { + saveCollectedArtifects(conversation); + conversationService.saveConversation(conversation); isSubmitting = false; busyPlanning = false; currentThought = null; @@ -170,6 +186,17 @@ }); } + function saveCollectedArtifects(conversation: Conversation): void { + let lastMessage = conversation.contents.last(); + + if (lastMessage?.role !== Role.Assistant || !lastMessage.shouldDisplayContent) { + lastMessage = new ConversationContent({ role: Role.Assistant }); + conversation.contents.push(lastMessage); + } + + lastMessage.artifacts = collectedArtifacts; + } + $: if ($conversationStore.shouldReset) { conversation = new Conversation(); conversationService.resetCurrentConversation(); diff --git a/Components/DiffControls.svelte b/Components/DiffControls.svelte index b794440..653c358 100644 --- a/Components/DiffControls.svelte +++ b/Components/DiffControls.svelte @@ -57,36 +57,52 @@ #diff-accept { grid-column: 1; - background-color: color-mix( - in srgb, - var(--color-green) 75%, - var(--background-primary) 25% - ); + color: var(--color-green); + border: solid; + border-color: var(--color-green); + border-width: var(--size-2-1); + background-color: var(--background-primary); } #diff-accept:hover { - background-color: var(--color-green); + background-color: color-mix( + in srgb, + var(--color-green) 25%, + white 10% + ); } #diff-accept:focus { - background-color: var(--color-green); + background-color: color-mix( + in srgb, + var(--color-green) 25%, + white 10% + ); } #diff-reject { grid-column: 3; - background-color: color-mix( - in srgb, - var(--color-red) 75%, - var(--background-primary) 25% - ); + color: var(--color-red); + border: solid; + border-color: var(--color-red); + border-width: var(--size-2-1); + background-color: var(--background-primary); } #diff-reject:hover { - background-color: var(--color-red); + background-color: color-mix( + in srgb, + var(--color-red) 25%, + white 10% + ); } #diff-reject:focus { - background-color: var(--color-red); + background-color: color-mix( + in srgb, + var(--color-red) 25%, + white 10% + ); } .diff-button { diff --git a/Components/PlanApprovalControls.svelte b/Components/PlanApprovalControls.svelte index 542624b..4b741b3 100644 --- a/Components/PlanApprovalControls.svelte +++ b/Components/PlanApprovalControls.svelte @@ -58,36 +58,52 @@ #plan-approve { grid-column: 1; - background-color: color-mix( - in srgb, - var(--color-green) 75%, - var(--background-primary) 25% - ); + color: var(--color-green); + border: solid; + border-color: var(--color-green); + border-width: var(--size-2-1); + background-color: var(--background-primary); } #plan-approve:hover { - background-color: var(--color-green); + background-color: color-mix( + in srgb, + var(--color-green) 25%, + white 10% + ); } #plan-approve:focus { - background-color: var(--color-green); + background-color: color-mix( + in srgb, + var(--color-green) 25%, + white 10% + ); } #plan-reject { grid-column: 3; - background-color: color-mix( - in srgb, - var(--color-red) 75%, - var(--background-primary) 25% - ); + color: var(--color-red); + border: solid; + border-color: var(--color-red); + border-width: var(--size-2-1); + background-color: var(--background-primary); } #plan-reject:hover { - background-color: var(--color-red); + background-color: color-mix( + in srgb, + var(--color-red) 25%, + white 10% + ); } #plan-reject:focus { - background-color: var(--color-red); + background-color: color-mix( + in srgb, + var(--color-red) 25%, + white 10% + ); } .plan-approval-button { diff --git a/Conversations/Artifact.ts b/Conversations/Artifact.ts new file mode 100644 index 0000000..27293dd --- /dev/null +++ b/Conversations/Artifact.ts @@ -0,0 +1,53 @@ +import type { IBinaryFile } from "Conversations/IBinaryFile"; + +export class Artifact implements IBinaryFile { + + public filePath: string; + public mimeType: string; + public originalContent: string; + public updatedContent: string; + public base64: string | undefined; + public artifactPath?: string; + + public constructor(filePath: string, mimeType: string, originalContent: string, updatedContent: string, base64?: string, artifactPath?: string) { + this.filePath = filePath; + this.mimeType = mimeType; + this.originalContent = originalContent; + this.updatedContent = updatedContent; + this.base64 = base64; + this.artifactPath = artifactPath; + } + + public getStoragePath(): string | undefined { + return this.artifactPath; + } + + public setStoragePath(path: string): void { + this.artifactPath = path; + } + + public static isArtifactData(this: void, data: unknown): data is { + filePath: string; + mimeType: string; + originalContent: string; + updatedContent: string; + base64?: string; + artifactPath?: string; + } { + return ( + data !== null && + typeof data === "object" && + "filePath" in data && + typeof data.filePath === "string" && + "mimeType" in data && + typeof data.mimeType === "string" && + "originalContent" in data && + typeof data.originalContent === "string" && + "updatedContent" in data && + typeof data.updatedContent === "string" && + (!("base64" in data) || typeof data.base64 === "string") && + (!("artifactPath" in data) || typeof data.artifactPath === "string") + ); + } + +} \ No newline at end of file diff --git a/Conversations/Attachment.ts b/Conversations/Attachment.ts index b4c6337..6d72fef 100644 --- a/Conversations/Attachment.ts +++ b/Conversations/Attachment.ts @@ -3,8 +3,9 @@ import { isAudioFile, isImageFile, isKnownFileType, isTextFile, isVideoFile } fr import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping"; import { isImageMimeType, isTextMimeType, MimeType, toMimeType } from "Enums/MimeType"; import { StringTools } from "Helpers/StringTools"; +import type { IBinaryFile } from "Conversations/IBinaryFile"; -export class Attachment { +export class Attachment implements IBinaryFile { public fileName: string; public mimeType: string; @@ -41,6 +42,14 @@ export class Attachment { return this.base64; } + public getStoragePath(): string | undefined { + return this.filePath; + } + + public setStoragePath(path: string): void { + this.filePath = path; + } + public getFileID(provider: AIProvider): string | undefined { return this.fileID[provider]; } diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index c8f451b..cdc172e 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -3,6 +3,7 @@ import { ApiErrorType } from "Types/ApiError"; import type { Attachment } from "./Attachment"; import type { Reference } from "./Reference"; import { Copy } from "Enums/Copy"; +import type { Artifact } from "./Artifact"; type ConversationContentInit = { role: Role; @@ -11,6 +12,7 @@ type ConversationContentInit = { displayContent?: string; toolCall?: string; functionResponse?: string; + artifacts?: Artifact[]; attachments?: Attachment[]; references?: Reference[]; shouldDisplayContent?: boolean; @@ -30,6 +32,7 @@ export class ConversationContent { public displayContent: string | undefined; public toolCall: string | undefined; public functionResponse: string | undefined; + public artifacts: Artifact[]; public attachments: Attachment[]; public references: Reference[]; public shouldDisplayContent: boolean; @@ -47,6 +50,7 @@ export class ConversationContent { * @param init.displayContent - Display content is used when content needs to be formatted differently when displayed versus as a prompt * @param init.toolCall - JSON string of the function call data (only set for function/tool calls) * @param init.functionResponse - JSON string of the function call response data (only set for function/tool responses) + * @param init.artifacts - Array of artefacts that track edits to files made by the agent during the turn * @param init.attachments - Array of file attachments associated with this message (defaults to empty array) * @param init.references - Array of file references, used to display attachment's to the user associated with attachments * @param init.shouldDisplayContent - Whether this content should be displayed in the UI (defaults to true, false for system-generated messages) @@ -62,6 +66,7 @@ export class ConversationContent { this.displayContent = init.displayContent; this.toolCall = init.toolCall; this.functionResponse = init.functionResponse; + this.artifacts = init.artifacts ?? []; this.attachments = init.attachments ?? []; this.references = init.references ?? []; this.shouldDisplayContent = init.shouldDisplayContent ?? true; @@ -84,6 +89,7 @@ export class ConversationContent { displayContent?: string; toolCall?: string; functionResponse?: string; + artifacts?: unknown[]; attachments?: unknown[]; references?: unknown[]; shouldDisplayContent?: boolean; @@ -103,6 +109,7 @@ export class ConversationContent { (!("displayContent" in data) || typeof data.displayContent === "string") && (!("toolCall" in data) || typeof data.toolCall === "string") && (!("functionResponse" in data) || typeof data.functionResponse === "string") && + (!("artifacts" in data) || Array.isArray(data.artifacts)) && (!("attachments" in data) || Array.isArray(data.attachments)) && (!("references" in data) || Array.isArray(data.references)) && (!("shouldDisplayContent" in data) || typeof data.shouldDisplayContent === "boolean") && diff --git a/Conversations/IBinaryFile.ts b/Conversations/IBinaryFile.ts new file mode 100644 index 0000000..48fc812 --- /dev/null +++ b/Conversations/IBinaryFile.ts @@ -0,0 +1,5 @@ +export interface IBinaryFile { + base64: string | undefined; + getStoragePath(): string | undefined; + setStoragePath(path: string): void; +} diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 37f1204..4071929 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -150,7 +150,7 @@ export enum Copy { ButtonApprove = "Approve", ButtonReject = "Reject", - ButtonSuggest = "Suggest", + ButtonDiscuss = "Discuss", // Agent file message AttachedFile = `The file {fileName} is attached and its full contents follow below. This is the actual content of the file — read it directly to answer the user. This attachment may be a file the user uploaded to the chat, or a vault file you retrieved with a tool; either way, the content below is authoritative and you do NOT need to read or fetch this file again.`, diff --git a/Enums/FileType.ts b/Enums/FileType.ts index 2e10c2c..ff253c9 100644 --- a/Enums/FileType.ts +++ b/Enums/FileType.ts @@ -151,8 +151,9 @@ export enum FileType { } export function toFileType(fileType: string): FileType { - if (isKnownFileType(fileType)) { - return fileType; + const normalized = fileType.startsWith('.') ? fileType.slice(1) : fileType; + if (isKnownFileType(normalized)) { + return normalized; } return FileType.UNKNOWN; } diff --git a/Enums/Path.ts b/Enums/Path.ts index f124e9a..8d59c88 100644 --- a/Enums/Path.ts +++ b/Enums/Path.ts @@ -3,6 +3,7 @@ export enum Path { VaultkeeperAIDir = "Vaultkeeper AI", Conversations = `${Path.VaultkeeperAIDir}/Conversations`, Attachments = `${Path.Conversations}/Attachments`, + Artifacts = `${Path.Conversations}/Artifacts`, UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`, ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`, Memories = `${Path.VaultkeeperAIDir}/Memories.md` diff --git a/Services/AIServices/AIToolService.ts b/Services/AIServices/AIToolService.ts index 2eee655..88fb3c7 100644 --- a/Services/AIServices/AIToolService.ts +++ b/Services/AIServices/AIToolService.ts @@ -6,7 +6,7 @@ import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse"; import { AIToolCall } from "AIClasses/AIToolCall"; import type { ISearchMatch } from "../../Types/SearchTypes"; import { AbortService } from "../AbortService"; -import { normalizePath, TAbstractFile, TFile } from "obsidian"; +import { arrayBufferToBase64, normalizePath, TAbstractFile, TFile } from "obsidian"; import { Exception } from "Helpers/Exception"; import { Copy } from "Enums/Copy"; import { pathExtname, replaceCopy } from "Helpers/Helpers"; @@ -16,7 +16,7 @@ import type { WebViewerService } from "Services/WebViewerService"; import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload"; import { Attachment } from "Conversations/Attachment"; import { isDocumentMimeType, MimeType } from "Enums/MimeType"; -import { isTextFile, toFileType } from "Enums/FileType"; +import { isBinaryFile, isTextFile, toFileType } from "Enums/FileType"; import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping"; import { StringTools } from "Helpers/StringTools"; import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions"; @@ -36,6 +36,8 @@ import { DeleteVaultFolderArgsSchema, MoveVaultFolderArgsSchema } from "AIClasses/Schemas/AIToolSchemas"; +import { Artifact } from "Conversations/Artifact"; +import { extname } from "path-browserify"; export class AIToolService { @@ -337,23 +339,19 @@ export class AIToolService { count: binaryResults.length }; - return new AIToolResponsePayload(response, attachments); + return new AIToolResponsePayload(response, [], attachments); } private async writeVaultFile(filePath: string, content: string): Promise { - const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content); - if (result instanceof Error) { - return new AIToolResponsePayload({ success: false, error: result.message }); - } - return new AIToolResponsePayload({ success: true }); + return await this.asTrackedAction(filePath, async () => { + return await this.fileSystemService.writeToFilePath(normalizePath(filePath), content); + }); } private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise { - const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent); - if (result instanceof Error) { - return new AIToolResponsePayload({ success: false, error: result.message }); - } - return new AIToolResponsePayload({ success: true }); + return await this.asTrackedAction(filePath, async () => { + return await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent); + }); } private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise { @@ -361,15 +359,19 @@ export class AIToolService { return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" }); } - const results = await Promise.all(filePaths.map(async filePath => { - const result = await this.fileSystemService.deleteFile(filePath); - if (result instanceof Error) { - return { path: filePath, success: false, error: result.message }; - } - return { path: filePath, success: true }; - })); + const results: object[] = []; + const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths); - return new AIToolResponsePayload({ results }); + for (const filePath of filePaths) { + const deleteResult = await this.fileSystemService.deleteFile(filePath); + if (deleteResult instanceof Error) { + results.push({ path: filePath, success: false, error: deleteResult.message }); + continue; + } + results.push({ path: filePath, success: true }); + } + + return new AIToolResponsePayload({ results }, artifacts); } private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise { @@ -401,10 +403,38 @@ export class AIToolService { if (!confirmation) { return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" }); } + + const contents = await this.fileSystemService.listDirectoryContents(path, true); + const filePaths = contents.filter(content => content instanceof TFile).map(file => file.path); + + const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths); const result = await this.fileSystemService.deleteFolder(path); + return result instanceof Error - ? new AIToolResponsePayload({ path: path, success: false, error: result.message }) - : new AIToolResponsePayload({ path: path, success: true }); + ? new AIToolResponsePayload({ path: path, success: false, error: result.message }, artifacts) + : new AIToolResponsePayload({ path: path, success: true }, artifacts); + } + + private async collectDeletionCandidatesArtifacts(filePaths: string[]): Promise { + const artifacts: Artifact[] = []; + + for (const filePath of filePaths) { + const fileType = toFileType(extname(filePath)); + // Anything that isn't a note we will just store as a binary artifact + if (isBinaryFile(fileType) || isDocumentMimeType(FileTypeToMimeType[fileType])) { + const result = await this.fileSystemService.readBinaryFile(filePath); + if (result instanceof ArrayBuffer) { + artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], "", "", arrayBufferToBase64(result))); + } + } else { + const result = await this.fileSystemService.readFilePath(filePath); + if (typeof result === "string") { + artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], result, "")); + } + } + } + + return artifacts; } private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise { @@ -437,7 +467,7 @@ export class AIToolService { return format === "text" ? new AIToolResponsePayload({ content: result }) - : new AIToolResponsePayload({ success: true }, [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]); + : new AIToolResponsePayload({ success: true }, [], [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]); } private async readMemories(): Promise { @@ -460,4 +490,24 @@ export class AIToolService { return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) }); } + + private async asTrackedAction(filePath: string, action: () => Promise): Promise { + let preActionResult = await this.fileSystemService.readFilePath(filePath); + if (preActionResult instanceof Error) { + preActionResult = ""; // The file does not exist yet + } + const actionResult = await action(); + if (actionResult instanceof Error) { + return new AIToolResponsePayload({ success: false, error: actionResult.message }); + } + let postActionResult = actionResult ? await this.fileSystemService.readFile(actionResult) : ""; + if (postActionResult instanceof Error) { + postActionResult = ""; // The file has been deleted + } + + const fileType = toFileType(extname(filePath)); + + return new AIToolResponsePayload({ success: true }, + [new Artifact(filePath, FileTypeToMimeType[fileType], preActionResult, postActionResult)]); + } } \ No newline at end of file diff --git a/Services/AIServices/BaseAgent.ts b/Services/AIServices/BaseAgent.ts index 48468f9..d667d09 100644 --- a/Services/AIServices/BaseAgent.ts +++ b/Services/AIServices/BaseAgent.ts @@ -208,12 +208,18 @@ export abstract class BaseAgent { return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue }; } - protected async performAITool(toolCall: AIToolCall): Promise { + protected async performAITool(toolCall: AIToolCall, callbacks: IChatServiceCallbacks): Promise { const providerResult = await this.ai?.resolveToolCall?.(toolCall) ?? null; if (providerResult !== null) { return providerResult; } - return this.aiToolService.performAITool(toolCall); + const result = await this.aiToolService.performAITool(toolCall); + + for (const artifact of result.payload.artifacts) { + callbacks.onArtifactProduced(artifact); + } + + return result; } private async withToolCallingDisabled(callback: () => Promise): Promise { diff --git a/Services/AIServices/ExecutionAgent.ts b/Services/AIServices/ExecutionAgent.ts index c8d8bfd..da32a0e 100644 --- a/Services/AIServices/ExecutionAgent.ts +++ b/Services/AIServices/ExecutionAgent.ts @@ -73,7 +73,7 @@ export class ExecutionAgent extends BaseAgent { this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`); this.updateThought(toolCall, callbacks); - const functionResponse = await this.performAITool(toolCall); + const functionResponse = await this.performAITool(toolCall, callbacks); this.conversation.addFunctionResponse(functionResponse); return { shouldExit: false }; }); diff --git a/Services/AIServices/MainAgent.ts b/Services/AIServices/MainAgent.ts index 47b36a2..824eb46 100644 --- a/Services/AIServices/MainAgent.ts +++ b/Services/AIServices/MainAgent.ts @@ -79,7 +79,7 @@ export class MainAgent extends BaseAgent { this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`); this.updateThought(toolCall, callbacks); - const functionResponse = await this.performAITool(toolCall); + const functionResponse = await this.performAITool(toolCall, callbacks); conversation.addFunctionResponse(functionResponse); return { shouldExit: false }; }); diff --git a/Services/AIServices/OrchestrationAgent.ts b/Services/AIServices/OrchestrationAgent.ts index e06706a..2cfa782 100644 --- a/Services/AIServices/OrchestrationAgent.ts +++ b/Services/AIServices/OrchestrationAgent.ts @@ -220,7 +220,7 @@ export class OrchestrationAgent extends BaseAgent { isAITool(toolCallName, AITool.ListVaultFiles)) { this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`); this.updateThought(toolCall, callbacks); - const toolResponse = await this.performAITool(toolCall); + const toolResponse = await this.performAITool(toolCall, callbacks); planningConversation.addFunctionResponse(toolResponse); return Promise.resolve({ shouldExit: false }); } diff --git a/Services/AIServices/PlanningAgent.ts b/Services/AIServices/PlanningAgent.ts index e5356f7..1814e9a 100644 --- a/Services/AIServices/PlanningAgent.ts +++ b/Services/AIServices/PlanningAgent.ts @@ -90,7 +90,7 @@ export class PlanningAgent extends BaseAgent { } this.updateThought(toolCall, callbacks); - const functionResponse = await this.performAITool(toolCall); + const functionResponse = await this.performAITool(toolCall, callbacks); conversation.addFunctionResponse(functionResponse); return { shouldExit: false }; }); diff --git a/Services/AIServices/QuickAgent.ts b/Services/AIServices/QuickAgent.ts index cb34281..4dfe073 100644 --- a/Services/AIServices/QuickAgent.ts +++ b/Services/AIServices/QuickAgent.ts @@ -44,6 +44,7 @@ export class QuickAgent extends BaseAgent { onStreamingUpdate: () => {}, onThoughtUpdate: () => {}, onToolCallStarted: () => {}, + onArtifactProduced: () => {}, onPlanningStarted: () => {}, onPlanningFinished: () => {}, onUserQuestion: async () => new Promise(() => {}), diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 620dba6..4329150 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -18,12 +18,14 @@ import type { ExecutionPlan } from "Types/ExecutionPlan"; import type { MainAgent } from "./AIServices/MainAgent"; import type { ChatMode } from "Enums/ChatMode"; import type { PlanApprovalResponse } from "Types/PlanApprovalResponse"; +import type { Artifact } from "Conversations/Artifact"; export interface IChatServiceCallbacks { onSubmit: () => void; onStreamingUpdate: () => void; onThoughtUpdate: (thought: string | null) => void; onToolCallStarted: (toolName: string) => void; + onArtifactProduced: (artifact: Artifact) => void; onPlanningStarted: () => void; onPlanningFinished: () => void; onUserQuestion: (question: string) => Promise; @@ -125,13 +127,12 @@ export class ChatService { } } finally { this.eventService.trigger(Event.DiffClosed); - await this.saveConversation(conversation); + callbacks.onThoughtUpdate(null); + callbacks.onComplete(); if (this.semaphoreHeld) { this.semaphoreHeld = false; this.semaphore.release(); } - callbacks.onThoughtUpdate(null); - callbacks.onComplete(); } } @@ -146,9 +147,6 @@ export class ChatService { } private async saveConversation(conversation: Conversation) { - const result = await this.conversationService.saveConversation(conversation); - if (result instanceof Error) { - new Notice(`Failed to save conversation data for '${conversation.title}'`); - } + await this.conversationService.saveConversation(conversation); } } diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 49b8c2e..a0b6b21 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -8,7 +8,9 @@ import { Attachment } from "Conversations/Attachment"; import { Exception } from "Helpers/Exception"; import type { IAIFileService } from "AIClasses/IAIFileService"; import { Reference } from "Conversations/Reference"; -import { arrayBufferToBase64 } from "obsidian"; +import { Artifact } from "Conversations/Artifact"; +import type { IBinaryFile } from "Conversations/IBinaryFile"; +import { arrayBufferToBase64, Notice } from "obsidian"; import { StringTools } from "Helpers/StringTools"; export class ConversationFileSystemService { @@ -32,9 +34,9 @@ export class ConversationFileSystemService { return `${Path.Conversations}/${conversation.title}.json`; } - public async saveConversation(conversation: Conversation): Promise { + public async saveConversation(conversation: Conversation): Promise { if (this.isDeleted) { - return ""; // Return empty string to indicate silent skip (not an error) + return; } if (!this.currentConversationPath) { @@ -43,21 +45,19 @@ export class ConversationFileSystemService { // can happen if the conversation is deleted during an active request const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true); if (!fileExists) { - return this.currentConversationPath; + return; } } conversation.updated = new Date(); - // Save attachment files and update filePaths + // Save binary files (attachments and artifacts) and update their storage paths for (const content of conversation.contents) { for (const attachment of content.attachments) { - if (!attachment.filePath && attachment.base64) { - const filePath = await this.saveAttachmentFile(attachment); - if (!(filePath instanceof Error)) { - attachment.filePath = filePath.replace(`${Path.Conversations}/`, ''); - } - } + await this.saveBinaryFile(attachment, Path.Attachments); + } + for (const artifact of content.artifacts) { + await this.saveBinaryFile(artifact, Path.Artifacts); } } @@ -73,6 +73,13 @@ export class ConversationFileSystemService { displayContent: content.displayContent, toolCall: content.toolCall, functionResponse: content.functionResponse, + artifacts: content.artifacts.map(artifact => ({ + filePath: artifact.filePath, + mimeType: artifact.mimeType, + originalContent: artifact.originalContent, + updatedContent: artifact.updatedContent, + artifactPath: artifact.artifactPath + })), attachments: content.attachments.map(att => ({ fileName: att.fileName, mimeType: att.mimeType, @@ -90,10 +97,8 @@ export class ConversationFileSystemService { const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false); if (result instanceof Error) { - return result; + new Notice(`Failed to save conversation data for '${conversation.title}'`); } - - return this.currentConversationPath; } public resetCurrentConversation() { @@ -137,6 +142,7 @@ export class ConversationFileSystemService { // Queue garbage collection after AI file deletion this.deletionQueue = this.deletionQueue.then(async () => { await this.garbageCollectAttachments(); + await this.garbageCollectArtifacts(); }); } @@ -205,6 +211,57 @@ export class ConversationFileSystemService { } } + public async garbageCollectArtifacts(): Promise { + try { + // 1. Get all artifact files + const artifactFiles = await this.fileSystemService.listFilesInDirectory( + Path.Artifacts, + false, + true + ); + + if (artifactFiles.length === 0) { + return; + } + + // 2. Build reference count map + const referenceCount = new Map(); + + const conversations = await this.getAllConversations(); + for (const conversation of conversations) { + for (const content of conversation.contents) { + for (const artifact of content.artifacts) { + if (artifact.artifactPath) { + const count = referenceCount.get(artifact.artifactPath) || 0; + referenceCount.set(artifact.artifactPath, count + 1); + } + } + } + } + + // 3. Delete unreferenced files + for (const file of artifactFiles) { + const relativePath = file.path.replace(`${Path.Conversations}/`, ''); + const refCount = referenceCount.get(relativePath) || 0; + + if (refCount === 0) { + const deleteResult = await this.fileSystemService.deleteFile( + file.path, + true, + false + ); + + if (deleteResult instanceof Error) { + Exception.log(deleteResult); + } + } + } + } catch (error) { + Exception.log(error); + return Exception.new(error); + } + } + public async updateConversationTitle(oldPath: string, newTitle: string): Promise { const newPath = `${Path.Conversations}/${newTitle}.json`; @@ -219,30 +276,31 @@ export class ConversationFileSystemService { } } - private async saveAttachmentFile(attachment: Attachment): Promise { - const hash = await StringTools.computeSHA256Hash(attachment.base64); + private async saveBinaryFile(file: IBinaryFile, storageFolder: Path): Promise { + if (file.getStoragePath() || !file.base64) { + return; + } + + const hash = await StringTools.computeSHA256Hash(file.base64); const fileName = `${hash}.bin`; - const filePath = `${Path.Attachments}/${fileName}`; + const filePath = `${storageFolder}/${fileName}`; const exists = await this.fileSystemService.exists(filePath, true); - if (exists) { - return filePath; + if (!exists) { + const arrayBuffer = StringTools.toBuffer(file.base64); + const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true); + + if (result instanceof Error) { + Exception.log(result); + return; + } } - const arrayBuffer = StringTools.toBuffer(attachment.base64); - - const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true); - - if (result instanceof Error) { - Exception.log(result); - return filePath; - } - - return filePath; + file.setStoragePath(filePath.replace(`${Path.Conversations}/`, '')); } - private async loadAttachmentFile(filePath: string): Promise { - const fullPath = `${Path.Conversations}/${filePath}`; + private async loadBinaryFile(storagePath: string): Promise { + const fullPath = `${Path.Conversations}/${storagePath}`; const arrayBuffer = await this.fileSystemService.readBinaryFile(fullPath, true); if (arrayBuffer instanceof Error) { @@ -271,6 +329,7 @@ export class ConversationFileSystemService { const contentPromises = result.contents.map(async content => { const attachments = await this.deserializeAttachments(content.attachments); const references = this.deserializeReferences(content.references); + const artifacts = await this.deserializeArtifacts(content.artifacts); return new ConversationContent({ role: content.role, @@ -279,6 +338,7 @@ export class ConversationFileSystemService { displayContent: content.displayContent, toolCall: content.toolCall, functionResponse: content.functionResponse, + artifacts: artifacts, attachments: attachments, references: references, shouldDisplayContent: content.shouldDisplayContent, @@ -306,7 +366,7 @@ export class ConversationFileSystemService { continue; } - const base64 = await this.loadAttachmentFile(attachmentData.filePath); + const base64 = await this.loadBinaryFile(attachmentData.filePath); if (!base64) { Exception.warn(`Skipping attachment with missing file: ${attachmentData.fileName} (${attachmentData.filePath})`); @@ -327,6 +387,35 @@ export class ConversationFileSystemService { return attachments; } + private async deserializeArtifacts(artifactsData: unknown): Promise { + if (!Array.isArray(artifactsData)) { + return []; + } + + const artifacts: Artifact[] = []; + + for (const artifactData of artifactsData) { + if (!Artifact.isArtifactData(artifactData)) { + continue; + } + + const base64 = artifactData.artifactPath + ? await this.loadBinaryFile(artifactData.artifactPath) + : undefined; + + artifacts.push(new Artifact( + artifactData.filePath, + artifactData.mimeType, + artifactData.originalContent, + artifactData.updatedContent, + base64, + artifactData.artifactPath + )); + } + + return artifacts; + } + private deserializeReferences(referencesData: unknown): Reference[] { if (!Array.isArray(referencesData)) { return []; diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts index 84849a1..59d4c2b 100644 --- a/Services/ConversationNamingService.ts +++ b/Services/ConversationNamingService.ts @@ -55,12 +55,8 @@ export class ConversationNamingService { } conversation.title = validatedName; - const saveResult = await this.conversationService.saveConversation(conversation); + await this.conversationService.saveConversation(conversation); - if (saveResult instanceof Error) { - Exception.throw(saveResult); - } - onNameChanged?.(conversation.title); } catch (error) { if (!AbortService.isAbortError(error)) { diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index 3ecc428..7ca2fc2 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -48,7 +48,7 @@ export class FileSystemService { if (file instanceof TFile) { const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot); if (!arrayBuffer) { - return Exception.new(`Failed to read binary dta for: ${filePath}`); + return Exception.new(`Failed to read binary data for: ${filePath}`); } return arrayBuffer; } diff --git a/Services/VaultService.ts b/Services/VaultService.ts index d8b0770..339b699 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -116,13 +116,15 @@ export class VaultService { public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { filePath = this.sanitiserService.sanitize(filePath); + const fileExtension = pathExtname(filePath); + if (this.isExclusion(filePath, allowAccessToPluginRoot)) { Exception.log(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`); return Exception.new(`Failed to create file, permission denied: ${filePath}`); } - if (isFileType(pathExtname(filePath), FileType.PDF)) { - return Exception.new("Creating PDF files is not supported"); + if (isBinaryFile(pathExtname(fileExtension)) || isDocumentFile(pathExtname(fileExtension))) { + return Exception.new(`Creating ${pathExtname(filePath)} files is not supported`); } const fileName = path.basename(filePath); @@ -134,13 +136,15 @@ export class VaultService { public async modify(file: TFile, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const filePath = this.sanitiserService.sanitize(file.path); + const fileExtension = pathExtname(filePath); + if (this.isExclusion(file.path, allowAccessToPluginRoot)) { Exception.log(`Plugin attempted to modify a file that is in the exclusion list: ${filePath}`); return Exception.new(`File does not exist: ${filePath}`); } - if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) { - return Exception.new("Modifying PDF files is not supported"); + if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) { + return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`); } const currentContent = await this.read(file, allowAccessToPluginRoot); @@ -157,13 +161,15 @@ export class VaultService { public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record) => void, allowAccessToPluginRoot: boolean = false): Promise { const filePath = this.sanitiserService.sanitize(file.path); + const fileExtension = pathExtname(filePath); + if (this.isExclusion(file.path, allowAccessToPluginRoot)) { Exception.log(`Plugin attempted to update frontmatter of a file that is in the exclusion list: ${filePath}`); return Exception.new(`File does not exist: ${filePath}`); } - if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) { - return Exception.new("Modifying PDF files is not supported"); + if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) { + return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`); } try { @@ -183,8 +189,8 @@ export class VaultService { return Exception.new(`File does not exist: ${filePath}`); } - if (isFileType(pathExtname(filePath), FileType.PDF)) { - return Exception.new("Patching PDF files is not supported"); + if (isBinaryFile(pathExtname(filePath))) { + return Exception.new(`Patching ${pathExtname(filePath)} files is not supported`); } if (oldContent.length !== newContent.length) { @@ -236,10 +242,6 @@ export class VaultService { return currentContent; } - if (isFileType(pathExtname(filePath), FileType.PDF)) { - await this.fileManager.trashFile(file); - } - return this.proposeChange(file.name, file.name, currentContent, "", requiresConfirmation, async () => { await this.fileManager.trashFile(file); }); diff --git a/Styles/diff2html_styles.css b/Styles/diff2html_styles.css index 5043d28..6ddb3e7 100644 --- a/Styles/diff2html_styles.css +++ b/Styles/diff2html_styles.css @@ -134,40 +134,48 @@ font-weight: var(--font-semibold); border-radius: var(--button-radius); cursor: pointer; - border: none; - color: var(--text-on-accent); + border-width: var(--size-2-1); + border-style: solid; + background-color: var(--background-primary); } .diff-mobile-controls .diff-mobile-accept { - background-color: color-mix( - in srgb, - var(--color-green) 75%, - var(--background-primary) 25% - ); + color: var(--color-green); + border-color: var(--color-green); } .diff-mobile-controls .diff-mobile-accept:active { - background-color: var(--color-green); -} - -.diff-mobile-controls .diff-mobile-suggest { - background-color: var(--interactive-accent); -} - -.diff-mobile-controls .diff-mobile-suggest:active { - background-color: var(--interactive-accent-hover); -} - -.diff-mobile-controls .diff-mobile-reject { background-color: color-mix( in srgb, - var(--color-red) 75%, - var(--background-primary) 25% + var(--color-green) 25%, + white 10% ); } +.diff-mobile-controls .diff-mobile-discuss { + color: var(--interactive-accent); + border-color: var(--interactive-accent); +} + +.diff-mobile-controls .diff-mobile-discuss:active { + background-color: color-mix( + in srgb, + var(--interactive-accent) 25%, + white 10% + ); +} + +.diff-mobile-controls .diff-mobile-reject { + color: var(--color-red); + border-color: var(--color-red); +} + .diff-mobile-controls .diff-mobile-reject:active { - background-color: var(--color-red); + background-color: color-mix( + in srgb, + var(--color-red) 25%, + white 10% + ); } /* Adjust diff height on mobile to account for buttons */ diff --git a/Styles/plan_approval_styles.css b/Styles/plan_approval_styles.css index 49bb1ba..5d51975 100644 --- a/Styles/plan_approval_styles.css +++ b/Styles/plan_approval_styles.css @@ -43,40 +43,48 @@ font-weight: var(--font-semibold); border-radius: var(--button-radius); cursor: pointer; - border: none; - color: var(--text-on-accent); + border-width: var(--size-2-1); + border-style: solid; + background-color: var(--background-primary); } .plan-approval-mobile-controls .plan-approval-mobile-approve { - background-color: color-mix( - in srgb, - var(--color-green) 75%, - var(--background-primary) 25% - ); + color: var(--color-green); + border-color: var(--color-green); } .plan-approval-mobile-controls .plan-approval-mobile-approve:active { - background-color: var(--color-green); -} - -.plan-approval-mobile-controls .plan-approval-mobile-suggest { - background-color: var(--interactive-accent); -} - -.plan-approval-mobile-controls .plan-approval-mobile-suggest:active { - background-color: var(--interactive-accent-hover); -} - -.plan-approval-mobile-controls .plan-approval-mobile-reject { background-color: color-mix( in srgb, - var(--color-red) 75%, - var(--background-primary) 25% + var(--color-green) 25%, + white 10% ); } +.plan-approval-mobile-controls .plan-approval-mobile-discuss { + color: var(--interactive-accent); + border-color: var(--interactive-accent); +} + +.plan-approval-mobile-controls .plan-approval-mobile-discuss:active { + background-color: color-mix( + in srgb, + var(--interactive-accent) 25%, + white 10% + ); +} + +.plan-approval-mobile-controls .plan-approval-mobile-reject { + color: var(--color-red); + border-color: var(--color-red); +} + .plan-approval-mobile-controls .plan-approval-mobile-reject:active { - background-color: var(--color-red); + background-color: color-mix( + in srgb, + var(--color-red) 25%, + white 10% + ); } /* Adjust plan height on mobile to account for buttons */ diff --git a/Views/DiffView.ts b/Views/DiffView.ts index 1e8c0c0..4423ac6 100644 --- a/Views/DiffView.ts +++ b/Views/DiffView.ts @@ -116,11 +116,11 @@ export class DiffView extends ItemView { }); acceptButton.setAttribute('aria-label', Copy.ButtonApprove); - const suggestButton = container.createEl('button', { - cls: 'diff-mobile-button diff-mobile-suggest', - text: Copy.ButtonSuggest + const discussButton = container.createEl('button', { + cls: 'diff-mobile-button diff-mobile-discuss', + text: Copy.ButtonDiscuss }); - suggestButton.setAttribute('aria-label', Copy.ButtonSuggest); + discussButton.setAttribute('aria-label', Copy.ButtonDiscuss); const rejectButton = container.createEl('button', { cls: 'diff-mobile-button diff-mobile-reject', @@ -133,7 +133,7 @@ export class DiffView extends ItemView { await this.refocusMainView(); }); - this.registerDomEvent(suggestButton, 'click', async () => { + this.registerDomEvent(discussButton, 'click', async () => { await this.refocusMainView(true); }); diff --git a/Views/PlanApprovalView.ts b/Views/PlanApprovalView.ts index e556d8e..af20e4e 100644 --- a/Views/PlanApprovalView.ts +++ b/Views/PlanApprovalView.ts @@ -129,11 +129,11 @@ export class PlanApprovalView extends ItemView { }); approveButton.setAttribute('aria-label', Copy.ButtonApprove); - const suggestButton = container.createEl('button', { - cls: 'plan-approval-mobile-button plan-approval-mobile-suggest', - text: Copy.ButtonSuggest + const discussButton = container.createEl('button', { + cls: 'plan-approval-mobile-button plan-approval-mobile-discuss', + text: Copy.ButtonDiscuss }); - suggestButton.setAttribute('aria-label', Copy.ButtonSuggest); + discussButton.setAttribute('aria-label', Copy.ButtonDiscuss); const rejectButton = container.createEl('button', { cls: 'plan-approval-mobile-button plan-approval-mobile-reject', @@ -146,7 +146,7 @@ export class PlanApprovalView extends ItemView { await this.refocusMainView(); }); - this.registerDomEvent(suggestButton, 'click', async () => { + this.registerDomEvent(discussButton, 'click', async () => { await this.refocusMainView(true); }); diff --git a/__tests__/Conversations/Conversation.test.ts b/__tests__/Conversations/Conversation.test.ts index afd359c..6c441ca 100644 --- a/__tests__/Conversations/Conversation.test.ts +++ b/__tests__/Conversations/Conversation.test.ts @@ -479,6 +479,7 @@ describe('Conversation', () => { AITool.ReadVaultFiles, new AIToolResponsePayload( { results: [{ path: 'image.png', error: 'File does not exist: image.png' }] }, + [], [validAttachment] ), 'tool-123' @@ -527,6 +528,7 @@ describe('Conversation', () => { AITool.ReadVaultFiles, new AIToolResponsePayload( { message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 }, + [], [attachment] ), 'tool-123' @@ -550,6 +552,7 @@ describe('Conversation', () => { AITool.ReadVaultFiles, new AIToolResponsePayload( { message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 }, + [], [docAttachment] ), 'tool-123' @@ -577,6 +580,7 @@ describe('Conversation', () => { AITool.ReadVaultFiles, new AIToolResponsePayload( { results: [{ type: 'md', path: 'notes.md', contents: '# Notes' }], message: 'The contents of the files are included below.' }, + [], [xlsxAttachment, imgAttachment] ), 'tool-123' diff --git a/__tests__/Services/AIControllerService.test.ts b/__tests__/Services/AIControllerService.test.ts index 87a1c12..62dd11d 100644 --- a/__tests__/Services/AIControllerService.test.ts +++ b/__tests__/Services/AIControllerService.test.ts @@ -33,6 +33,7 @@ describe('AIControllerService - Integration Tests', () => { onStreamingUpdate: vi.fn(), onThoughtUpdate: vi.fn(), onToolCallStarted: vi.fn(), + onArtifactProduced: vi.fn(), onPlanningStarted: vi.fn(), onPlanningFinished: vi.fn(), onUserQuestion: vi.fn(), diff --git a/__tests__/Services/AIToolService.test.ts b/__tests__/Services/AIToolService.test.ts index 0da4ca5..3f286a6 100644 --- a/__tests__/Services/AIToolService.test.ts +++ b/__tests__/Services/AIToolService.test.ts @@ -35,6 +35,8 @@ describe('AIToolService - Integration Tests', () => { searchVaultFiles: vi.fn(), listFilesInDirectory: vi.fn(), readFilePath: vi.fn(), + readFile: vi.fn(), + readBinaryFile: vi.fn(), writeToFilePath: vi.fn(), patchFileAtPath: vi.fn(), deleteFile: vi.fn(), diff --git a/__tests__/Services/ConversationFileSystemService.test.ts b/__tests__/Services/ConversationFileSystemService.test.ts index e8e991b..9085c04 100644 --- a/__tests__/Services/ConversationFileSystemService.test.ts +++ b/__tests__/Services/ConversationFileSystemService.test.ts @@ -105,9 +105,9 @@ describe('ConversationFileSystemService - Integration Tests', () => { it('should save conversation with correct structure', async () => { const conversation = createTestConversation('Test Save'); - const path = await service.saveConversation(conversation); + await service.saveConversation(conversation); - expect(path).toBe('Vaultkeeper AI/Conversations/Test Save.json'); + expect(service.getCurrentConversationPath()).toBe('Vaultkeeper AI/Conversations/Test Save.json'); expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledWith( 'Vaultkeeper AI/Conversations/Test Save.json', expect.objectContaining({ @@ -341,10 +341,9 @@ describe('ConversationFileSystemService - Integration Tests', () => { await service.deleteCurrentConversation(); // Try to save the same conversation again (simulates what happens in finally block) - const result = await service.saveConversation(conversation); + await service.saveConversation(conversation); - // Should return empty string (silent skip), not save the file - expect(result).toBe(''); + // Should silently skip, not save the file again expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledTimes(1); // Only the initial save }); }); diff --git a/__tests__/Services/ExecutionAgent.test.ts b/__tests__/Services/ExecutionAgent.test.ts index c18ec6e..2268aa9 100644 --- a/__tests__/Services/ExecutionAgent.test.ts +++ b/__tests__/Services/ExecutionAgent.test.ts @@ -30,6 +30,7 @@ describe('ExecutionAgent - Unit Tests', () => { onStreamingUpdate: vi.fn(), onThoughtUpdate: vi.fn(), onToolCallStarted: vi.fn(), + onArtifactProduced: vi.fn(), onPlanningStarted: vi.fn(), onPlanningFinished: vi.fn(), onUserQuestion: vi.fn().mockResolvedValue('User answer'), diff --git a/__tests__/Services/MultiAgentIntegration.test.ts b/__tests__/Services/MultiAgentIntegration.test.ts index 109d113..33869b7 100644 --- a/__tests__/Services/MultiAgentIntegration.test.ts +++ b/__tests__/Services/MultiAgentIntegration.test.ts @@ -39,6 +39,7 @@ describe('Multi-Agent Integration Tests', () => { onStreamingUpdate: vi.fn(), onThoughtUpdate: vi.fn(), onToolCallStarted: vi.fn(), + onArtifactProduced: vi.fn(), onPlanningStarted: vi.fn(), onPlanningFinished: vi.fn(), onUserQuestion: vi.fn().mockResolvedValue('User answer'), diff --git a/__tests__/Services/PlanningAgent.test.ts b/__tests__/Services/PlanningAgent.test.ts index 756bf5c..981b4e1 100644 --- a/__tests__/Services/PlanningAgent.test.ts +++ b/__tests__/Services/PlanningAgent.test.ts @@ -32,6 +32,7 @@ describe('PlanningAgent - Unit Tests', () => { onStreamingUpdate: vi.fn(), onThoughtUpdate: vi.fn(), onToolCallStarted: vi.fn(), + onArtifactProduced: vi.fn(), onPlanningStarted: vi.fn(), onPlanningFinished: vi.fn(), onUserQuestion: vi.fn().mockResolvedValue('User answer'),