From 10ddb1da28228543ded82ee2205d95907983f42a Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 10 Jul 2026 21:23:44 +0100 Subject: [PATCH] feat: add artifact tracking system for agent file operations Implement comprehensive artifact tracking to record all file modifications made by the AI agent during conversations. Add artifact persistence, garbage collection, and UI updates with new "Discuss" button styling. --- AIClasses/ToolDefinitions/AIToolResponse.ts | 26 +-- .../ToolDefinitions/AIToolResponsePayload.ts | 5 +- Components/ChatWindow.svelte | 27 ++++ Components/DiffControls.svelte | 44 +++-- Components/PlanApprovalControls.svelte | 44 +++-- Conversations/Artifact.ts | 53 ++++++ Conversations/Attachment.ts | 11 +- Conversations/ConversationContent.ts | 7 + Conversations/IBinaryFile.ts | 5 + Enums/Copy.ts | 2 +- Enums/FileType.ts | 5 +- Enums/Path.ts | 1 + Services/AIServices/AIToolService.ts | 98 ++++++++--- Services/AIServices/BaseAgent.ts | 10 +- Services/AIServices/ExecutionAgent.ts | 2 +- Services/AIServices/MainAgent.ts | 2 +- Services/AIServices/OrchestrationAgent.ts | 2 +- Services/AIServices/PlanningAgent.ts | 2 +- Services/AIServices/QuickAgent.ts | 1 + Services/ChatService.ts | 12 +- Services/ConversationFileSystemService.ts | 153 ++++++++++++++---- Services/ConversationNamingService.ts | 6 +- Services/FileSystemService.ts | 2 +- Services/VaultService.ts | 26 +-- Styles/diff2html_styles.css | 52 +++--- Styles/plan_approval_styles.css | 52 +++--- Views/DiffView.ts | 10 +- Views/PlanApprovalView.ts | 10 +- __tests__/Conversations/Conversation.test.ts | 4 + .../Services/AIControllerService.test.ts | 1 + __tests__/Services/AIToolService.test.ts | 2 + .../ConversationFileSystemService.test.ts | 9 +- __tests__/Services/ExecutionAgent.test.ts | 1 + .../Services/MultiAgentIntegration.test.ts | 1 + __tests__/Services/PlanningAgent.test.ts | 1 + 35 files changed, 497 insertions(+), 192 deletions(-) create mode 100644 Conversations/Artifact.ts create mode 100644 Conversations/IBinaryFile.ts 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'),