From 0812e91c37e74a29ebc61739d215e4d4c3fe52f2 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Sun, 9 Nov 2025 13:57:47 +0000 Subject: [PATCH] fix: prevent empty messages and assistant-to-assistant structure - Filter out messages with empty content in Claude, Gemini, and OpenAI - Insert hidden "Continue" message when last message is from assistant - Remove function call JSON from assistant message content - Hide empty messages in ChatArea UI - Add GitHub link and about content to help modal - Add comprehensive tests for message handling edge cases --- AIClasses/Claude/Claude.ts | 3 +- AIClasses/Gemini/Gemini.ts | 5 +- AIClasses/OpenAI/OpenAI.ts | 1 + Components/ChatArea.svelte | 20 +- Enums/Copy.ts | 7 +- Modals/HelpModalSvelte.svelte | 30 ++ Services/ChatService.ts | 99 +++++- __tests__/AIClasses/Gemini.test.ts | 7 +- __tests__/Services/ChatService.test.ts | 420 +++++++++++++++++++++++++ 9 files changed, 568 insertions(+), 24 deletions(-) diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index a091895..ef6ae84 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -228,7 +228,8 @@ export class Claude implements IAIClass { role: content.role, content: contentBlocks }; - }); + }) + .filter(message => message.content.length > 0); } private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 49c1ac0..905160d 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -210,9 +210,10 @@ export class Gemini implements IAIClass { return { role: content.role === Role.User ? Role.User : Role.Model, - parts: parts.length > 0 ? parts : [{ text: "" }] + parts: parts }; - }); + }) + .filter(message => message.parts.length > 0); } private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 03d6864..16052c3 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -126,6 +126,7 @@ export class OpenAI implements IAIClass { content: contentToExtract }; }) + .filter(message => message.content !== "" || message.tool_calls || message.tool_call_id) ]; const tools = this.mapFunctionDefinitions( diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 4331616..8bdf10a 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -175,13 +175,17 @@
{#each messages as message, index} - {#if !message.isFunctionCallResponse && message.content} -
-
- {#if message.role === Role.User} + {#if !message.isFunctionCallResponse && message.content.trim() !== ""} + {#if message.role === Role.User} +
+
- {:else} - {@const messageId = message.timestamp.getTime().toString()} +
+
+ {:else} + {@const messageId = message.timestamp.getTime().toString()} +
+
{#if currentStreamingMessageId === messageId}
@@ -189,9 +193,9 @@ {@html getStaticHTML(message)} {/if}
- {/if} +
-
+ {/if} {/if} {/each} diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 7eb2a03..33114cb 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -58,7 +58,9 @@ export enum Copy { // Help Modal Copy HelpModalAboutTitle = "About", - HelpModalAboutContent = "", + HelpModalAboutContent = `### About AI Agent + +**AI Agent** brings the power of Claude, Gemini, and OpenAI directly into your Obsidian vault with intelligent note management capabilities.`, HelpModalGuideTitle = "Plugin Guide", HelpModalGuideContent = "", @@ -69,6 +71,9 @@ export enum Copy { HelpModalPrivacyTitle = "Privacy", HelpModalPrivacyContent = "", + // SVG Icons + GitHubIconPath = "M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z", + // Example Content EXAMPLE_USER_INSTRUCTION = `### TL;DR diff --git a/Modals/HelpModalSvelte.svelte b/Modals/HelpModalSvelte.svelte index 72105a9..920e72f 100644 --- a/Modals/HelpModalSvelte.svelte +++ b/Modals/HelpModalSvelte.svelte @@ -148,6 +148,36 @@ {#if content !== ""}
{@html content} + {#if selectedTopic === 1} +
+

Links

+ +
+

Created with ❤️ for the Obsidian community

+ {/if}
{/if}
diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 422f380..fb09487 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -90,6 +90,7 @@ export class ChatService { )); } + this.ensureCorrectConversationStructure(conversation); response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); } } finally { @@ -141,6 +142,26 @@ export class ChatService { this.statusBarService.animateTokens(inputTokens, outputTokens); } + private ensureCorrectConversationStructure(conversation: Conversation) { + // Check if the last message is from the assistant to prevent assistant-to-assistant structure + // This can happen when the assistant's last message had no function call and the user sends a new request + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + // Insert a hidden "Continue" message to maintain proper conversation structure + conversation.contents.push(new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true // isFunctionCallResponse = true (hides from UI) + )); + } + } + } + private async streamRequestResponse( conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks ): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> { @@ -175,18 +196,21 @@ export class ChatService { if (chunk.content) { accumulatedContent += chunk.content; conversation.setMostRecentContent(accumulatedContent); - callbacks.onThoughtUpdate(null); + if (accumulatedContent.trim() !== "") { + callbacks.onThoughtUpdate(null); + } } if (chunk.isComplete) { - // No content and no function call - remove empty message - if (accumulatedContent.trim() == "" && !capturedFunctionCall) { - conversation.contents.pop(); - } + const sanitizedContent = this.sanitizeFunctionCallContent(accumulatedContent, capturedFunctionCall); - conversation.setMostRecentContent(accumulatedContent); - if (capturedFunctionCall) { - conversation.setMostRecentFunctionCall(capturedFunctionCall?.toConversationString()); + if (sanitizedContent.trim() === "" && !capturedFunctionCall) { + conversation.contents.pop(); + } else { + conversation.setMostRecentContent(sanitizedContent); + if (capturedFunctionCall) { + conversation.setMostRecentFunctionCall(capturedFunctionCall?.toConversationString()); + } } } callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); @@ -196,4 +220,63 @@ export class ChatService { return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue }; } + + // handle the rare event where a function call is also included in content (gemini sometimes does this) + private sanitizeFunctionCallContent(content: string, functionCall: AIFunctionCall | null): string { + // Early returns for simple cases + if (!functionCall || !content.trim()) { + return content; + } + + // If content has no JSON-like characters, return as-is + if (!content.includes('{') || !content.includes('}')) { + return content; + } + + const functionCallString = functionCall.toConversationString(); + let sanitized = content; + + // Step 1: Remove markdown code blocks that might contain the function call + // Pattern matches ```json\n...\n``` or ```\n...\n``` + sanitized = sanitized.replace(/```(?:json)?\s*\n?([\s\S]*?)\n?```/g, (match, codeContent) => { + // If the code block contains our function call, remove it entirely + if (codeContent.trim() === functionCallString.trim()) { + return ''; + } + // Otherwise keep the code block + return match; + }); + + // Step 2: Remove exact JSON match (handles compact JSON) + sanitized = sanitized.replace(functionCallString, '').trim(); + + // Step 3: Handle pretty-printed variations by normalizing both strings + try { + const functionCallObj = JSON.parse(functionCallString); + const normalizedTarget = JSON.stringify(functionCallObj); + + // Find and remove any JSON that matches when normalized + // This regex finds JSON objects/arrays in the text + const jsonPattern = /\{(?:[^{}]|(?:\{(?:[^{}]|(?:\{[^{}]*\}))*\}))*\}|\[(?:[^\[\]]|(?:\[(?:[^\[\]]|(?:\[[^\[\]]*\]))*\]))*\]/g; + + sanitized = sanitized.replace(jsonPattern, (match) => { + try { + const parsedMatch = JSON.parse(match); + const normalizedMatch = JSON.stringify(parsedMatch); + // Remove if it matches our function call when normalized + return normalizedMatch === normalizedTarget ? '' : match; + } catch { + // If it's not valid JSON, keep it + return match; + } + }); + } catch { + // If function call string isn't valid JSON, we've done what we can + } + + // Step 4: Clean up multiple consecutive whitespace/newlines left by removals + sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim(); + + return sanitized; + } } diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index eabfc94..b0a2e73 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -310,7 +310,7 @@ describe('Gemini', () => { describe('Message Format Conversion', () => { it('should convert roles to User/Model', async () => { const conversation = new Conversation(); - conversation.contents.push(new ConversationContent(Role.User, 'Hello')); + conversation.contents.push(new ConversationContent(Role.User, 'Hello', 'Hello')); conversation.contents.push(new ConversationContent(Role.Assistant, 'Hi there')); mockStreamingService.streamRequest.mockImplementation(async function* () { @@ -416,9 +416,8 @@ describe('Gemini', () => { const result = (gemini as any).extractContents([invalidContent]); - // Should result in empty parts (no text, no function call) - expect(result).toHaveLength(1); - expect(result[0].parts).toEqual([{ text: '' }]); + // Should be filtered out since it has no valid parts (no text, invalid function call) + expect(result).toHaveLength(0); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); diff --git a/__tests__/Services/ChatService.test.ts b/__tests__/Services/ChatService.test.ts index 7ad0602..c5ed6fe 100644 --- a/__tests__/Services/ChatService.test.ts +++ b/__tests__/Services/ChatService.test.ts @@ -5,6 +5,7 @@ import { Services } from '../../Services/Services'; import { Conversation } from '../../Conversations/Conversation'; import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; +import { AIFunctionCall } from '../../AIClasses/AIFunctionCall'; /** * INTEGRATION TESTS - Simplified @@ -207,4 +208,423 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { expect(newService.onNameChanged).toBeUndefined(); }); }); + + describe('assistant-to-assistant message prevention', () => { + it('should insert Continue message when last message is from assistant', () => { + const conversation = new Conversation(); + // Add an initial user message + conversation.contents.push(new ConversationContent(Role.User, 'First message')); + // Add an assistant response + conversation.contents.push(new ConversationContent(Role.Assistant, 'Assistant response')); + + // Verify the last message is from assistant + expect(conversation.contents[conversation.contents.length - 1].role).toBe(Role.Assistant); + expect(conversation.contents.length).toBe(2); + + // Now we need to simulate what happens in submit() when a new user message is added + // Check if the last message is from the assistant (like the fix does) + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + conversation.contents.push(new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true + )); + } + } + + // Verify Continue message was inserted + expect(conversation.contents.length).toBe(3); + const continueMessage = conversation.contents[2]; + expect(continueMessage.role).toBe(Role.User); + expect(continueMessage.content).toBe('Continue'); + expect(continueMessage.isFunctionCallResponse).toBe(true); + }); + + it('should NOT insert Continue message when last message is from user', () => { + const conversation = new Conversation(); + // Add a user message + conversation.contents.push(new ConversationContent(Role.User, 'User message')); + + // Verify the last message is from user + expect(conversation.contents[conversation.contents.length - 1].role).toBe(Role.User); + expect(conversation.contents.length).toBe(1); + + // Simulate the check from submit() + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + conversation.contents.push(new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true + )); + } + } + + // Verify NO Continue message was inserted + expect(conversation.contents.length).toBe(1); + }); + + it('should NOT insert Continue message for empty conversation', () => { + const conversation = new Conversation(); + + // Verify empty + expect(conversation.contents.length).toBe(0); + + // Simulate the check from submit() + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + conversation.contents.push(new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true + )); + } + } + + // Verify still empty + expect(conversation.contents.length).toBe(0); + }); + + it('should maintain proper structure after Continue message insertion', () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent(Role.User, 'First')); + conversation.contents.push(new ConversationContent(Role.Assistant, 'Response')); + + // Simulate the fix + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + conversation.contents.push(new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true + )); + } + } + + // Add the actual user message + conversation.contents.push(new ConversationContent(Role.User, 'Second message')); + + // Verify the structure: User -> Assistant -> User(Continue) -> User + expect(conversation.contents.length).toBe(4); + expect(conversation.contents[0].role).toBe(Role.User); + expect(conversation.contents[0].content).toBe('First'); + expect(conversation.contents[1].role).toBe(Role.Assistant); + expect(conversation.contents[1].content).toBe('Response'); + expect(conversation.contents[2].role).toBe(Role.User); + expect(conversation.contents[2].content).toBe('Continue'); + expect(conversation.contents[2].isFunctionCallResponse).toBe(true); + expect(conversation.contents[3].role).toBe(Role.User); + expect(conversation.contents[3].content).toBe('Second message'); + }); + + it('should work with function call responses in conversation', () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent(Role.User, 'Request')); + conversation.contents.push(new ConversationContent(Role.Assistant, 'Making a function call', '', '{"name":"test"}', new Date(), true)); + // Function response (already User role with isFunctionCallResponse) + conversation.contents.push(new ConversationContent(Role.User, 'Function result', 'Function result', '', new Date(), false, true, 'tool-1')); + // Assistant processes the function response + conversation.contents.push(new ConversationContent(Role.Assistant, 'Final response')); + + // Now the last message is Assistant, so Continue should be inserted + expect(conversation.contents[conversation.contents.length - 1].role).toBe(Role.Assistant); + + // Simulate the fix + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + conversation.contents.push(new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true + )); + } + } + + // Verify Continue was inserted + expect(conversation.contents.length).toBe(5); + expect(conversation.contents[4].content).toBe('Continue'); + expect(conversation.contents[4].isFunctionCallResponse).toBe(true); + }); + }); + + describe('sanitizeFunctionCallContent (private method tests via reflection)', () => { + // Access private method for testing + const getSanitizeMethod = (service: ChatService) => { + return (service as any).sanitizeFunctionCallContent.bind(service); + }; + + it('should return content unchanged when no function call is provided', () => { + const sanitize = getSanitizeMethod(service); + const content = 'This is regular content with {"some": "json"}'; + + const result = sanitize(content, null); + + expect(result).toBe(content); + }); + + it('should return content unchanged when content is empty', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('test_function', { arg1: 'value1' }); + + const result = sanitize('', functionCall); + + expect(result).toBe(''); + }); + + it('should return content unchanged when content is only whitespace', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('test_function', { arg1: 'value1' }); + + const result = sanitize(' \n\t ', functionCall); + + expect(result).toBe(' \n\t '); + }); + + it('should remove exact function call JSON from content', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('write_file', { + file_path: 'test.md', + content: 'Hello world' + }); + const functionCallString = functionCall.toConversationString(); + const content = `Here is the function call: ${functionCallString}`; + + const result = sanitize(content, functionCall); + + expect(result).toBe('Here is the function call:'); + }); + + it('should remove pretty-printed function call JSON (2 spaces)', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('search_files', { + query: 'test query', + limit: 10 + }); + + const prettyPrinted = JSON.stringify(JSON.parse(functionCall.toConversationString()), null, 2); + const content = `Function call:\n${prettyPrinted}\nEnd of call`; + + const result = sanitize(content, functionCall); + + expect(result).toBe('Function call:\n\nEnd of call'); + }); + + it('should remove pretty-printed function call JSON (4 spaces)', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('read_file', { + file_path: 'example.ts' + }); + + const prettyPrinted = JSON.stringify(JSON.parse(functionCall.toConversationString()), null, 4); + const content = prettyPrinted; + + const result = sanitize(content, functionCall); + + expect(result).toBe(''); + }); + + it('should preserve legitimate JSON content when no function call exists', () => { + const sanitize = getSanitizeMethod(service); + const legitimateJson = JSON.stringify({ + user: 'test', + data: { nested: 'value' } + }, null, 2); + const content = `Here is your data:\n${legitimateJson}`; + + const result = sanitize(content, null); + + expect(result).toBe(content); + }); + + it('should only remove function call JSON, preserving other content', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('delete_file', { + file_path: 'old.txt' + }); + const functionCallString = functionCall.toConversationString(); + const content = `Processing your request...\n${functionCallString}\nOperation completed successfully.`; + + const result = sanitize(content, functionCall); + + expect(result).toBe('Processing your request...\n\nOperation completed successfully.'); + }); + + it('should handle function calls with special characters in arguments', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('write_file', { + file_path: 'path/to/file.ts', + content: 'const regex = /test.*pattern/g;\nfunction() { return "value"; }' + }); + const functionCallString = functionCall.toConversationString(); + const content = functionCallString; + + const result = sanitize(content, functionCall); + + expect(result).toBe(''); + }); + + it('should handle function calls with nested objects', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('complex_function', { + config: { + nested: { + deeply: { + value: 'test' + } + } + }, + array: [1, 2, 3] + }); + const functionCallString = functionCall.toConversationString(); + const content = `Before\n${functionCallString}\nAfter`; + + const result = sanitize(content, functionCall); + + expect(result).toBe('Before\n\nAfter'); + }); + + it('should handle function calls with toolId', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('test_function', { arg: 'value' }, 'tool-123'); + const functionCallString = functionCall.toConversationString(); + const content = functionCallString; + + const result = sanitize(content, functionCall); + + expect(result).toBe(''); + }); + + it('should not remove similar but different JSON structures', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('write_file', { + file_path: 'test.md' + }); + + // Similar but different structure - should not be removed + const differentJson = JSON.stringify({ + functionCall: { + name: 'write_file', + args: { file_path: 'different.md' } + } + }); + const content = `Here is a different call: ${differentJson}`; + + const result = sanitize(content, functionCall); + + // Should keep the different JSON since it doesn't match exactly + expect(result).toContain('different.md'); + }); + + it('should handle multiple whitespace variations in pretty-printed JSON', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('test', { key: 'value' }); + + // Content with irregular whitespace + const content = `{ + "functionCall": { + "name": "test", + "args": { + "key": "value" + } + } +}`; + + const result = sanitize(content, functionCall); + + expect(result).toBe(''); + }); + + it('should preserve natural language content when function call exists', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('write_vault_file', { + content: 'Character sheet content', + file_name: 'Walker.md' + }); + const content = 'I have created a note for Omaz and will now create a note for walker'; + + const result = sanitize(content, functionCall); + + // Natural language content should be preserved - it doesn't look like JSON + expect(result).toBe(content); + }); + + it('should preserve content that does not start with {', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('search_vault_files', { + search_terms: ['Walker'] + }); + const content = 'Searching for details about Walker in Campaign 2 folder.'; + + const result = sanitize(content, functionCall); + + // Content doesn't look like JSON, should be preserved + expect(result).toBe(content); + }); + + it('should preserve content that does not end with }', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('test', { key: 'value' }); + const content = '{ "incomplete": "json"'; + + const result = sanitize(content, functionCall); + + // Malformed JSON should be preserved + expect(result).toBe(content); + }); + + it('should preserve content with mixed text and JSON-like structures', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('test', { key: 'value' }); + const content = 'Here is some info: { "data": "example" } and more text'; + + const result = sanitize(content, functionCall); + + // Mixed content should be preserved - doesn't match the exact JSON pattern + expect(result).toBe(content); + }); + + it('should only sanitize when content is EXACTLY matching JSON structure', () => { + const sanitize = getSanitizeMethod(service); + const functionCall = new AIFunctionCall('write_file', { + file_path: 'test.md', + content: 'Hello' + }); + const functionCallString = functionCall.toConversationString(); + + // Only exact matches should be removed + const exactMatch = functionCallString; + const withPrefix = `Creating file: ${functionCallString}`; + const withSuffix = `${functionCallString} - done`; + + expect(sanitize(exactMatch, functionCall)).toBe(''); + expect(sanitize(withPrefix, functionCall)).toBe('Creating file:'); + expect(sanitize(withSuffix, functionCall)).toBe('- done'); + }); + }); });