diff --git a/AIClasses/AIFunctionCall.ts b/AIClasses/AIFunctionCall.ts index 7081337..52325c4 100644 --- a/AIClasses/AIFunctionCall.ts +++ b/AIClasses/AIFunctionCall.ts @@ -5,11 +5,13 @@ export class AIFunctionCall { public readonly name: AIFunction; public readonly arguments: Record; public readonly toolId?: string; + public readonly thoughtSignature?: string; - constructor(name: AIFunction, args: Record, toolId?: string) { + constructor(name: AIFunction, args: Record, toolId?: string, thoughtSignature?: string) { this.name = name; this.arguments = args; this.toolId = toolId; + this.thoughtSignature = thoughtSignature; } public toConversationString() { @@ -17,7 +19,8 @@ export class AIFunctionCall { functionCall: { name: this.name, args: this.arguments, - id: this.toolId + id: this.toolId, + thoughtSignature: this.thoughtSignature } }); } diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 72c9cf9..bdb9cbf 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -44,7 +44,7 @@ export abstract class BaseAIClass implements IAIClass { ): AsyncGenerator; protected abstract parseStreamChunk(chunk: string): IStreamChunk; - protected abstract extractContents(conversationContent: ConversationContent[]): object; + protected abstract extractContents(conversationContent: ConversationContent[]): unknown; protected abstract mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object; protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] { @@ -98,7 +98,7 @@ export abstract class BaseAIClass implements IAIClass { throw new ApiError({ type: errorType || ApiErrorType.SERVER_ERROR, message: code ? `${message} (${code})` : message, - userMessage: "Service error. Retrying...", + userMessage: "Service error.", isRetryable: true }); } diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index fb75567..a22193d 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -106,7 +106,8 @@ export class Claude extends BaseAIClass { functionCall = new AIFunctionCall( aiFunctionFromString(this.accumulatedFunctionName), args as Record, - this.accumulatedFunctionId || undefined + this.accumulatedFunctionId || undefined, + undefined // thoughtSignature not used by Claude ); } catch (error) { Exception.log(error); diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 8bfc0c1..cc45b2b 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -16,6 +16,7 @@ export class Gemini extends BaseAIClass { private accumulatedFunctionName: string | null = null; private accumulatedFunctionArgs: Record = {}; + private accumulatedThoughtSignature: string | null = null; public constructor() { super(AIProvider.Gemini); @@ -29,6 +30,7 @@ export class Gemini extends BaseAIClass { this.accumulatedFunctionName = null; this.accumulatedFunctionArgs = {}; + this.accumulatedThoughtSignature = null; const contents = this.extractContents(conversation.contents); @@ -112,6 +114,11 @@ export class Gemini extends BaseAIClass { ...part.functionCall.args }; } + + // Accumulate thought signature (sibling property on Part) + if (part.thoughtSignature) { + this.accumulatedThoughtSignature = part.thoughtSignature; + } break; // Only handle first function call per chunk } } @@ -126,7 +133,9 @@ export class Gemini extends BaseAIClass { if (isComplete && this.accumulatedFunctionName) { functionCall = new AIFunctionCall( aiFunctionFromString(this.accumulatedFunctionName), - this.accumulatedFunctionArgs as Record + this.accumulatedFunctionArgs as Record, + undefined, // toolId not used by Gemini + this.accumulatedThoughtSignature || undefined ); } @@ -147,10 +156,47 @@ export class Gemini extends BaseAIClass { const parts: Part[] = []; const contentToExtract = this.getContentToExtract(content); - if (contentToExtract.trim() !== "") { - if (content.isFunctionCallResponse) { - const parsedContent = this.parseFunctionResponse(contentToExtract); - if (parsedContent && parsedContent.functionResponse) { + // Add text content if not a function call response + if (contentToExtract.trim() !== "" && !content.isFunctionCallResponse) { + parts.push({ text: contentToExtract }); + } + + // Add function call if present + if (content.isFunctionCall && content.functionCall.trim() !== "") { + const parsedContent = this.parseFunctionCall(content.functionCall); + + if (parsedContent) { + if (content.thoughtSignature && content.thoughtSignature.trim() !== "") { + // Has signature - use proper function call format + const part: Part = { + functionCall: { + name: parsedContent.functionCall.name, + args: parsedContent.functionCall.args + }, + thoughtSignature: content.thoughtSignature + }; + parts.push(part); + } else { + // No signature (cross-provider scenario) - use legacy text format + parts.push({ + text: this.convertFunctionCallToText(parsedContent) + }); + } + } else if (contentToExtract.trim() === "") { + // Fall back to treating as text + parts.push({ + text: "Error parsing function call" + }); + } + } + + // Add function response if present + if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { + const parsedContent = this.parseFunctionResponse(contentToExtract); + + if (parsedContent) { + if (parsedContent.id && parsedContent.id.trim() !== "") { + // Has ID - use proper function response format parts.push({ functionResponse: { name: parsedContent.functionResponse.name, @@ -158,21 +204,15 @@ export class Gemini extends BaseAIClass { } }); } else { - parts.push({ text: contentToExtract }); + // No ID (cross-provider scenario) - use legacy text format + parts.push({ + text: this.convertFunctionResponseToText(parsedContent) + }); } } else { - parts.push({ text: contentToExtract }); - } - } - - if (content.isFunctionCall && content.functionCall.trim() !== "") { - const parsedContent = this.parseFunctionCall(content.functionCall); - if (parsedContent && parsedContent.functionCall) { + // Fall back to text content parts.push({ - functionCall: { - name: parsedContent.functionCall.name, - args: parsedContent.functionCall.args - } + text: contentToExtract }); } } @@ -192,4 +232,19 @@ export class Gemini extends BaseAIClass { parameters: functionDefinition.parameters as FunctionDeclaration['parameters'] })); } + + /* + If a conversation used another provider it may not have thought signatures required by Gemini 3. + Instead provide the function call and response as plain text to preserve context without breaking. + */ + + private convertFunctionCallToText(parsedContent: import("AIClasses/Schemas/AIFunctionTypes").StoredFunctionCall): string { + const inputJson = JSON.stringify(parsedContent.functionCall.args); + return `[Legacy Tool Call] ${parsedContent.functionCall.name}\nInput: ${inputJson}`; + } + + private convertFunctionResponseToText(parsedContent: import("AIClasses/Schemas/AIFunctionTypes").StoredFunctionResponse): string { + const resultJson = JSON.stringify(parsedContent.functionResponse.response); + return `[Legacy Tool Result] ${parsedContent.functionResponse.name}\nResult: ${resultJson}`; + } } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 5d2d76d..667b6b2 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -6,7 +6,7 @@ import { AIProvider, AIProviderURL, toProviderModel } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { fromString as aiFunctionFromString } from "Enums/AIFunction"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; -import type { ResponseEvent, ResponseOutputTextDelta, ResponseFunctionCallArgumentsDone, ResponseDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIFunctionTool } from "./OpenAITypes"; +import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIFunctionTool, ResponsesAPIInput } from "./OpenAITypes"; import { Exception } from "Helpers/Exception"; import { ApiErrorType } from "Types/ApiError"; @@ -109,17 +109,33 @@ export class OpenAI extends BaseAIClass { } case "response.function_call_arguments.done": { - // Complete function call received - const doneEvent = event as ResponseFunctionCallArgumentsDone; - const toolCall = doneEvent.call; + // Function call arguments streaming - we can ignore these + // The complete function call info comes in response.output_item.done + break; + } - if (toolCall.type === "function" && toolCall.function) { + case "response.completed": + case "response.done": { + // Response completed + isComplete = true; + break; + } + + case "response.output_item.done": { + // Complete output item received - this includes function calls with name + const itemDoneEvent = event as ResponseOutputItemDone; + + // Check if this is a function call + if (itemDoneEvent.item.type === "function_call" && + itemDoneEvent.item.name && + itemDoneEvent.item.arguments) { try { - const args = JSON.parse(toolCall.function.arguments) as Record; + const args = JSON.parse(itemDoneEvent.item.arguments) as Record; functionCall = new AIFunctionCall( - aiFunctionFromString(toolCall.function.name), + aiFunctionFromString(itemDoneEvent.item.name), args as Record, - toolCall.id + itemDoneEvent.item.call_id || itemDoneEvent.item_id, + undefined // thoughtSignature not used by OpenAI ); // When we receive a function call, we should continue the conversation shouldContinue = true; @@ -130,30 +146,11 @@ export class OpenAI extends BaseAIClass { break; } - case "response.completed": - case "response.done": { - // Response completed - isComplete = true; - const doneEvent = event as ResponseDone; - - // Check if the response contains tool calls that should trigger continuation - if (doneEvent.response?.output) { - for (const outputItem of doneEvent.response.output) { - if (outputItem.tool_calls && outputItem.tool_calls.length > 0) { - shouldContinue = true; - break; - } - } - } - break; - } - case "response.created": case "response.in_progress": case "response.content_part.added": case "response.content_part.done": case "response.output_item.added": - case "response.output_item.done": case "response.output_text.done": case "response.web_search_call.in_progress": case "response.web_search_call.searching": @@ -179,60 +176,73 @@ export class OpenAI extends BaseAIClass { } } - protected extractContents(conversationContent: ConversationContent[]) { - return this.filterConversationContents(conversationContent) - .map(content => { - const contentToExtract = this.getContentToExtract(content); - // Handle function call - if (content.isFunctionCall && content.functionCall.trim() !== "") { - const parsedContent = this.parseFunctionCall(content.functionCall); - if (parsedContent) { - return { - role: content.role, - content: contentToExtract.trim() !== "" ? contentToExtract : null, - tool_calls: [ - { - id: parsedContent.functionCall.id, - type: "function", - function: { - name: parsedContent.functionCall.name, - arguments: JSON.stringify(parsedContent.functionCall.args) - } - } - ] - }; - } else { - return { // Fall back to regular message - role: content.role, - content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call" - }; - } - } + protected extractContents(conversationContent: ConversationContent[]): ResponsesAPIInput[] { + const results: ResponsesAPIInput[] = []; - // Handle function response - if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { - const parsedContent = this.parseFunctionResponse(contentToExtract); - if (parsedContent) { - return { - role: "tool", - tool_call_id: parsedContent.id, - content: JSON.stringify(parsedContent.functionResponse.response) - }; - } else { - return { // Fall back to regular message - role: content.role, - content: contentToExtract - }; - } - } + for (const content of this.filterConversationContents(conversationContent)) { + const contentToExtract = this.getContentToExtract(content); - // Regular text message - return { - role: content.role, + // Case 1: Assistant message with function call + if (content.isFunctionCall && content.functionCall.trim() !== "") { + const parsedContent = this.parseFunctionCall(content.functionCall); + + if (parsedContent) { + // Add assistant text message if present + const messageContent = contentToExtract.trim(); + if (messageContent !== "") { + results.push({ + role: content.role as "user" | "assistant", + content: messageContent + }); + } + + // Add function call as separate input item + results.push({ + type: "function_call", + call_id: parsedContent.functionCall.id, + name: parsedContent.functionCall.name, + arguments: JSON.stringify(parsedContent.functionCall.args) + }); + } else { + // Fall back to regular message if parsing fails + results.push({ + role: content.role as "user" | "assistant", + content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call" + }); + } + continue; + } + + // Case 2: Function call response + if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { + const parsedContent = this.parseFunctionResponse(contentToExtract); + + if (parsedContent) { + results.push({ + type: "function_call_output", + call_id: parsedContent.id, + output: JSON.stringify(parsedContent.functionResponse.response) + }); + } else { + // Fall back to regular user message if parsing fails + results.push({ + role: content.role as "user" | "assistant", + content: contentToExtract + }); + } + continue; + } + + // Case 3: Regular text message (user or assistant) + if (contentToExtract.trim() !== "") { + results.push({ + role: content.role as "user" | "assistant", content: contentToExtract - }; - }) - .filter(message => message.content !== "" || message.tool_calls || message.tool_call_id); + }); + } + } + + return results; } protected mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): OpenAIFunctionTool[] { diff --git a/AIClasses/OpenAI/OpenAITypes.ts b/AIClasses/OpenAI/OpenAITypes.ts index 43496d0..b390fa7 100644 --- a/AIClasses/OpenAI/OpenAITypes.ts +++ b/AIClasses/OpenAI/OpenAITypes.ts @@ -14,13 +14,23 @@ export interface ResponseOutputTextDelta extends ResponseEvent { export interface ResponseFunctionCallArgumentsDone extends ResponseEvent { type: "response.function_call_arguments.done"; - call: { + item_id: string; + name: string; + output_index: number; + arguments: string; + sequence_number: number; +} + +export interface ResponseOutputItemDone extends ResponseEvent { + type: "response.output_item.done"; + item_id: string; + output_index: number; + item: { id: string; - type: "function"; - function: { - name: string; - arguments: string; - }; + type: string; + name?: string; + call_id?: string; + arguments?: string; }; } @@ -30,16 +40,12 @@ export interface ResponseDone extends ResponseEvent { id: string; status: string; output: Array<{ - role: string; - content?: string; - tool_calls?: Array<{ - id: string; - type: string; - function: { - name: string; - arguments: string; - }; - }>; + type?: string; // "message" | "function_call" | etc. + role?: string; // For message items + content?: string; // For message items + name?: string; // For function_call items + call_id?: string; // For function_call items + arguments?: string; // For function_call items }>; output_text?: string; usage?: { @@ -72,4 +78,36 @@ export interface OpenAIFunctionTool { name: string; description: string; parameters: IAIFunctionDefinition["parameters"]; -} \ No newline at end of file +} + +/** + * Responses API Input Item Types + * These are the formats that can appear in the input array + */ + +// Regular user/assistant message +export interface ResponsesAPIMessageInput { + role: "user" | "assistant"; + content: string; +} + +// Function call item (reconstructed from storage or appended from response.output) +export interface ResponsesAPIFunctionCallInput { + type: "function_call"; + call_id: string; + name: string; + arguments: string; // JSON string +} + +// Function call output (result of executing a function) +export interface ResponsesAPIFunctionCallOutputInput { + type: "function_call_output"; + call_id: string; + output: string; // JSON string +} + +// Union type for all possible input items +export type ResponsesAPIInput = + | ResponsesAPIMessageInput + | ResponsesAPIFunctionCallInput + | ResponsesAPIFunctionCallOutputInput; \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index fab6d17..9e411b0 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -31,36 +31,28 @@ chatContainer.scroll({ top: 0, behavior: "instant" }); } - export function scrollChatArea(behavior: ScrollBehavior | undefined) { + export function updateChatAreaLayout(behavior: ScrollBehavior | undefined) { tick().then(() => { settled = false; - if (messageElements.length === 0 || !chatAreaPaddingElement) { + if (messageElements.length <= 0 || !chatAreaPaddingElement) { if (chatAreaPaddingElement) { chatAreaPaddingElement.style.padding = "0px"; } return; } - messageElements.sort((a, b) => a.index - b.index)[messageElements.length - 1]; - const gap = parseFloat(getComputedStyle(chatContainer).gap) || 0; const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0; const paddingBottom = parseFloat(getComputedStyle(chatContainer).paddingBottom) || 0; - let usedSpace = 0; - for (let i = messageElements.length - 1; i >= 0; i--) { - const messageElement = messageElements[i]; - usedSpace += messageElement.element.offsetHeight + gap; - if (messageElement.element.classList.contains(Role.User)) { - break; - } - } - const padding = chatContainer.offsetHeight - paddingTop - paddingBottom - usedSpace; + const messageElement = messageElements.sort((a, b) => a.index - b.index)[messageElements.length - 1]; + const messageSpace = messageElement.element.offsetHeight; + const padding = chatContainer.offsetHeight - paddingTop - paddingBottom - messageSpace; chatAreaPaddingElement.style.padding = `${Math.max(0, padding / 2)}px`; tick().then(() => { - if (behavior) { + if (autoScroll && behavior) { chatContainer.scroll({ top: chatContainer.scrollHeight, behavior: behavior }) } tick().then(() => settled = true); @@ -69,6 +61,8 @@ } let settled: boolean = false; + let autoScroll: boolean = true; + let lastScrollTop: number = 0; let chatAreaPaddingElement: HTMLElement | undefined; @@ -144,6 +138,35 @@ messageElements.push({ index: index, element: element }); } + // decide if we should be auto scrolling + function handleScroll() { + if (!chatContainer) { + return; + } + + const scrollTop = chatContainer.scrollTop; + const scrollHeight = chatContainer.scrollHeight; + const clientHeight = chatContainer.clientHeight; + + // Only process if the user actually scrolled (scrollTop changed) + // This prevents false triggers when content grows and pushes things down + if (scrollTop === lastScrollTop) { + return; + } + + const previousScrollTop = lastScrollTop; + lastScrollTop = scrollTop; + + // Check if we're at the bottom (with a small tolerance for rounding errors) + const isAtBottom = scrollHeight - scrollTop - clientHeight < 5; + + if (isAtBottom) { + autoScroll = true; + } else if (scrollTop < previousScrollTop) { + autoScroll = false; // user scrolled up + } + } + // Track streaming messages and update them incrementally $: { messages.forEach((message) => { @@ -173,7 +196,7 @@ } -
+
{#each messages as message, index} {#if !message.isFunctionCallResponse && message.content.trim() !== ""} {#if message.role === Role.User} diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index c27c976..b7501b0 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -13,12 +13,15 @@ import type { ChatService } from "Services/ChatService"; import type { ConversationFileSystemService } from "Services/ConversationFileSystemService"; import type { SettingsService } from "Services/SettingsService"; + import { Copy } from "Enums/Copy"; + import { AbortService } from "Services/AbortService"; const plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); const settingsService: SettingsService = Resolve(Services.SettingsService); const chatService: ChatService = Resolve(Services.ChatService); const workSpaceService: WorkSpaceService = Resolve(Services.WorkSpaceService); const conversationService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService); + const abortService: AbortService = Resolve(Services.AbortService); let chatContainer: HTMLDivElement; let chatArea: ChatArea; @@ -86,7 +89,7 @@ function handleStop() { chatService.stop(); currentThought = null; - chatArea.scrollChatArea("smooth"); + chatArea.updateChatAreaLayout("smooth"); } async function handleSubmit(userRequest: string, formattedRequest: string) { @@ -98,20 +101,27 @@ await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, { onSubmit: () => { - chatArea.scrollChatArea("smooth"); + chatArea.updateChatAreaLayout("smooth"); isSubmitting = true; }, onStreamingUpdate: (streamingId) => { conversation = conversation; currentStreamingMessageId = streamingId; + chatArea.updateChatAreaLayout("smooth"); }, onThoughtUpdate: (thought) => { - currentThought = thought; + if (thought !== Copy.AIThoughtMessage) { + currentThought = thought; + } else if (currentThought !== null) { + // we are in-between thoughts so use generic copy + currentThought = thought; + } }, onComplete: async () => { cancelling = false; isSubmitting = false; - chatArea.scrollChatArea(undefined); + abortService.reset(); + chatArea.updateChatAreaLayout("smooth"); await chatService.updateTokenDisplay(conversation); }, onCancel: () => { @@ -148,7 +158,7 @@ chatService.onNameChanged?.(loadedConversation.title); chatService.updateTokenDisplay(loadedConversation); conversationStore.clearLoadFlag(); - chatArea.scrollChatArea("instant"); + chatArea.updateChatAreaLayout("instant"); } }); } diff --git a/Conversations/Conversation.ts b/Conversations/Conversation.ts index 442a8b1..24c03c4 100644 --- a/Conversations/Conversation.ts +++ b/Conversations/Conversation.ts @@ -1,6 +1,5 @@ import { StringTools } from "Helpers/StringTools"; import { ConversationContent } from "./ConversationContent"; -import { ApiErrorType } from "Types/ApiError"; export class Conversation { @@ -32,28 +31,4 @@ export class Conversation { data.contents.every(ConversationContent.isConversationContentData) ); } - - public setMostRecentContent(content: string) { - const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1]; - if (conversationContent) { - conversationContent.content = content; - conversationContent.errorType = undefined; - } - } - - public setMostRecentError(content: string, errorType: ApiErrorType) { - const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1]; - if (conversationContent) { - conversationContent.content = content; - conversationContent.errorType = errorType; - } - } - - public setMostRecentFunctionCall(functionCall: string) { - const conversationContent: ConversationContent | undefined = this.contents[this.contents.length - 1]; - if (conversationContent) { - conversationContent.functionCall = functionCall; - conversationContent.isFunctionCall = true; - } - } } \ No newline at end of file diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index b647d42..216acbd 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -10,9 +10,10 @@ export class ConversationContent { isFunctionCall: boolean; isFunctionCallResponse: boolean; toolId?: string; + thoughtSignature?: string; errorType?: ApiErrorType; - constructor(role: Role, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string, errorType?: ApiErrorType) { + constructor(role: Role, content: string = "", promptContent: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string, thoughtSignature?: string, errorType?: ApiErrorType) { this.role = role; this.content = content; this.promptContent = promptContent; @@ -21,11 +22,13 @@ export class ConversationContent { this.isFunctionCall = isFunctionCall; this.isFunctionCallResponse = isFunctionCallResponse; this.toolId = toolId; + this.thoughtSignature = thoughtSignature; this.errorType = errorType; } public static isConversationContentData(this: void, data: unknown): data is { - role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string, errorType?: string + role: string; content: string; promptContent: string; functionCall: string; timestamp: string, isFunctionCall: boolean, + isFunctionCallResponse: boolean, toolId?: string, thoughtSignature?: string, errorType?: string } { return ( data !== null && @@ -47,6 +50,7 @@ export class ConversationContent { // optional conversation data fields (!("toolId" in data) || typeof data.toolId === "string") && + (!("thoughtSignature" in data) || typeof data.thoughtSignature === "string") && (!("errorType" in data) || typeof data.errorType === "string") ); } diff --git a/Enums/Copy.ts b/Enums/Copy.ts index a5b09e3..8753428 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -57,6 +57,8 @@ export enum Copy { TooltipShowApiKey = "Show API Key", TooltipHideApiKey = "Hide API Key", + AIThoughtMessage = "Thinking...", + // Help Modal Copy HelpModalAboutTitle = "About", HelpModalAboutContent = `#### About Vaultkeeper AI diff --git a/Modals/ConversationHistoryModal.ts b/Modals/ConversationHistoryModal.ts index d0be145..6f1fa2f 100644 --- a/Modals/ConversationHistoryModal.ts +++ b/Modals/ConversationHistoryModal.ts @@ -95,7 +95,7 @@ export class ConversationHistoryModal extends Modal { const deletedIds: string[] = []; for (const item of itemsToDelete) { - const result = await this.fileSystemService.deleteFile(item.filePath, true); + const result = await this.fileSystemService.deleteFile(item.filePath, true, false); if (result instanceof Error) { new Notice(`Failed to delete conversation '${item.title}'`); continue; diff --git a/Services/AbortService.ts b/Services/AbortService.ts index 250855d..49fc44d 100644 --- a/Services/AbortService.ts +++ b/Services/AbortService.ts @@ -6,6 +6,8 @@ export class AbortService { return error instanceof DOMException && error.name === "AbortError"; } + public reset = () => this.initialiseAbortController(); // semantic alias for initialiseAbortController + public initialiseAbortController(): void { this.abortController.abort(); this.abortController = new AbortController(); diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 5e65837..3e31c73 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -17,6 +17,7 @@ import type { EventService } from "./EventService"; import { Event } from "Enums/Event"; import { AbortService } from "./AbortService"; import { Exception } from "Helpers/Exception"; +import { Copy } from "Enums/Copy"; export interface IChatServiceCallbacks { onSubmit: () => void; @@ -99,6 +100,8 @@ export class ChatService { conversation.contents.push(new ConversationContent( Role.User, functionResponseString, functionResponseString, "", new Date(), false, true, functionResponse.toolId )); + } else { + callbacks.onThoughtUpdate(Copy.AIThoughtMessage); } this.ensureCorrectConversationStructure(conversation); @@ -184,9 +187,8 @@ export class ChatService { return { functionCall: null, shouldContinue: false };; } - const aiMessage = new ConversationContent(Role.Assistant); - conversation.contents.push(aiMessage); - callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); + const conversationContent = new ConversationContent(Role.Assistant); + conversation.contents.push(conversationContent); let accumulatedContent = ""; let capturedFunctionCall: AIFunctionCall | null = null; @@ -194,8 +196,9 @@ export class ChatService { for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions)) { if (chunk.error && chunk.errorType) { - conversation.setMostRecentError(chunk.error, chunk.errorType); - callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); + conversationContent.content = chunk.error; + conversationContent.errorType = chunk.errorType; + callbacks.onStreamingUpdate(null); break; } @@ -210,7 +213,7 @@ export class ChatService { if (chunk.content) { accumulatedContent += chunk.content; - conversation.setMostRecentContent(accumulatedContent); + conversationContent.content = accumulatedContent; if (accumulatedContent.trim() !== "") { callbacks.onThoughtUpdate(null); } @@ -222,13 +225,20 @@ export class ChatService { if (sanitizedContent.trim() === "" && !capturedFunctionCall) { conversation.contents.pop(); } else { - conversation.setMostRecentContent(sanitizedContent); + conversationContent.content = sanitizedContent; if (capturedFunctionCall) { - conversation.setMostRecentFunctionCall(capturedFunctionCall?.toConversationString()); + conversationContent.isFunctionCall = true; + conversationContent.functionCall = capturedFunctionCall.toConversationString(); + if (capturedFunctionCall.thoughtSignature) { + conversationContent.thoughtSignature = capturedFunctionCall.thoughtSignature; + } } } } - callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); + + if (conversationContent.content.trim() !== "") { + callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString()); + } } callbacks.onStreamingUpdate(null); diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 1587ff7..cf9bb56 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -46,6 +46,7 @@ export class ConversationFileSystemService { isFunctionCall: content.isFunctionCall, isFunctionCallResponse: content.isFunctionCallResponse, toolId: content.toolId, + thoughtSignature: content.thoughtSignature, errorType: content.errorType })) }; @@ -110,6 +111,7 @@ export class ConversationFileSystemService { content.isFunctionCall, content.isFunctionCallResponse, content.toolId, + content.thoughtSignature, content.errorType ); }); diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts index 8997cee..a1666ee 100644 --- a/Services/ConversationNamingService.ts +++ b/Services/ConversationNamingService.ts @@ -72,7 +72,7 @@ export class ConversationNamingService { } private async validateName(generatedName: string): Promise { - const cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, "").split(/\s+/).slice(0, 6).join(" "); + const cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, ""); let index = 1; let availableTitle = cleanedTitle; diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index a85613f..788265e 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -48,15 +48,15 @@ export class StreamingService { return; } catch (error) { - lastError = error instanceof Error ? error : Exception.new(error); + lastError = Exception.new(error); if (AbortService.isAbortError(error)) { throw error; } if (!this.shouldRetry(error, attempt)) { - Exception.log(lastError); - yield this.createErrorChunk(lastError); + Exception.log(error); + yield this.createErrorChunk(Exception.new(error)); return; } diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 3ff06cd..61fda0b 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -166,6 +166,10 @@ export class VaultService { return Exception.new(`File does not exist: ${sourcePath}`); } + if (this.isExclusion(destinationPath, allowAccessToPluginRoot)) { + return Exception.new(`Failed to rename "${sourcePath}" to "${destinationPath}", permission denied.`) + } + try { await this.createDirectories(destinationPath, allowAccessToPluginRoot) await this.fileManager.renameFile(file, destinationPath); diff --git a/Types/ApiError.ts b/Types/ApiError.ts index 88ccc2b..ed2d39a 100644 --- a/Types/ApiError.ts +++ b/Types/ApiError.ts @@ -18,9 +18,7 @@ export interface ApiErrorInfo { } export class ApiError extends Error { - constructor( - public info: ApiErrorInfo - ) { + constructor(public info: ApiErrorInfo) { super(info.message); this.name = "ApiError"; } @@ -46,19 +44,19 @@ export class ApiError extends Error { switch (status) { case 429: type = ApiErrorType.RATE_LIMIT; - userMessage = "Rate limit exceeded. Retrying..."; + userMessage = "Rate limit exceeded."; isRetryable = true; break; case 503: type = ApiErrorType.OVERLOADED; - userMessage = "Service overloaded. Retrying..."; + userMessage = "Service overloaded."; isRetryable = true; break; case 500: case 502: case 504: type = ApiErrorType.SERVER_ERROR; - userMessage = "Server error. Retrying..."; + userMessage = "Server error."; isRetryable = true; break; case 401: @@ -78,7 +76,7 @@ export class ApiError extends Error { isRetryable = false; } - const message = `API request failed: ${status} - ${statusText}${providerMessage ? ` - ${providerMessage}` : ""}`; + const message = `API request failed: ${status} - ${statusText} ${providerMessage ? `${providerMessage}` : ""}`; return new ApiError({ type, diff --git a/__tests__/AIClasses/AIFunctionCall.test.ts b/__tests__/AIClasses/AIFunctionCall.test.ts new file mode 100644 index 0000000..ff05009 --- /dev/null +++ b/__tests__/AIClasses/AIFunctionCall.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from 'vitest'; +import { AIFunctionCall } from '../../AIClasses/AIFunctionCall'; +import { AIFunction } from '../../Enums/AIFunction'; + +describe('AIFunctionCall', () => { + describe('constructor', () => { + it('should create instance with name and arguments only', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' } + ); + + expect(functionCall.name).toBe(AIFunction.SearchVaultFiles); + expect(functionCall.arguments).toEqual({ query: 'test' }); + expect(functionCall.toolId).toBeUndefined(); + expect(functionCall.thoughtSignature).toBeUndefined(); + }); + + it('should create instance with name, arguments, and toolId', () => { + const functionCall = new AIFunctionCall( + AIFunction.ReadFile, + { path: 'test.md' }, + 'tool-123' + ); + + expect(functionCall.name).toBe(AIFunction.ReadFile); + expect(functionCall.arguments).toEqual({ path: 'test.md' }); + expect(functionCall.toolId).toBe('tool-123'); + expect(functionCall.thoughtSignature).toBeUndefined(); + }); + + it('should create instance with all four parameters including thoughtSignature', () => { + const signature = 'base64EncodedSignature=='; + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'notes' }, + undefined, + signature + ); + + expect(functionCall.name).toBe(AIFunction.SearchVaultFiles); + expect(functionCall.arguments).toEqual({ query: 'notes' }); + expect(functionCall.toolId).toBeUndefined(); + expect(functionCall.thoughtSignature).toBe(signature); + }); + + it('should create instance with toolId and thoughtSignature', () => { + const signature = 'aGVsbG8gd29ybGQ='; + const functionCall = new AIFunctionCall( + AIFunction.WriteFile, + { path: 'note.md', content: 'Hello' }, + 'tool-456', + signature + ); + + expect(functionCall.name).toBe(AIFunction.WriteFile); + expect(functionCall.arguments).toEqual({ path: 'note.md', content: 'Hello' }); + expect(functionCall.toolId).toBe('tool-456'); + expect(functionCall.thoughtSignature).toBe(signature); + }); + + it('should handle empty arguments object', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + {} + ); + + expect(functionCall.name).toBe(AIFunction.SearchVaultFiles); + expect(functionCall.arguments).toEqual({}); + }); + + it('should handle complex nested arguments', () => { + const complexArgs = { + filters: { + tags: ['important', 'work'], + dateRange: { start: '2024-01-01', end: '2024-12-31' } + }, + limit: 10 + }; + + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + complexArgs + ); + + expect(functionCall.arguments).toEqual(complexArgs); + }); + }); + + describe('toConversationString', () => { + it('should serialize to JSON with only name and args when no optional fields', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' } + ); + + const serialized = functionCall.toConversationString(); + const parsed = JSON.parse(serialized); + + expect(parsed).toEqual({ + functionCall: { + name: AIFunction.SearchVaultFiles, + args: { query: 'test' }, + id: undefined, + thoughtSignature: undefined + } + }); + }); + + it('should serialize with toolId when present', () => { + const functionCall = new AIFunctionCall( + AIFunction.ReadFile, + { path: 'note.md' }, + 'tool-789' + ); + + const serialized = functionCall.toConversationString(); + const parsed = JSON.parse(serialized); + + expect(parsed).toEqual({ + functionCall: { + name: AIFunction.ReadFile, + args: { path: 'note.md' }, + id: 'tool-789', + thoughtSignature: undefined + } + }); + }); + + it('should serialize with thoughtSignature when present', () => { + const signature = 'dGVzdFNpZ25hdHVyZQ=='; + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'notes' }, + undefined, + signature + ); + + const serialized = functionCall.toConversationString(); + const parsed = JSON.parse(serialized); + + expect(parsed).toEqual({ + functionCall: { + name: AIFunction.SearchVaultFiles, + args: { query: 'notes' }, + id: undefined, + thoughtSignature: signature + } + }); + }); + + it('should serialize with both toolId and thoughtSignature when present', () => { + const signature = 'YW5vdGhlclNpZ25hdHVyZQ=='; + const functionCall = new AIFunctionCall( + AIFunction.WriteFile, + { path: 'file.md', content: 'content' }, + 'tool-999', + signature + ); + + const serialized = functionCall.toConversationString(); + const parsed = JSON.parse(serialized); + + expect(parsed).toEqual({ + functionCall: { + name: AIFunction.WriteFile, + args: { path: 'file.md', content: 'content' }, + id: 'tool-999', + thoughtSignature: signature + } + }); + }); + + it('should produce valid JSON string', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + 'tool-123', + 'c2lnbmF0dXJl' + ); + + const serialized = functionCall.toConversationString(); + + expect(() => JSON.parse(serialized)).not.toThrow(); + }); + + it('should handle special characters in arguments', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test "quotes" and \'apostrophes\' and\nnewlines' } + ); + + const serialized = functionCall.toConversationString(); + const parsed = JSON.parse(serialized); + + expect(parsed.functionCall.args.query).toBe('test "quotes" and \'apostrophes\' and\nnewlines'); + }); + + it('should handle empty string thoughtSignature', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + undefined, + '' + ); + + const serialized = functionCall.toConversationString(); + const parsed = JSON.parse(serialized); + + expect(parsed.functionCall.thoughtSignature).toBe(''); + }); + }); + + describe('properties', () => { + it('should have immutable name property', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' } + ); + + // Readonly is enforced at TypeScript compile time + // At runtime, the properties are accessible + expect(functionCall.name).toBe(AIFunction.SearchVaultFiles); + }); + + it('should have immutable arguments property', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' } + ); + + expect(functionCall.arguments).toEqual({ query: 'test' }); + }); + + it('should have immutable toolId property', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + 'tool-123' + ); + + expect(functionCall.toolId).toBe('tool-123'); + }); + + it('should have immutable thoughtSignature property', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + undefined, + 'signature' + ); + + expect(functionCall.thoughtSignature).toBe('signature'); + }); + }); + + describe('edge cases', () => { + it('should handle very long thoughtSignature (realistic base64)', () => { + const longSignature = 'A'.repeat(10000); + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + undefined, + longSignature + ); + + expect(functionCall.thoughtSignature).toBe(longSignature); + expect(functionCall.thoughtSignature).toHaveLength(10000); + }); + + it('should handle thoughtSignature with base64 special characters', () => { + const base64Signature = 'SGVsbG8gV29ybGQ+Pz8/Pz8+Pg=='; + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + undefined, + base64Signature + ); + + expect(functionCall.thoughtSignature).toBe(base64Signature); + }); + + it('should handle undefined toolId and defined thoughtSignature', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + undefined, + 'signature' + ); + + expect(functionCall.toolId).toBeUndefined(); + expect(functionCall.thoughtSignature).toBe('signature'); + }); + + it('should handle defined toolId and undefined thoughtSignature', () => { + const functionCall = new AIFunctionCall( + AIFunction.SearchVaultFiles, + { query: 'test' }, + 'tool-123', + undefined + ); + + expect(functionCall.toolId).toBe('tool-123'); + expect(functionCall.thoughtSignature).toBeUndefined(); + }); + }); +}); diff --git a/__tests__/AIClasses/BaseAIClass.test.ts b/__tests__/AIClasses/BaseAIClass.test.ts new file mode 100644 index 0000000..bcdfe14 --- /dev/null +++ b/__tests__/AIClasses/BaseAIClass.test.ts @@ -0,0 +1,511 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { Claude } from '../../AIClasses/Claude/Claude'; +import { OpenAI } from '../../AIClasses/OpenAI/OpenAI'; +import { Gemini } from '../../AIClasses/Gemini/Gemini'; +import { ConversationContent } from '../../Conversations/ConversationContent'; +import { Role } from '../../Enums/Role'; +import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { StreamingService } from '../../Services/StreamingService'; +import { SettingsService } from '../../Services/SettingsService'; +import { AIFunctionDefinitions } from '../../AIClasses/FunctionDefinitions/AIFunctionDefinitions'; +import { AbortService } from '../../Services/AbortService'; +import { AIProvider } from '../../Enums/ApiProvider'; + +/** + * BaseAIClass Shared Method Tests + * + * Tests the provider-agnostic methods in BaseAIClass that all providers inherit. + * Focus on cross-provider compatibility for parsing and filtering. + */ +describe('BaseAIClass Shared Methods', () => { + let claude: Claude; + let openai: OpenAI; + let gemini: Gemini; + + beforeEach(() => { + // Mock IPrompt + const mockPrompt = { + systemInstruction: vi.fn().mockReturnValue('System instruction'), + userInstruction: vi.fn().mockResolvedValue('User instruction') + }; + RegisterSingleton(Services.IPrompt, mockPrompt); + + // Mock VaultkeeperAIPlugin + RegisterSingleton(Services.VaultkeeperAIPlugin, {}); + + // Mock SettingsService + const mockSettingsService = { + settings: { + model: 'claude-3-5-sonnet-20241022', + apiKeys: { + claude: 'test-claude-key', + openai: 'test-openai-key', + gemini: 'test-gemini-key' + } + }, + getApiKeyForProvider: vi.fn((provider: AIProvider) => { + if (provider === AIProvider.Claude) return 'test-claude-key'; + if (provider === AIProvider.OpenAI) return 'test-openai-key'; + if (provider === AIProvider.Gemini) return 'test-gemini-key'; + return ''; + }), + getApiKeyForCurrentModel: vi.fn(() => 'test-claude-key') + }; + RegisterSingleton(Services.SettingsService, mockSettingsService); + + // Create real AbortService instance + const abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + + // Mock StreamingService + const mockStreamingService = { + streamRequest: vi.fn() + }; + RegisterSingleton(Services.StreamingService, mockStreamingService); + + // Mock AIFunctionDefinitions + const mockFunctionDefinitions = { + getQueryActions: vi.fn().mockReturnValue([]) + }; + RegisterSingleton(Services.AIFunctionDefinitions, mockFunctionDefinitions); + + claude = new Claude(); + openai = new OpenAI(); + gemini = new Gemini(); + }); + + afterEach(() => { + DeregisterAllServices(); + }); + + describe('parseFunctionCall', () => { + it('should parse Claude-style function call (with id)', () => { + const claudeCall = JSON.stringify({ + functionCall: { + id: 'toolu_123', + name: 'search_vault_files', + args: { query: 'test' } + } + }); + + const result = (claude as any).parseFunctionCall(claudeCall); + + expect(result).toBeDefined(); + expect(result.functionCall.id).toBe('toolu_123'); + expect(result.functionCall.name).toBe('search_vault_files'); + expect(result.functionCall.args).toEqual({ query: 'test' }); + }); + + it('should parse OpenAI-style function call (with id)', () => { + const openaiCall = JSON.stringify({ + functionCall: { + id: 'call_abc', + name: 'read_file', + args: { path: 'note.md' } + } + }); + + const result = (openai as any).parseFunctionCall(openaiCall); + + expect(result).toBeDefined(); + expect(result.functionCall.id).toBe('call_abc'); + expect(result.functionCall.name).toBe('read_file'); + expect(result.functionCall.args).toEqual({ path: 'note.md' }); + }); + + it('should parse Gemini-style function call (no id)', () => { + const geminiCall = JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'gemini test' } + } + }); + + const result = (gemini as any).parseFunctionCall(geminiCall); + + expect(result).toBeDefined(); + expect(result.functionCall.name).toBe('search_vault_files'); + expect(result.functionCall.args).toEqual({ query: 'gemini test' }); + // ID may be undefined for Gemini + expect(result.functionCall.id).toBeUndefined(); + }); + + it('should handle invalid JSON gracefully', () => { + const invalidJson = 'not valid json {'; + + const result = (claude as any).parseFunctionCall(invalidJson); + + expect(result).toBeNull(); + }); + + it('should handle missing required fields', () => { + // Missing 'name' field + const missingName = JSON.stringify({ + functionCall: { + id: 'test-id', + args: { query: 'test' } + } + }); + + const result = (claude as any).parseFunctionCall(missingName); + + // Should still parse, but may have undefined name + expect(result).toBeDefined(); + expect(result.functionCall.id).toBe('test-id'); + }); + + it('should handle empty args object', () => { + const emptyArgs = JSON.stringify({ + functionCall: { + id: 'test-id', + name: 'list_files', + args: {} + } + }); + + const result = (claude as any).parseFunctionCall(emptyArgs); + + expect(result).toBeDefined(); + expect(result.functionCall.args).toEqual({}); + }); + + it('should handle complex nested args', () => { + const complexArgs = JSON.stringify({ + functionCall: { + id: 'test-id', + name: 'search', + args: { + filters: { + tags: ['important', 'work'], + dateRange: { start: '2024-01-01', end: '2024-12-31' } + }, + options: { limit: 10, sort: 'date' } + } + } + }); + + const result = (claude as any).parseFunctionCall(complexArgs); + + expect(result).toBeDefined(); + expect(result.functionCall.args.filters.tags).toHaveLength(2); + expect(result.functionCall.args.options.limit).toBe(10); + }); + }); + + describe('parseFunctionResponse', () => { + it('should parse response with id field', () => { + const responseWithId = JSON.stringify({ + id: 'call-123', + functionResponse: { + name: 'search_vault_files', + response: ['file1.md', 'file2.md'] + } + }); + + const result = (claude as any).parseFunctionResponse(responseWithId); + + expect(result).toBeDefined(); + expect(result.id).toBe('call-123'); + expect(result.functionResponse.name).toBe('search_vault_files'); + expect(result.functionResponse.response).toEqual(['file1.md', 'file2.md']); + }); + + it('should parse response without id field', () => { + const responseNoId = JSON.stringify({ + functionResponse: { + name: 'read_file', + response: { content: 'File contents' } + } + }); + + const result = (gemini as any).parseFunctionResponse(responseNoId); + + expect(result).toBeDefined(); + expect(result.functionResponse.name).toBe('read_file'); + expect(result.functionResponse.response).toEqual({ content: 'File contents' }); + expect(result.id).toBeUndefined(); + }); + + it('should handle invalid JSON', () => { + const invalidJson = 'invalid response json'; + + const result = (claude as any).parseFunctionResponse(invalidJson); + + expect(result).toBeNull(); + }); + + it('should handle null response', () => { + const nullResponse = JSON.stringify({ + id: 'call-123', + functionResponse: { + name: 'delete_file', + response: null + } + }); + + const result = (openai as any).parseFunctionResponse(nullResponse); + + expect(result).toBeDefined(); + expect(result.functionResponse.response).toBeNull(); + }); + + it('should handle complex response objects', () => { + const complexResponse = JSON.stringify({ + id: 'call-456', + functionResponse: { + name: 'query_database', + response: { + results: [ + { id: 1, title: 'Note 1' }, + { id: 2, title: 'Note 2' } + ], + metadata: { totalCount: 2, page: 1 } + } + } + }); + + const result = (claude as any).parseFunctionResponse(complexResponse); + + expect(result).toBeDefined(); + expect(result.functionResponse.response.results).toHaveLength(2); + expect(result.functionResponse.response.metadata.totalCount).toBe(2); + }); + }); + + describe('filterConversationContents', () => { + it('should filter out empty content', () => { + const contents = [ + new ConversationContent(Role.User, 'Hello', 'Hello'), + new ConversationContent(Role.Assistant, ''), // Empty + new ConversationContent(Role.Assistant, ' '), // Whitespace only + new ConversationContent(Role.User, 'World', 'World') + ]; + + const result = (claude as any).filterConversationContents(contents); + + expect(result).toHaveLength(2); + expect(result[0].content).toBe('Hello'); + expect(result[1].content).toBe('World'); + }); + + it('should filter orphaned calls from different providers', () => { + const contents = [ + new ConversationContent(Role.User, 'Test', 'Test'), + + // Orphaned Claude function call (no response) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_orphan', + name: 'search', + args: {} + } + }), + new Date(), + true, + false, + 'toolu_orphan' + ); + return content; + })(), + + new ConversationContent(Role.User, 'Next question', 'Next question') + ]; + + const result = (claude as any).filterConversationContents(contents); + + // Orphaned call should be excluded + expect(result).toHaveLength(2); + expect(result[0].content).toBe('Test'); + expect(result[1].content).toBe('Next question'); + }); + + it('should preserve most recent call regardless of provider', () => { + const contents = [ + new ConversationContent(Role.User, 'Test', 'Test'), + + // Most recent function call (at end, so kept even without response) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_recent', + name: 'search', + args: {} + } + }), + new Date(), + true, + false, + 'call_recent' + ); + return content; + })() + ]; + + const result = (openai as any).filterConversationContents(contents); + + // Most recent call should be included + expect(result).toHaveLength(2); + expect(result[1].isFunctionCall).toBe(true); + }); + + it('should handle function call with response correctly', () => { + const contents = [ + new ConversationContent(Role.User, 'Search', 'Search'), + + // Function call with response (should be kept) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_with_response', + name: 'search', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + 'call_with_response' + ); + return content; + })(), + + // Corresponding response + (() => { + const responseContent = JSON.stringify({ + id: 'call_with_response', + functionResponse: { name: 'search', response: [] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + const result = (gemini as any).filterConversationContents(contents); + + // All three should be kept + expect(result).toHaveLength(3); + }); + + it('should handle mixed orphaned and complete calls', () => { + const contents = [ + new ConversationContent(Role.User, 'Start', 'Start'), + + // Orphaned call 1 + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { id: 'orphan1', name: 'search', args: {} } + }), + new Date(), + true, + false, + 'orphan1' + ); + return content; + })(), + + new ConversationContent(Role.User, 'Middle', 'Middle'), + + // Complete call with response + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { id: 'complete1', name: 'read', args: {} } + }), + new Date(), + true, + false, + 'complete1' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'complete1', + functionResponse: { name: 'read', response: 'data' } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.User, 'End', 'End') + ]; + + const result = (claude as any).filterConversationContents(contents); + + // Should have: Start, Middle, complete call, complete response, End + expect(result).toHaveLength(5); + expect(result[0].content).toBe('Start'); + expect(result[1].content).toBe('Middle'); + expect(result[2].isFunctionCall).toBe(true); + expect(result[3].isFunctionCallResponse).toBe(true); + expect(result[4].content).toBe('End'); + }); + }); + + describe('Cross-Provider Consistency', () => { + it('should parse same function call JSON consistently across providers', () => { + const sharedFunctionCall = JSON.stringify({ + functionCall: { + id: 'shared-123', + name: 'search_vault_files', + args: { query: 'consistent test' } + } + }); + + const claudeResult = (claude as any).parseFunctionCall(sharedFunctionCall); + const openaiResult = (openai as any).parseFunctionCall(sharedFunctionCall); + const geminiResult = (gemini as any).parseFunctionCall(sharedFunctionCall); + + // All providers should parse to the same structure + expect(claudeResult.functionCall.name).toBe(openaiResult.functionCall.name); + expect(openaiResult.functionCall.name).toBe(geminiResult.functionCall.name); + expect(claudeResult.functionCall.args).toEqual(openaiResult.functionCall.args); + expect(openaiResult.functionCall.args).toEqual(geminiResult.functionCall.args); + }); + + it('should filter conversations consistently across providers', () => { + const sharedConversation = [ + new ConversationContent(Role.User, 'Test', 'Test'), + new ConversationContent(Role.Assistant, ''), // Empty + new ConversationContent(Role.User, 'More', 'More') + ]; + + const claudeFiltered = (claude as any).filterConversationContents(sharedConversation); + const openaiFiltered = (openai as any).filterConversationContents(sharedConversation); + const geminiFiltered = (gemini as any).filterConversationContents(sharedConversation); + + // All should filter to same length + expect(claudeFiltered).toHaveLength(2); + expect(openaiFiltered).toHaveLength(2); + expect(geminiFiltered).toHaveLength(2); + + // Content should match + expect(claudeFiltered[0].content).toBe(openaiFiltered[0].content); + expect(openaiFiltered[0].content).toBe(geminiFiltered[0].content); + }); + }); +}); diff --git a/__tests__/AIClasses/CrossProviderIntegration.test.ts b/__tests__/AIClasses/CrossProviderIntegration.test.ts new file mode 100644 index 0000000..fe3c2a6 --- /dev/null +++ b/__tests__/AIClasses/CrossProviderIntegration.test.ts @@ -0,0 +1,1565 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { Gemini } from '../../AIClasses/Gemini/Gemini'; +import { Claude } from '../../AIClasses/Claude/Claude'; +import { OpenAI } from '../../AIClasses/OpenAI/OpenAI'; +import { Conversation } from '../../Conversations/Conversation'; +import { ConversationContent } from '../../Conversations/ConversationContent'; +import { Role } from '../../Enums/Role'; +import { RegisterSingleton, DeregisterAllServices, Resolve } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { StreamingService } from '../../Services/StreamingService'; +import { SettingsService } from '../../Services/SettingsService'; +import { AIFunctionDefinitions } from '../../AIClasses/FunctionDefinitions/AIFunctionDefinitions'; +import { AbortService } from '../../Services/AbortService'; +import { AIProvider } from '../../Enums/ApiProvider'; + +/** + * Cross-Provider Integration Tests for Thought Signature Support + * + * These tests verify that conversations can seamlessly switch between AI providers + * while maintaining context through the thoughtSignature feature. The key scenarios: + * + * 1. Claude/OpenAI → Gemini: Function calls without thought signatures are converted + * to legacy text format to preserve context without breaking Gemini 3 Pro. + * + * 2. Gemini → Claude/OpenAI: Function calls with thought signatures can be sent to + * other providers (they'll ignore the signature but maintain the function call). + * + * 3. Mixed conversations: Ensure that a conversation history with mixed provider + * function calls is handled gracefully. + */ +describe('Cross-Provider Integration - Thought Signature Support', () => { + let gemini: Gemini; + let mockStreamingService: any; + let mockPrompt: any; + let mockPlugin: any; + let mockSettingsService: any; + let mockFunctionDefinitions: any; + let abortService: AbortService; + + beforeEach(() => { + // Mock IPrompt + mockPrompt = { + systemInstruction: vi.fn().mockReturnValue('System instruction'), + userInstruction: vi.fn().mockResolvedValue('User instruction') + }; + RegisterSingleton(Services.IPrompt, mockPrompt); + + // Mock VaultkeeperAIPlugin + mockPlugin = {}; + RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin); + + // Mock SettingsService + mockSettingsService = { + settings: { + model: 'gemini-2.5-flash-lite', + apiKeys: { + claude: 'test-claude-key', + openai: 'test-openai-key', + gemini: 'test-gemini-key' + } + }, + getApiKeyForProvider: vi.fn((provider: AIProvider) => { + if (provider === AIProvider.Claude) return 'test-claude-key'; + if (provider === AIProvider.OpenAI) return 'test-openai-key'; + if (provider === AIProvider.Gemini) return 'test-gemini-key'; + return ''; + }), + getApiKeyForCurrentModel: vi.fn(() => 'test-gemini-key') + }; + RegisterSingleton(Services.SettingsService, mockSettingsService); + + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + + // Mock StreamingService + mockStreamingService = { + streamRequest: vi.fn() + }; + RegisterSingleton(Services.StreamingService, mockStreamingService); + + // Mock AIFunctionDefinitions + mockFunctionDefinitions = { + getQueryActions: vi.fn().mockReturnValue([ + { + name: 'search_vault_files', + description: 'Test function', + parameters: { + type: 'object', + properties: { + query: { type: 'string' } + } + } + } + ]) + }; + RegisterSingleton(Services.AIFunctionDefinitions, mockFunctionDefinitions); + + gemini = new Gemini(); + }); + + afterEach(() => { + DeregisterAllServices(); + }); + + describe('Claude/OpenAI → Gemini Switching', () => { + it('should convert Claude function call (no thoughtSignature) to legacy text format', () => { + // Simulate a conversation started with Claude that made a function call + const claudeFunctionCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'meeting notes' } + } + }), + new Date(), + true, + false, + 'call_abc123', // Claude's toolId + undefined // No thoughtSignature from Claude + ); + + const result = (gemini as any).extractContents([claudeFunctionCall]); + + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Call]'); + expect(result[0].parts[0].text).toContain('search_vault_files'); + expect(result[0].parts[0].text).toContain('meeting notes'); + }); + + it('should convert OpenAI function call (no thoughtSignature) to legacy text format', () => { + // Simulate a conversation started with OpenAI + const openaiFunctionCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'read_file', + args: { path: 'project.md' } + } + }), + new Date(), + true, + false, + 'call_xyz789', // OpenAI's toolId + undefined // No thoughtSignature from OpenAI + ); + + const result = (gemini as any).extractContents([openaiFunctionCall]); + + expect(result[0].parts[0].text).toContain('[Legacy Tool Call] read_file'); + expect(result[0].parts[0].text).toContain('project.md'); + }); + + it('should convert function response without id to legacy text format', () => { + // Function responses from Claude/OpenAI may not have the id field in content + const responseContent = JSON.stringify({ + functionResponse: { + name: 'search_vault_files', + response: ['note1.md', 'note2.md'] + } + }); + + const claudeResponse = new ConversationContent( + Role.User, + responseContent, + responseContent + ); + claudeResponse.isFunctionCallResponse = true; + + const result = (gemini as any).extractContents([claudeResponse]); + + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Result]'); + expect(result[0].parts[0].text).toContain('search_vault_files'); + expect(result[0].parts[0].text).toContain('note1.md'); + }); + }); + + describe('Gemini → Claude/OpenAI Switching', () => { + it('should handle Gemini function call with thoughtSignature when switching providers', () => { + // This test verifies the data structure is preserved + // In actual usage, Claude/OpenAI would ignore the thoughtSignature field + const geminiFunctionCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + undefined, + 'geminiThoughtSignature==' // Gemini's thought signature + ); + + // Verify the signature is stored in ConversationContent + expect(geminiFunctionCall.thoughtSignature).toBe('geminiThoughtSignature=='); + + // When this conversation is sent to Claude/OpenAI, they'll see the function call + // but ignore the thoughtSignature field (which is fine) + const functionCallData = JSON.parse(geminiFunctionCall.functionCall); + expect(functionCallData.functionCall.name).toBe('search_vault_files'); + expect(functionCallData.functionCall.args).toEqual({ query: 'test' }); + }); + }); + + describe('Mixed Conversation History', () => { + it('should handle conversation with function calls from multiple providers', () => { + const conversation = [ + // User starts conversation + new ConversationContent(Role.User, 'Search for notes about the project', 'Search for notes about the project'), + + // Claude makes a function call (no signature) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'project' } + } + }), + new Date(), + true, + false, + 'call_claude_123', + undefined // No signature + ); + return content; + })(), + + // Function response + (() => { + const responseContent = JSON.stringify({ + functionResponse: { + name: 'search_vault_files', + response: ['project.md'] + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + // Claude responds + new ConversationContent(Role.Assistant, 'I found project.md'), + + // User asks follow-up + new ConversationContent(Role.User, 'Read that file', 'Read that file'), + + // Now Gemini makes a function call (with signature) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'read_file', + args: { path: 'project.md' } + } + }), + new Date(), + true, + false, + undefined, + 'geminiSignature123==' // Has signature + ); + return content; + })(), + + // Function response + (() => { + const responseContent = JSON.stringify({ + id: 'gemini-response-1', + functionResponse: { + name: 'read_file', + response: { content: 'File contents' } + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + const result = (gemini as any).extractContents(conversation); + + // Should have all messages, with appropriate formatting + expect(result.length).toBeGreaterThan(0); + + // Find the Claude function call - should be legacy text + const claudeFunctionCall = result.find((r: any) => + r.parts[0]?.text?.includes('[Legacy Tool Call]') && + r.parts[0]?.text?.includes('search_vault_files') + ); + expect(claudeFunctionCall).toBeDefined(); + + // Find the Gemini function call - should have proper format with signature + const geminiFunctionCall = result.find((r: any) => + r.parts[0]?.functionCall?.name === 'read_file' && + r.parts[0]?.thoughtSignature === 'geminiSignature123==' + ); + expect(geminiFunctionCall).toBeDefined(); + }); + + it('should handle conversation switching from provider without signatures to Gemini', () => { + // Start with OpenAI conversation + const conversation = [ + new ConversationContent(Role.User, 'List files', 'List files'), + + // OpenAI function call (no signature) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'list_files', + args: {} + } + }), + new Date(), + true, + false, + 'call_openai_456', + undefined + ); + return content; + })(), + + // Response (no id) + (() => { + const responseContent = JSON.stringify({ + functionResponse: { + name: 'list_files', + response: ['file1.md', 'file2.md'] + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + const result = (gemini as any).extractContents(conversation); + + // Verify OpenAI function call was converted to legacy format + expect(result).toHaveLength(3); + expect(result[1].parts[0].text).toContain('[Legacy Tool Call]'); + expect(result[2].parts[0].text).toContain('[Legacy Tool Result]'); + }); + + it('should preserve context when alternating between providers with function calls', () => { + // Simulate a conversation that switches providers multiple times + const conversation = [ + new ConversationContent(Role.User, 'Initial query', 'Initial query'), + + // Provider 1 (no signature) - function call + response + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { name: 'func1', args: { a: 1 } } + }), + new Date(), + true, + false, + 'call-1' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + functionResponse: { name: 'func1', response: 'result1' } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Response 1'), + + // Provider 2 (with signature) - function call + response + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { name: 'func2', args: { b: 2 } } + }), + new Date(), + true, + false, + undefined, + 'sig2==' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'resp-2', + functionResponse: { name: 'func2', response: 'result2' } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Response 2') + ]; + + const result = (gemini as any).extractContents(conversation); + + // All content should be present + expect(result.length).toBe(7); + + // First function call should be legacy format + expect(result[1].parts[0]).toHaveProperty('text'); + expect(result[1].parts[0].text).toContain('[Legacy Tool Call] func1'); + + // First response should be legacy format + expect(result[2].parts[0]).toHaveProperty('text'); + expect(result[2].parts[0].text).toContain('[Legacy Tool Result] func1'); + + // Second function call should have proper format + expect(result[4].parts[0]).toHaveProperty('functionCall'); + expect(result[4].parts[0]).toHaveProperty('thoughtSignature'); + expect(result[4].parts[0].thoughtSignature).toBe('sig2=='); + + // Second response should have proper format + expect(result[5].parts[0]).toHaveProperty('functionResponse'); + }); + }); + + describe('Claude ↔ OpenAI Switching', () => { + it('should handle Claude function call when switching to OpenAI', () => { + // Simulate a conversation started with Claude that made a function call + const claudeFunctionCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_abc123', // Claude's tool_use ID + name: 'search_vault_files', + args: { query: 'meeting notes' } + } + }), + new Date(), + true, + false, + 'toolu_abc123', // toolId from Claude + undefined // No thoughtSignature + ); + + const claudeResponse = (() => { + const responseContent = JSON.stringify({ + id: 'toolu_abc123', + functionResponse: { + name: 'search_vault_files', + response: ['meeting1.md', 'meeting2.md'] + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(); + + // Now switch to OpenAI - it should read Claude's function call + const openai = new OpenAI(); + const result = (openai as any).extractContents([claudeFunctionCall, claudeResponse]); + + // OpenAI should convert Claude's function call to its Responses API format + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'function_call', + call_id: 'toolu_abc123', + name: 'search_vault_files', + arguments: '{"query":"meeting notes"}' + }); + expect(result[1]).toEqual({ + type: 'function_call_output', + call_id: 'toolu_abc123', + output: '["meeting1.md","meeting2.md"]' + }); + }); + + it('should handle OpenAI function call when switching to Claude', () => { + // Simulate a conversation started with OpenAI + const openaiFunctionCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_xyz789', // OpenAI's call_id + name: 'read_file', + args: { path: 'project.md' } + } + }), + new Date(), + true, + false, + 'call_xyz789', // OpenAI's toolId + undefined // No thoughtSignature + ); + + const openaiResponse = (() => { + const responseContent = JSON.stringify({ + id: 'call_xyz789', + functionResponse: { + name: 'read_file', + response: { content: 'Project details here' } + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(); + + // Now switch to Claude - it should read OpenAI's function call + const claude = new Claude(); + const result = (claude as any).extractContents([openaiFunctionCall, openaiResponse]); + + // Claude should convert OpenAI's function call to its tool_use format + expect(result).toHaveLength(2); + expect(result[0].content[0]).toEqual({ + type: 'tool_use', + id: 'call_xyz789', + name: 'read_file', + input: { path: 'project.md' } + }); + expect(result[1].content[0]).toEqual({ + type: 'tool_result', + tool_use_id: 'call_xyz789', + content: '{"content":"Project details here"}' + }); + }); + + it('should handle conversation alternating between Claude and OpenAI', () => { + // Complex scenario: Claude → OpenAI → Claude → OpenAI + const conversation = [ + // User starts + new ConversationContent(Role.User, 'Search for files', 'Search for files'), + + // Claude makes a function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_1', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + 'toolu_1' + ); + return content; + })(), + + // Function response + (() => { + const responseContent = JSON.stringify({ + id: 'toolu_1', + functionResponse: { name: 'search_vault_files', response: ['file1.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + // Claude responds + new ConversationContent(Role.Assistant, 'Found file1.md'), + + // User continues + new ConversationContent(Role.User, 'Read it', 'Read it'), + + // OpenAI makes a function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_2', + name: 'read_file', + args: { path: 'file1.md' } + } + }), + new Date(), + true, + false, + 'call_2' + ); + return content; + })(), + + // Function response + (() => { + const responseContent = JSON.stringify({ + id: 'call_2', + functionResponse: { name: 'read_file', response: { content: 'File contents' } } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + // Test that both Claude and OpenAI can read this mixed conversation + const claude = new Claude(); + const claudeResult = (claude as any).extractContents(conversation); + + expect(claudeResult.length).toBeGreaterThan(0); + // Both function calls should be present with their tool_use format + const claudeToolUses = claudeResult.filter((r: any) => + r.content.some((c: any) => c.type === 'tool_use') + ); + expect(claudeToolUses).toHaveLength(2); + + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents(conversation); + + expect(openaiResult.length).toBeGreaterThan(0); + // Both function calls should be present in Responses API format + const openaiFunctionCalls = openaiResult.filter((r: any) => r.type === 'function_call'); + expect(openaiFunctionCalls).toHaveLength(2); + }); + + it('should handle function call with toolId but empty string', () => { + // Both Claude and OpenAI should treat empty string toolId as missing + const emptyToolIdCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: '', // Empty string + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + '', // Empty toolId + undefined + ); + + // Claude should convert to legacy text format + const claude = new Claude(); + const claudeResult = (claude as any).extractContents([emptyToolIdCall]); + expect(claudeResult[0].content[0].type).toBe('text'); + expect(claudeResult[0].content[0].text).toContain('[Legacy Tool Call]'); + + // OpenAI should also handle it gracefully (fall back to regular message) + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents([emptyToolIdCall]); + // Should either convert to text or handle the empty ID + expect(openaiResult).toHaveLength(1); + }); + + it('should handle whitespace-only toolId same as empty string', () => { + const whitespaceToolIdCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: ' ', // Whitespace only + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + ' ', // Whitespace toolId + undefined + ); + + // Claude should trim and treat as empty → legacy format + const claude = new Claude(); + const claudeResult = (claude as any).extractContents([whitespaceToolIdCall]); + expect(claudeResult[0].content[0].type).toBe('text'); + expect(claudeResult[0].content[0].text).toContain('[Legacy Tool Call]'); + }); + }); + + describe('OpenAI → Claude/Gemini Switching', () => { + it('should handle OpenAI function call when switching to Claude', () => { + // Detailed test already covered in "Claude ↔ OpenAI Switching" + // This test focuses on verifying Claude properly interprets OpenAI's call_id + const openaiFunctionCall = new ConversationContent( + Role.Assistant, + 'Let me read that file for you', + '', + JSON.stringify({ + functionCall: { + id: 'call_openai_123', + name: 'read_file', + args: { path: 'notes.md' } + } + }), + new Date(), + true, + false, + 'call_openai_123', + undefined + ); + + const claude = new Claude(); + const result = (claude as any).extractContents([openaiFunctionCall]); + + // Claude should preserve the OpenAI call_id in its tool_use format + expect(result).toHaveLength(1); + expect(result[0].content).toHaveLength(2); // text + tool_use + expect(result[0].content[0].type).toBe('text'); + expect(result[0].content[0].text).toBe('Let me read that file for you'); + expect(result[0].content[1]).toEqual({ + type: 'tool_use', + id: 'call_openai_123', + name: 'read_file', + input: { path: 'notes.md' } + }); + }); + + it('should convert OpenAI function call (no thoughtSignature) to Gemini legacy format', () => { + // OpenAI function call should be converted to legacy text when sent to Gemini + const openaiFunctionCall = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_abc', + name: 'search_vault_files', + args: { query: 'project notes' } + } + }), + new Date(), + true, + false, + 'call_abc', + undefined // No thoughtSignature from OpenAI + ); + + const result = (gemini as any).extractContents([openaiFunctionCall]); + + // Gemini should convert to legacy format since there's no thoughtSignature + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Call]'); + expect(result[0].parts[0].text).toContain('search_vault_files'); + expect(result[0].parts[0].text).toContain('project notes'); + }); + + it('should handle OpenAI function response when switching to Gemini', () => { + // OpenAI response with ID should work with Gemini + const openaiResponse = (() => { + const responseContent = JSON.stringify({ + id: 'call_123', + functionResponse: { + name: 'search_vault_files', + response: ['file1.md', 'file2.md'] + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(); + + const result = (gemini as any).extractContents([openaiResponse]); + + // Gemini should accept the response with ID + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toEqual({ + functionResponse: { + name: 'search_vault_files', + response: ['file1.md', 'file2.md'] + } + }); + }); + + it('should handle OpenAI → Gemini → Claude round-trip', () => { + // Test that function call survives multiple provider switches + const conversation = [ + new ConversationContent(Role.User, 'Search files', 'Search files'), + + // OpenAI creates function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_xyz', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + 'call_xyz' + ); + return content; + })(), + + // Response + (() => { + const responseContent = JSON.stringify({ + id: 'call_xyz', + functionResponse: { name: 'search_vault_files', response: ['file.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Found file.md') + ]; + + // Gemini reads OpenAI conversation - function call becomes legacy + const geminiResult = (gemini as any).extractContents(conversation); + const geminiLegacyCall = geminiResult.find((r: any) => + r.parts[0]?.text?.includes('[Legacy Tool Call]') + ); + expect(geminiLegacyCall).toBeDefined(); + + // Claude reads the same conversation - should work + const claude = new Claude(); + const claudeResult = (claude as any).extractContents(conversation); + expect(claudeResult.length).toBeGreaterThan(0); + + // OpenAI can re-read its own conversation + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents(conversation); + expect(openaiResult.length).toBeGreaterThan(0); + const functionCall = openaiResult.find((r: any) => r.type === 'function_call'); + expect(functionCall).toBeDefined(); + expect(functionCall.call_id).toBe('call_xyz'); + }); + }); + + describe('Three-Way Provider Switching', () => { + it('should handle Claude → OpenAI → Gemini conversation', () => { + // Complex three-way conversation: toolId → toolId → thoughtSignature transition + const conversation = [ + new ConversationContent(Role.User, 'Start search', 'Start search'), + + // Claude makes a function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_1', + name: 'search_vault_files', + args: { query: 'notes' } + } + }), + new Date(), + true, + false, + 'toolu_1' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'toolu_1', + functionResponse: { name: 'search_vault_files', response: ['note1.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Found note1.md'), + new ConversationContent(Role.User, 'Read it', 'Read it'), + + // OpenAI makes a function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_2', + name: 'read_file', + args: { path: 'note1.md' } + } + }), + new Date(), + true, + false, + 'call_2' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'call_2', + functionResponse: { name: 'read_file', response: { content: 'File contents' } } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Here is the content'), + new ConversationContent(Role.User, 'Summarize it', 'Summarize it') + ]; + + // Gemini should be able to read this conversation + // Both Claude and OpenAI calls should convert to legacy format (no thoughtSignature) + const geminiResult = (gemini as any).extractContents(conversation); + expect(geminiResult.length).toBeGreaterThan(0); + + const legacyCalls = geminiResult.filter((r: any) => + r.parts[0]?.text?.includes('[Legacy Tool Call]') + ); + expect(legacyCalls.length).toBe(2); // Both Claude and OpenAI calls + + // Claude should be able to read the full conversation + const claude = new Claude(); + const claudeResult = (claude as any).extractContents(conversation); + expect(claudeResult.length).toBeGreaterThan(0); + + // OpenAI should be able to read the full conversation + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents(conversation); + expect(openaiResult.length).toBeGreaterThan(0); + }); + + it('should handle Gemini → Claude → OpenAI conversation', () => { + // Three-way: thoughtSignature → toolId → toolId transition + const conversation = [ + new ConversationContent(Role.User, 'Search files', 'Search files'), + + // Gemini makes a function call WITH thoughtSignature + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'project' } + } + }), + new Date(), + true, + false, + undefined, + 'gemini_signature_1==' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'resp_1', + functionResponse: { name: 'search_vault_files', response: ['project.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Found project.md'), + new ConversationContent(Role.User, 'Read it', 'Read it'), + + // Claude makes a function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_2', + name: 'read_file', + args: { path: 'project.md' } + } + }), + new Date(), + true, + false, + 'toolu_2' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'toolu_2', + functionResponse: { name: 'read_file', response: { content: 'Project info' } } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Project details...'), + new ConversationContent(Role.User, 'Write summary', 'Write summary'), + + // OpenAI makes a function call + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_3', + name: 'write_file', + args: { path: 'summary.md', content: 'Summary here' } + } + }), + new Date(), + true, + false, + 'call_3' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'call_3', + functionResponse: { name: 'write_file', response: { success: true } } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + // All three providers should be able to read this conversation + const geminiResult = (gemini as any).extractContents(conversation); + expect(geminiResult.length).toBeGreaterThan(0); + + const claudeResult = (new Claude() as any).extractContents(conversation); + expect(claudeResult.length).toBeGreaterThan(0); + + const openaiResult = (new OpenAI() as any).extractContents(conversation); + expect(openaiResult.length).toBeGreaterThan(0); + + // Verify all three function calls are present in each provider's view + const geminiFunctionCalls = geminiResult.filter((r: any) => + r.parts[0]?.functionCall || r.parts[0]?.text?.includes('[Legacy Tool Call]') + ); + expect(geminiFunctionCalls.length).toBe(3); + + const claudeToolUses = claudeResult.filter((r: any) => + r.content.some((c: any) => c.type === 'tool_use' || c.text?.includes('[Legacy Tool Call]')) + ); + expect(claudeToolUses.length).toBeGreaterThan(0); + }); + + it('should handle round-trip: Claude → Gemini → Claude', () => { + // Ensure conversation data survives round trip through different provider + const conversation = [ + new ConversationContent(Role.User, 'Test', 'Test'), + + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_round', + name: 'search_vault_files', + args: { query: 'test', limit: 5 } + } + }), + new Date(), + true, + false, + 'toolu_round' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'toolu_round', + functionResponse: { name: 'search_vault_files', response: ['a.md', 'b.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Found 2 files') + ]; + + // Claude reads it initially + const claude1 = new Claude(); + const claudeResult1 = (claude1 as any).extractContents(conversation); + expect(claudeResult1.length).toBeGreaterThan(0); + + // Verify function call is preserved + const claudeCall1 = claudeResult1.find((r: any) => + r.content.some((c: any) => c.type === 'tool_use') + ); + expect(claudeCall1).toBeDefined(); + expect(claudeCall1.content[0].id).toBe('toolu_round'); + expect(claudeCall1.content[0].input).toEqual({ query: 'test', limit: 5 }); + + // Gemini reads it (converts to legacy) + const geminiResult = (gemini as any).extractContents(conversation); + const geminiLegacy = geminiResult.find((r: any) => + r.parts[0]?.text?.includes('[Legacy Tool Call]') + ); + expect(geminiLegacy).toBeDefined(); + expect(geminiLegacy.parts[0].text).toContain('search_vault_files'); + expect(geminiLegacy.parts[0].text).toContain('test'); + + // Claude reads it again (should work the same way) + const claude2 = new Claude(); + const claudeResult2 = (claude2 as any).extractContents(conversation); + expect(claudeResult2).toEqual(claudeResult1); // Should be identical + }); + + it('should handle round-trip: OpenAI → Gemini → OpenAI', () => { + const conversation = [ + new ConversationContent(Role.User, 'Search', 'Search'), + + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_round', + name: 'search_vault_files', + args: { query: 'openai test' } + } + }), + new Date(), + true, + false, + 'call_round' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'call_round', + functionResponse: { name: 'search_vault_files', response: ['file.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Found file') + ]; + + // OpenAI reads initially + const openai1 = new OpenAI(); + const openaiResult1 = (openai1 as any).extractContents(conversation); + expect(openaiResult1.length).toBeGreaterThan(0); + + const openaiCall1 = openaiResult1.find((r: any) => r.type === 'function_call'); + expect(openaiCall1).toBeDefined(); + expect(openaiCall1.call_id).toBe('call_round'); + + // Gemini reads (converts to legacy) + const geminiResult = (gemini as any).extractContents(conversation); + expect(geminiResult.length).toBeGreaterThan(0); + + // OpenAI reads again (should be consistent) + const openai2 = new OpenAI(); + const openaiResult2 = (openai2 as any).extractContents(conversation); + expect(openaiResult2).toEqual(openaiResult1); + }); + + it('should handle complex multi-switch with function calls at each step', () => { + // Ultimate test: Each provider makes a function call in sequence + const conversation = [ + new ConversationContent(Role.User, 'Complex workflow', 'Complex workflow'), + + // Step 1: Claude + (() => { + const content = new ConversationContent( + Role.Assistant, + 'Searching...', + '', + JSON.stringify({ + functionCall: { + id: 'toolu_step1', + name: 'search_vault_files', + args: { query: 'workflow' } + } + }), + new Date(), + true, + false, + 'toolu_step1' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'toolu_step1', + functionResponse: { name: 'search_vault_files', response: ['workflow.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.User, 'Read it', 'Read it'), + + // Step 2: OpenAI + (() => { + const content = new ConversationContent( + Role.Assistant, + 'Reading file...', + '', + JSON.stringify({ + functionCall: { + id: 'call_step2', + name: 'read_file', + args: { path: 'workflow.md' } + } + }), + new Date(), + true, + false, + 'call_step2' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'call_step2', + functionResponse: { name: 'read_file', response: { content: 'Workflow steps...' } } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.User, 'List all files', 'List all files'), + + // Step 3: Gemini (with thoughtSignature) + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'list_files', + args: { path: '/' } + } + }), + new Date(), + true, + false, + undefined, + 'gemini_step3_signature==' + ); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'resp_step3', + functionResponse: { name: 'list_files', response: ['file1.md', 'file2.md'] } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })(), + + new ConversationContent(Role.Assistant, 'Complete!') + ]; + + // All providers should handle this complex conversation + const claudeResult = (new Claude() as any).extractContents(conversation); + expect(claudeResult.length).toBeGreaterThan(5); + + const openaiResult = (new OpenAI() as any).extractContents(conversation); + expect(openaiResult.length).toBeGreaterThan(5); + + const geminiResult = (gemini as any).extractContents(conversation); + expect(geminiResult.length).toBeGreaterThan(5); + + // Each provider should see all 3 function calls (in their own format or legacy) + const claudeToolUses = claudeResult.filter((r: any) => + r.content.some((c: any) => c.type === 'tool_use' || c.text?.includes('[Legacy Tool Call]')) + ); + expect(claudeToolUses.length).toBe(3); + + const openaiFunctionCalls = openaiResult.filter((r: any) => + r.type === 'function_call' || r.type === 'message' + ); + expect(openaiFunctionCalls.length).toBeGreaterThan(0); + + // Gemini should have 1 native call (with signature) and 2 legacy calls + const geminiNativeCalls = geminiResult.filter((r: any) => + r.parts[0]?.functionCall && r.parts[0]?.thoughtSignature + ); + expect(geminiNativeCalls.length).toBe(1); + + const geminiLegacyCalls = geminiResult.filter((r: any) => + r.parts[0]?.text?.includes('[Legacy Tool Call]') + ); + // Note: Orphaned function calls without responses are filtered out by filterConversationContents + // So we verify at least the function calls with responses are present + expect(geminiLegacyCalls.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty thoughtSignature as missing signature (legacy format)', () => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { name: 'test_func', args: {} } + }), + new Date(), + true, + false, + undefined, + '' // Empty string should be treated as missing + ); + + const result = (gemini as any).extractContents([content]); + + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Call]'); + }); + + it('should handle whitespace-only thoughtSignature as missing signature (legacy format)', () => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { name: 'test_func', args: {} } + }), + new Date(), + true, + false, + undefined, + ' ' // Whitespace only - should be treated as empty after trim() + ); + + const result = (gemini as any).extractContents([content]); + + // Whitespace-only signature is trimmed and treated as missing + // Should fall back to legacy text format + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Call]'); + }); + + it('should gracefully handle conversation with only legacy format calls', () => { + // Entire conversation from Claude - no signatures anywhere + const conversation = [ + new ConversationContent(Role.User, 'Test', 'Test'), + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ functionCall: { name: 'func1', args: {} } }), + new Date(), + true + ); + return content; + })(), + (() => { + const responseContent = JSON.stringify({ + functionResponse: { name: 'func1', response: 'ok' } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + const result = (gemini as any).extractContents(conversation); + + // All function calls/responses should be in legacy format + expect(result[1].parts[0].text).toContain('[Legacy Tool Call]'); + expect(result[2].parts[0].text).toContain('[Legacy Tool Result]'); + }); + + it('should handle both toolId and thoughtSignature present (defensive)', () => { + // This tests the unusual scenario where both provider-specific fields are set + // In production, AIFunctionCall.toConversationString() ensures the id is in the JSON + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'tool-123', // ID in the JSON (as AIFunctionCall does) + name: 'test_func', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + 'tool-123', // toolId metadata (matches JSON id) + 'signature==' // Also has thoughtSignature (unusual but possible) + ); + + // Gemini should use thoughtSignature (its provider-specific field) + const geminiResult = (gemini as any).extractContents([content]); + expect(geminiResult[0].parts[0]).toHaveProperty('functionCall'); + expect(geminiResult[0].parts[0]).toHaveProperty('thoughtSignature'); + expect(geminiResult[0].parts[0].thoughtSignature).toBe('signature=='); + + // Claude reads from the JSON id field (not the metadata toolId field) + const claude = new Claude(); + const claudeResult = (claude as any).extractContents([content]); + expect(claudeResult[0].content[0]).toEqual({ + type: 'tool_use', + id: 'tool-123', + name: 'test_func', + input: { query: 'test' } + }); + + // OpenAI also reads from the JSON id field + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents([content]); + expect(openaiResult[0]).toEqual({ + type: 'function_call', + call_id: 'tool-123', + name: 'test_func', + arguments: '{"query":"test"}' + }); + }); + + it('should handle inconsistent state: toolId set but id missing from JSON (data corruption)', () => { + // Edge case: metadata field has toolId but the serialized JSON doesn't have id + // This should never happen in production (AIFunctionCall prevents this) + // but tests defensive behavior + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { name: 'test_func', args: { query: 'test' } } // No id field! + }), + new Date(), + true, + false, + 'tool-123', // toolId metadata is set + undefined + ); + + // Claude should convert to legacy format (defensive: no id in JSON) + const claude = new Claude(); + const claudeResult = (claude as any).extractContents([content]); + expect(claudeResult[0].content[0].type).toBe('text'); + expect(claudeResult[0].content[0].text).toContain('[Legacy Tool Call]'); + + // OpenAI should also fall back gracefully + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents([content]); + // OpenAI should handle this - either legacy conversion or error message + expect(openaiResult).toHaveLength(1); + }); + + it('should handle function response ID mismatch gracefully', () => { + // Function call with one ID, response with different ID + const conversation = [ + (() => { + const content = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call-123', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + 'call-123' + ); + return content; + })(), + (() => { + const responseContent = JSON.stringify({ + id: 'call-456', // DIFFERENT ID + functionResponse: { + name: 'search_vault_files', + response: ['file.md'] + } + }); + const content = new ConversationContent(Role.User, responseContent, responseContent); + content.isFunctionCallResponse = true; + return content; + })() + ]; + + // Providers should still parse this (even if IDs don't match) + const claude = new Claude(); + const claudeResult = (claude as any).extractContents(conversation); + expect(claudeResult).toHaveLength(2); + expect(claudeResult[0].content[0].id).toBe('call-123'); + expect(claudeResult[1].content[0].tool_use_id).toBe('call-456'); + + const openai = new OpenAI(); + const openaiResult = (openai as any).extractContents(conversation); + expect(openaiResult).toHaveLength(2); + expect(openaiResult[0].call_id).toBe('call-123'); + expect(openaiResult[1].call_id).toBe('call-456'); + }); + }); +}); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index 31ad32b..00e7370 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -168,6 +168,48 @@ describe('Gemini', () => { expect((gemini as any).accumulatedFunctionArgs).toEqual({ query: 'test' }); }); + it('should capture thoughtSignature when present in part', () => { + const signature = 'base64EncodedSignature=='; + const chunk = JSON.stringify({ + candidates: [{ + content: { + parts: [{ + functionCall: { + name: 'search_vault_files', + args: { query: 'test' } + }, + thoughtSignature: signature + }] + } + }] + }); + + (gemini as any).parseStreamChunk(chunk); + + expect((gemini as any).accumulatedFunctionName).toBe('search_vault_files'); + expect((gemini as any).accumulatedFunctionArgs).toEqual({ query: 'test' }); + expect((gemini as any).accumulatedThoughtSignature).toBe(signature); + }); + + it('should not set thoughtSignature when not present in part', () => { + const chunk = JSON.stringify({ + candidates: [{ + content: { + parts: [{ + functionCall: { + name: 'search_vault_files', + args: { query: 'test' } + } + }] + } + }] + }); + + (gemini as any).parseStreamChunk(chunk); + + expect((gemini as any).accumulatedThoughtSignature).toBeNull(); + }); + it('should merge function arguments incrementally (object spread)', () => { // First chunk with partial args (gemini as any).parseStreamChunk(JSON.stringify({ @@ -226,6 +268,47 @@ describe('Gemini', () => { expect(result.functionCall?.arguments).toEqual({ query: 'test' }); }); + it('should finalize function call with thoughtSignature on completion', () => { + const signature = 'finalSignature=='; + // Setup accumulated state + (gemini as any).accumulatedFunctionName = 'search_vault_files'; + (gemini as any).accumulatedFunctionArgs = { query: 'test' }; + (gemini as any).accumulatedThoughtSignature = signature; + + const chunk = JSON.stringify({ + candidates: [{ + finishReason: 'FUNCTION_CALL' + }] + }); + + const result = (gemini as any).parseStreamChunk(chunk); + + expect(result.isComplete).toBe(true); + expect(result.shouldContinue).toBe(true); + expect(result.functionCall).toBeDefined(); + expect(result.functionCall?.name).toBe('search_vault_files'); + expect(result.functionCall?.arguments).toEqual({ query: 'test' }); + expect(result.functionCall?.thoughtSignature).toBe(signature); + }); + + it('should finalize function call without thoughtSignature when not accumulated', () => { + // Setup accumulated state without signature + (gemini as any).accumulatedFunctionName = 'search_vault_files'; + (gemini as any).accumulatedFunctionArgs = { query: 'test' }; + (gemini as any).accumulatedThoughtSignature = null; + + const chunk = JSON.stringify({ + candidates: [{ + finishReason: 'FUNCTION_CALL' + }] + }); + + const result = (gemini as any).parseStreamChunk(chunk); + + expect(result.functionCall).toBeDefined(); + expect(result.functionCall?.thoughtSignature).toBeUndefined(); + }); + it('should detect completion with STOP finish reason', () => { const chunk = JSON.stringify({ candidates: [{ @@ -352,7 +435,7 @@ describe('Gemini', () => { expect(requestBody.system_instruction.parts[2].text).toBe('User instruction'); }); - it('should convert function call to Gemini format', () => { + it('should convert function call to Gemini format (with signature from Gemini)', () => { const functionCallContent = new ConversationContent( Role.Assistant, '', @@ -364,7 +447,10 @@ describe('Gemini', () => { } }), new Date(), - true + true, + false, + undefined, + 'geminiSignatureFromAPI==' // Has signature from Gemini ); const result = (gemini as any).extractContents([functionCallContent]); @@ -376,12 +462,101 @@ describe('Gemini', () => { functionCall: { name: 'search_vault_files', args: { query: 'test' } - } + }, + thoughtSignature: 'geminiSignatureFromAPI==' }); }); + it('should convert function call with thoughtSignature to Gemini format with signature', () => { + const signature = 'geminiSignature=='; + const functionCallContent = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + undefined, + signature + ); + + const result = (gemini as any).extractContents([functionCallContent]); + + expect(result).toHaveLength(1); + expect(result[0].role).toBe(Role.Model); + expect(result[0].parts).toHaveLength(1); + expect(result[0].parts[0]).toEqual({ + functionCall: { + name: 'search_vault_files', + args: { query: 'test' } + }, + thoughtSignature: signature + }); + }); + + it('should fall back to legacy text format for function call without thoughtSignature (cross-provider)', () => { + const functionCallContent = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true, + false, + undefined, + undefined // No thoughtSignature (came from Claude/OpenAI) + ); + + const result = (gemini as any).extractContents([functionCallContent]); + + expect(result).toHaveLength(1); + expect(result[0].role).toBe(Role.Model); + expect(result[0].parts).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Call]'); + expect(result[0].parts[0].text).toContain('search_vault_files'); + expect(result[0].parts[0].text).toContain('Input:'); + }); + + it('should fall back to legacy text format for function call with empty thoughtSignature', () => { + const functionCallContent = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + name: 'read_file', + args: { path: 'note.md' } + } + }), + new Date(), + true, + false, + undefined, + '' // Empty thoughtSignature + ); + + const result = (gemini as any).extractContents([functionCallContent]); + + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Call] read_file'); + }); + it('should convert function response to Gemini format', () => { const responseContent = JSON.stringify({ + id: 'call-123', functionResponse: { name: 'search_vault_files', response: ['file1.txt', 'file2.txt'] @@ -407,6 +582,52 @@ describe('Gemini', () => { }); }); + it('should fall back to legacy text format for function response without id (cross-provider)', () => { + const responseContent = JSON.stringify({ + functionResponse: { + name: 'search_vault_files', + response: ['file1.txt', 'file2.txt'] + } + }); + const functionResponseContent = new ConversationContent( + Role.User, + responseContent, + responseContent + ); + functionResponseContent.isFunctionCallResponse = true; + + const result = (gemini as any).extractContents([functionResponseContent]); + + expect(result).toHaveLength(1); + expect(result[0].parts).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Result]'); + expect(result[0].parts[0].text).toContain('search_vault_files'); + expect(result[0].parts[0].text).toContain('Result:'); + }); + + it('should fall back to legacy text format for function response with empty id', () => { + const responseContent = JSON.stringify({ + id: '', + functionResponse: { + name: 'read_file', + response: { content: 'file contents' } + } + }); + const functionResponseContent = new ConversationContent( + Role.User, + responseContent, + responseContent + ); + functionResponseContent.isFunctionCallResponse = true; + + const result = (gemini as any).extractContents([functionResponseContent]); + + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('[Legacy Tool Result] read_file'); + }); + it('should handle invalid JSON in function call gracefully', () => { const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); @@ -421,8 +642,10 @@ describe('Gemini', () => { const result = (gemini as any).extractContents([invalidContent]); - // Should be filtered out since it has no valid parts (no text, invalid function call) - expect(result).toHaveLength(0); + // Should fallback to error message as text (since content is empty and function call is invalid) + // The implementation includes an error message as text when parsing fails + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); expect(exceptionSpy).toHaveBeenCalled(); exceptionSpy.mockRestore(); @@ -494,7 +717,7 @@ describe('Gemini', () => { it('should include function call when it has a corresponding response', () => { const contents = [ new ConversationContent(Role.User, 'Search for files', 'Search for files'), - // Function call with response (not orphaned) + // Function call with response (not orphaned) - with thoughtSignature new ConversationContent( Role.Assistant, '', @@ -506,11 +729,15 @@ describe('Gemini', () => { } }), new Date(), - true + true, + false, + undefined, + 'signature123==' // Has signature ), // Corresponding function response (() => { const responseContent = JSON.stringify({ + id: 'resp-1', functionResponse: { name: 'search_vault_files', response: ['file1.txt'] @@ -533,7 +760,7 @@ describe('Gemini', () => { it('should include function call when it is the most recent item', () => { const contents = [ new ConversationContent(Role.User, 'Search for files', 'Search for files'), - // Function call as most recent item (should be included) + // Function call as most recent item (should be included) - with signature new ConversationContent( Role.Assistant, '', @@ -545,7 +772,10 @@ describe('Gemini', () => { } }), new Date(), - true + true, + false, + undefined, + 'latestCallSignature==' ) ]; @@ -557,7 +787,8 @@ describe('Gemini', () => { functionCall: { name: 'search_vault_files', args: { query: 'test' } - } + }, + thoughtSignature: 'latestCallSignature==' }); }); @@ -606,6 +837,124 @@ describe('Gemini', () => { }); }); + describe('Helper Methods', () => { + describe('convertFunctionCallToText', () => { + it('should convert function call to legacy text format', () => { + const parsedContent = { + functionCall: { + name: 'search_vault_files', + args: { query: 'test notes' } + } + }; + + const result = (gemini as any).convertFunctionCallToText(parsedContent); + + expect(result).toContain('[Legacy Tool Call]'); + expect(result).toContain('search_vault_files'); + expect(result).toContain('Input:'); + expect(result).toContain('"query":"test notes"'); + }); + + it('should format complex arguments correctly', () => { + const parsedContent = { + functionCall: { + name: 'write_file', + args: { + path: 'note.md', + content: 'Hello World', + metadata: { tags: ['important'] } + } + } + }; + + const result = (gemini as any).convertFunctionCallToText(parsedContent); + + expect(result).toContain('[Legacy Tool Call] write_file'); + expect(result).toContain('Input:'); + expect(result).toContain('"path":"note.md"'); + expect(result).toContain('"content":"Hello World"'); + expect(result).toContain('"metadata"'); + }); + + it('should handle function call with empty args', () => { + const parsedContent = { + functionCall: { + name: 'list_files', + args: {} + } + }; + + const result = (gemini as any).convertFunctionCallToText(parsedContent); + + expect(result).toBe('[Legacy Tool Call] list_files\nInput: {}'); + }); + }); + + describe('convertFunctionResponseToText', () => { + it('should convert function response to legacy text format', () => { + const parsedContent = { + functionResponse: { + name: 'search_vault_files', + response: ['file1.txt', 'file2.txt', 'file3.txt'] + } + }; + + const result = (gemini as any).convertFunctionResponseToText(parsedContent); + + expect(result).toContain('[Legacy Tool Result]'); + expect(result).toContain('search_vault_files'); + expect(result).toContain('Result:'); + expect(result).toContain('file1.txt'); + expect(result).toContain('file2.txt'); + }); + + it('should format complex response objects correctly', () => { + const parsedContent = { + functionResponse: { + name: 'read_file', + response: { + content: 'File contents here', + metadata: { size: 1024, modified: '2024-01-01' } + } + } + }; + + const result = (gemini as any).convertFunctionResponseToText(parsedContent); + + expect(result).toContain('[Legacy Tool Result] read_file'); + expect(result).toContain('Result:'); + expect(result).toContain('"content":"File contents here"'); + expect(result).toContain('"metadata"'); + }); + + it('should handle empty response', () => { + const parsedContent = { + functionResponse: { + name: 'delete_file', + response: null + } + }; + + const result = (gemini as any).convertFunctionResponseToText(parsedContent); + + expect(result).toBe('[Legacy Tool Result] delete_file\nResult: null'); + }); + + it('should handle string response', () => { + const parsedContent = { + functionResponse: { + name: 'get_status', + response: 'Success' + } + }; + + const result = (gemini as any).convertFunctionResponseToText(parsedContent); + + expect(result).toBe('[Legacy Tool Result] get_status\nResult: "Success"'); + }); + }); + }); + describe('mapFunctionDefinitions', () => { it('should map function definitions to Gemini format', () => { const definitions = [ @@ -670,6 +1019,7 @@ describe('Gemini', () => { // Set some accumulated state (gemini as any).accumulatedFunctionName = 'old_func'; (gemini as any).accumulatedFunctionArgs = { old: 'args' }; + (gemini as any).accumulatedThoughtSignature = 'oldSignature'; const conversation = new Conversation(); conversation.contents.push(new ConversationContent(Role.User, 'Test')); @@ -684,6 +1034,7 @@ describe('Gemini', () => { // State should be reset (after checking web search mode) expect((gemini as any).accumulatedFunctionName).toBeNull(); expect((gemini as any).accumulatedFunctionArgs).toEqual({}); + expect((gemini as any).accumulatedThoughtSignature).toBeNull(); }); }); }); diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 202b456..e528c0a 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -138,17 +138,18 @@ describe('OpenAI', () => { expect(result.isComplete).toBe(false); }); - it('should handle complete function call in done event', () => { - // Responses API provides the complete function call in one event + it('should handle complete function call in output_item.done event', () => { + // Responses API provides the complete function call in response.output_item.done event const chunk = JSON.stringify({ - type: 'response.function_call_arguments.done', - call: { - id: 'call_123', - type: 'function', - function: { - name: 'search_vault_files', - arguments: '{"query":"test"}' - } + type: 'response.output_item.done', + item_id: 'item_123', + output_index: 0, + item: { + id: 'item_123', + type: 'function_call', + name: 'search_vault_files', + call_id: 'call_123', + arguments: '{"query":"test"}' } }); @@ -162,8 +163,9 @@ describe('OpenAI', () => { expect(result.functionCall?.toolId).toBe('call_123'); }); - it('should handle response.done event with tool calls', () => { - // response.done event indicates completion and may contain tool calls + it('should handle response.done event', () => { + // response.done event indicates completion + // In Responses API, function calls are detected via output_item.done events, not response.done const chunk = JSON.stringify({ type: 'response.done', response: { @@ -171,17 +173,9 @@ describe('OpenAI', () => { status: 'completed', output: [ { + type: 'message', role: 'assistant', - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - name: 'search_vault_files', - arguments: '{"a":1}' - } - } - ] + content: 'Done' } ] } @@ -190,7 +184,7 @@ describe('OpenAI', () => { const result = (openai as any).parseStreamChunk(chunk); expect(result.isComplete).toBe(true); - expect(result.shouldContinue).toBe(true); + expect(result.shouldContinue).toBe(false); }); it('should handle unknown event types gracefully', () => { @@ -234,14 +228,15 @@ describe('OpenAI', () => { const exceptionSpy = vi.spyOn(Exception, 'log'); const chunk = JSON.stringify({ - type: 'response.function_call_arguments.done', - call: { - id: 'call_123', - type: 'function', - function: { - name: 'search_vault_files', - arguments: 'invalid json {' - } + type: 'response.output_item.done', + item_id: 'item_123', + output_index: 0, + item: { + id: 'item_123', + type: 'function_call', + name: 'search_vault_files', + call_id: 'call_123', + arguments: 'invalid json {' } }); @@ -343,7 +338,7 @@ describe('OpenAI', () => { expect(requestBody.messages).toBeUndefined(); }); - it('should convert function call to OpenAI tool_calls format', async () => { + it('should convert function call to Responses API format', async () => { const conversation = new Conversation(); const functionCallContent = new ConversationContent( Role.Assistant, @@ -370,25 +365,31 @@ describe('OpenAI', () => { const callArgs = mockStreamingService.streamRequest.mock.calls[0]; const requestBody = callArgs[1]; - const assistantMessage = requestBody.input.find((m: any) => m.role === Role.Assistant); - expect(assistantMessage).toBeDefined(); - expect(assistantMessage.tool_calls).toHaveLength(1); - expect(assistantMessage.tool_calls[0]).toEqual({ - id: 'call_123', - type: 'function', - function: { - name: 'search_vault_files', - arguments: '{"query":"test"}' - } + // Should have 2 items: assistant message + function call + expect(requestBody.input).toHaveLength(2); + + // First item: assistant message with text + expect(requestBody.input[0]).toEqual({ + role: Role.Assistant, + content: 'Let me search' + }); + + // Second item: function call + expect(requestBody.input[1]).toEqual({ + type: 'function_call', + call_id: 'call_123', + name: 'search_vault_files', + arguments: '{"query":"test"}' }); }); - it('should convert function response to role:tool format', async () => { + it('should convert function response to function_call_output format', async () => { const conversation = new Conversation(); const responseContent = JSON.stringify({ id: 'call_123', functionResponse: { + name: 'search_vault_files', response: ['file1.txt', 'file2.txt'] } }); @@ -409,11 +410,14 @@ describe('OpenAI', () => { const callArgs = mockStreamingService.streamRequest.mock.calls[0]; const requestBody = callArgs[1]; - const toolMessage = requestBody.input.find((m: any) => m.role === 'tool'); - expect(toolMessage).toBeDefined(); - expect(toolMessage.tool_call_id).toBe('call_123'); - expect(toolMessage.content).toBe(JSON.stringify(['file1.txt', 'file2.txt'])); + // Should have 1 function_call_output item + expect(requestBody.input).toHaveLength(1); + expect(requestBody.input[0]).toEqual({ + type: 'function_call_output', + call_id: 'call_123', + output: '["file1.txt","file2.txt"]' + }); }); it('should handle invalid JSON in function call gracefully', async () => { @@ -573,10 +577,23 @@ describe('OpenAI', () => { const callArgs = mockStreamingService.streamRequest.mock.calls[0]; const requestBody = callArgs[1]; - // Should have all 3 items (function call has response) + // Should have 3 items: user message, function_call, function_call_output expect(requestBody.input).toHaveLength(3); - expect(requestBody.input[1].tool_calls).toBeDefined(); - expect(requestBody.input[2].role).toBe('tool'); + expect(requestBody.input[0]).toEqual({ + role: Role.User, + content: 'Search for files' + }); + expect(requestBody.input[1]).toEqual({ + type: 'function_call', + call_id: 'call_123', + name: 'search_vault_files', + arguments: '{"query":"test"}' + }); + expect(requestBody.input[2]).toEqual({ + type: 'function_call_output', + call_id: 'call_123', + output: '["file1.txt"]' + }); }); it('should include function call when it is the most recent item', async () => { @@ -609,10 +626,18 @@ describe('OpenAI', () => { const callArgs = mockStreamingService.streamRequest.mock.calls[0]; const requestBody = callArgs[1]; - // Should have both items (most recent function call is included) + // Should have 2 items: user message and function_call (no assistant message since content is empty) expect(requestBody.input).toHaveLength(2); - expect(requestBody.input[1].tool_calls).toBeDefined(); - expect(requestBody.input[1].tool_calls[0].id).toBe('call_latest'); + expect(requestBody.input[0]).toEqual({ + role: Role.User, + content: 'Search for files' + }); + expect(requestBody.input[1]).toEqual({ + type: 'function_call', + call_id: 'call_latest', + name: 'search_vault_files', + arguments: '{"query":"test"}' + }); }); it('should handle multiple orphaned function calls correctly', async () => { @@ -669,6 +694,212 @@ describe('OpenAI', () => { expect(requestBody.input[1].content).toBe('Second message'); expect(requestBody.input[2].content).toBe('Third message'); }); + + describe('Responses API Format Edge Cases', () => { + it('should handle assistant message with both text and function call', async () => { + const conversation = new Conversation(); + const functionCallContent = new ConversationContent( + Role.Assistant, + 'I will search for that.', + '', + JSON.stringify({ + functionCall: { + id: 'call_123', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true + ); + conversation.contents.push(functionCallContent); + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'done', isComplete: true }; + }); + + const generator = openai.streamRequest(conversation, true); + for await (const chunk of generator) {} + + const callArgs = mockStreamingService.streamRequest.mock.calls[0]; + const requestBody = callArgs[1]; + + // Should have 2 items: assistant message + function call + expect(requestBody.input).toHaveLength(2); + expect(requestBody.input[0]).toEqual({ + role: Role.Assistant, + content: 'I will search for that.' + }); + expect(requestBody.input[1].type).toBe('function_call'); + }); + + it('should handle function call with empty text content', async () => { + const conversation = new Conversation(); + const functionCallContent = new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_123', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true + ); + conversation.contents.push(functionCallContent); + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'done', isComplete: true }; + }); + + const generator = openai.streamRequest(conversation, true); + for await (const chunk of generator) {} + + const callArgs = mockStreamingService.streamRequest.mock.calls[0]; + const requestBody = callArgs[1]; + + // Should have only 1 item: function call (no empty message) + expect(requestBody.input).toHaveLength(1); + expect(requestBody.input[0]).toEqual({ + type: 'function_call', + call_id: 'call_123', + name: 'search_vault_files', + arguments: '{"query":"test"}' + }); + }); + + it('should handle complex function response objects', async () => { + const conversation = new Conversation(); + const complexResponse = { + files: ['file1.txt', 'file2.md'], + count: 2, + metadata: { total: 100, filtered: 2 } + }; + const responseContent = JSON.stringify({ + id: 'call_123', + functionResponse: { + name: 'search_vault_files', + response: complexResponse + } + }); + const functionResponseContent = new ConversationContent( + Role.User, + responseContent, + responseContent + ); + functionResponseContent.isFunctionCallResponse = true; + conversation.contents.push(functionResponseContent); + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'done', isComplete: true }; + }); + + const generator = openai.streamRequest(conversation, true); + for await (const chunk of generator) {} + + const callArgs = mockStreamingService.streamRequest.mock.calls[0]; + const requestBody = callArgs[1]; + + expect(requestBody.input).toHaveLength(1); + expect(requestBody.input[0]).toEqual({ + type: 'function_call_output', + call_id: 'call_123', + output: JSON.stringify(complexResponse) + }); + }); + + it('should handle multiple sequential function calls and responses', async () => { + const conversation = new Conversation(); + + // First function call + conversation.contents.push(new ConversationContent( + Role.Assistant, + '', + '', + JSON.stringify({ + functionCall: { + id: 'call_1', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + new Date(), + true + )); + + // First response + const response1 = new ConversationContent( + Role.User, + JSON.stringify({ + id: 'call_1', + functionResponse: { name: 'search_vault_files', response: ['file1.txt'] } + }), + JSON.stringify({ + id: 'call_1', + functionResponse: { name: 'search_vault_files', response: ['file1.txt'] } + }) + ); + response1.isFunctionCallResponse = true; + conversation.contents.push(response1); + + // Second function call + conversation.contents.push(new ConversationContent( + Role.Assistant, + 'Let me read that file', + '', + JSON.stringify({ + functionCall: { + id: 'call_2', + name: 'read_file', + args: { path: 'file1.txt' } + } + }), + new Date(), + true + )); + + // Second response + const response2 = new ConversationContent( + Role.User, + JSON.stringify({ + id: 'call_2', + functionResponse: { name: 'read_file', response: 'file content' } + }), + JSON.stringify({ + id: 'call_2', + functionResponse: { name: 'read_file', response: 'file content' } + }) + ); + response2.isFunctionCallResponse = true; + conversation.contents.push(response2); + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'done', isComplete: true }; + }); + + const generator = openai.streamRequest(conversation, true); + for await (const chunk of generator) {} + + const callArgs = mockStreamingService.streamRequest.mock.calls[0]; + const requestBody = callArgs[1]; + + // Should have 5 items in correct order + expect(requestBody.input).toHaveLength(5); + expect(requestBody.input[0].type).toBe('function_call'); + expect(requestBody.input[0].call_id).toBe('call_1'); + expect(requestBody.input[1].type).toBe('function_call_output'); + expect(requestBody.input[1].call_id).toBe('call_1'); + expect(requestBody.input[2].role).toBe(Role.Assistant); + expect(requestBody.input[2].content).toBe('Let me read that file'); + expect(requestBody.input[3].type).toBe('function_call'); + expect(requestBody.input[3].call_id).toBe('call_2'); + expect(requestBody.input[4].type).toBe('function_call_output'); + expect(requestBody.input[4].call_id).toBe('call_2'); + }); + }); }); describe('mapFunctionDefinitions', () => { diff --git a/__tests__/Conversations/Conversation.test.ts b/__tests__/Conversations/Conversation.test.ts index ce5882a..eb18f49 100644 --- a/__tests__/Conversations/Conversation.test.ts +++ b/__tests__/Conversations/Conversation.test.ts @@ -205,13 +205,14 @@ describe('Conversation', () => { }); }); - describe('setMostRecentContent', () => { + describe('content manipulation', () => { it('should update content of most recent conversation content', () => { const conversation = new Conversation(); const content = new ConversationContent('user', 'initial'); conversation.contents.push(content); - conversation.setMostRecentContent('updated'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.content = 'updated'; expect(conversation.contents[0].content).toBe('updated'); }); @@ -222,7 +223,8 @@ describe('Conversation', () => { conversation.contents.push(new ConversationContent('assistant', 'second')); conversation.contents.push(new ConversationContent('user', 'third')); - conversation.setMostRecentContent('modified'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.content = 'modified'; expect(conversation.contents[0].content).toBe('first'); expect(conversation.contents[1].content).toBe('second'); @@ -233,7 +235,12 @@ describe('Conversation', () => { const conversation = new Conversation(); // Should not throw error - expect(() => conversation.setMostRecentContent('test')).not.toThrow(); + expect(() => { + const mostRecent = conversation.contents[conversation.contents.length - 1]; + if (mostRecent) { + mostRecent.content = 'test'; + } + }).not.toThrow(); expect(conversation.contents).toHaveLength(0); }); @@ -241,7 +248,8 @@ describe('Conversation', () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent('user', 'initial')); - conversation.setMostRecentContent(''); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.content = ''; expect(conversation.contents[0].content).toBe(''); }); @@ -251,19 +259,22 @@ describe('Conversation', () => { conversation.contents.push(new ConversationContent('user', 'initial')); const multilineContent = 'Line 1\nLine 2\nLine 3'; - conversation.setMostRecentContent(multilineContent); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.content = multilineContent; expect(conversation.contents[0].content).toBe(multilineContent); }); }); - describe('setMostRecentFunctionCall', () => { + describe('function call manipulation', () => { it('should set function call on most recent content', () => { const conversation = new Conversation(); const content = new ConversationContent('assistant'); conversation.contents.push(content); - conversation.setMostRecentFunctionCall('readFile'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.functionCall = 'readFile'; + mostRecent.isFunctionCall = true; expect(conversation.contents[0].functionCall).toBe('readFile'); }); @@ -273,7 +284,9 @@ describe('Conversation', () => { const content = new ConversationContent('assistant'); conversation.contents.push(content); - conversation.setMostRecentFunctionCall('readFile'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.functionCall = 'readFile'; + mostRecent.isFunctionCall = true; expect(conversation.contents[0].isFunctionCall).toBe(true); }); @@ -284,7 +297,9 @@ describe('Conversation', () => { conversation.contents.push(new ConversationContent('assistant')); conversation.contents.push(new ConversationContent('assistant')); - conversation.setMostRecentFunctionCall('searchFiles'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.functionCall = 'searchFiles'; + mostRecent.isFunctionCall = true; expect(conversation.contents[0].functionCall).toBe(''); expect(conversation.contents[0].isFunctionCall).toBe(false); @@ -298,7 +313,13 @@ describe('Conversation', () => { const conversation = new Conversation(); // Should not throw error - expect(() => conversation.setMostRecentFunctionCall('test')).not.toThrow(); + expect(() => { + const mostRecent = conversation.contents[conversation.contents.length - 1]; + if (mostRecent) { + mostRecent.functionCall = 'test'; + mostRecent.isFunctionCall = true; + } + }).not.toThrow(); expect(conversation.contents).toHaveLength(0); }); @@ -306,7 +327,9 @@ describe('Conversation', () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent('assistant')); - conversation.setMostRecentFunctionCall(''); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.functionCall = ''; + mostRecent.isFunctionCall = true; expect(conversation.contents[0].functionCall).toBe(''); expect(conversation.contents[0].isFunctionCall).toBe(true); @@ -317,7 +340,9 @@ describe('Conversation', () => { const content = new ConversationContent('assistant', '', '', 'oldFunction', new Date(), true); conversation.contents.push(content); - conversation.setMostRecentFunctionCall('newFunction'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.functionCall = 'newFunction'; + mostRecent.isFunctionCall = true; expect(conversation.contents[0].functionCall).toBe('newFunction'); expect(conversation.contents[0].isFunctionCall).toBe(true); @@ -337,11 +362,13 @@ describe('Conversation', () => { conversation.contents.push(assistantMessage); // Stream in assistant response - conversation.setMostRecentContent('Hi there'); - conversation.setMostRecentContent('Hi there, how can I help you?'); + const mostRecent = conversation.contents[conversation.contents.length - 1]; + mostRecent.content = 'Hi there'; + mostRecent.content = 'Hi there, how can I help you?'; // Assistant makes a function call - conversation.setMostRecentFunctionCall('readFile'); + mostRecent.functionCall = 'readFile'; + mostRecent.isFunctionCall = true; expect(conversation.contents).toHaveLength(2); expect(conversation.contents[0].role).toBe('user'); diff --git a/__tests__/Conversations/ConversationContent.test.ts b/__tests__/Conversations/ConversationContent.test.ts index 7f98119..45761df 100644 --- a/__tests__/Conversations/ConversationContent.test.ts +++ b/__tests__/Conversations/ConversationContent.test.ts @@ -26,6 +26,32 @@ describe('ConversationContent', () => { expect(content.toolId).toBe('tool-123'); }); + it('should create content with all parameters including thoughtSignature', () => { + const timestamp = new Date('2024-01-01T00:00:00.000Z'); + const signature = 'base64EncodedSignature=='; + const content = new ConversationContent( + 'user', + 'Hello', + 'promptContent', + 'functionCall', + timestamp, + true, + false, + 'tool-123', + signature + ); + + expect(content.role).toBe('user'); + expect(content.content).toBe('Hello'); + expect(content.promptContent).toBe('promptContent'); + expect(content.functionCall).toBe('functionCall'); + expect(content.timestamp).toBe(timestamp); + expect(content.isFunctionCall).toBe(true); + expect(content.isFunctionCallResponse).toBe(false); + expect(content.toolId).toBe('tool-123'); + expect(content.thoughtSignature).toBe(signature); + }); + it('should use default values when optional parameters are omitted', () => { const content = new ConversationContent('assistant'); @@ -37,6 +63,7 @@ describe('ConversationContent', () => { expect(content.isFunctionCall).toBe(false); expect(content.isFunctionCallResponse).toBe(false); expect(content.toolId).toBeUndefined(); + expect(content.thoughtSignature).toBeUndefined(); }); it('should use current date as default timestamp', () => { @@ -145,6 +172,37 @@ describe('ConversationContent', () => { expect(ConversationContent.isConversationContentData(validData)).toBe(true); }); + it('should return true for valid data with thoughtSignature', () => { + const validData = { + role: 'assistant', + content: '', + promptContent: '', + functionCall: 'readFile', + timestamp: '2024-01-01T00:00:00.000Z', + isFunctionCall: true, + isFunctionCallResponse: false, + thoughtSignature: 'base64Signature==' + }; + + expect(ConversationContent.isConversationContentData(validData)).toBe(true); + }); + + it('should return true for valid data with both toolId and thoughtSignature', () => { + const validData = { + role: 'assistant', + content: '', + promptContent: '', + functionCall: 'readFile', + timestamp: '2024-01-01T00:00:00.000Z', + isFunctionCall: true, + isFunctionCallResponse: false, + toolId: 'tool-123', + thoughtSignature: 'base64Signature==' + }; + + expect(ConversationContent.isConversationContentData(validData)).toBe(true); + }); + it('should return false when data is null', () => { expect(ConversationContent.isConversationContentData(null)).toBe(false); }); @@ -332,6 +390,35 @@ describe('ConversationContent', () => { expect(ConversationContent.isConversationContentData(validData)).toBe(true); }); + it('should return true when thoughtSignature is missing (optional field)', () => { + const validData = { + role: 'user', + content: 'Hello', + promptContent: '', + functionCall: '', + timestamp: '2024-01-01T00:00:00.000Z', + isFunctionCall: false, + isFunctionCallResponse: false + }; + + expect(ConversationContent.isConversationContentData(validData)).toBe(true); + }); + + it('should return false when thoughtSignature is not a string', () => { + const invalidData = { + role: 'user', + content: 'Hello', + promptContent: '', + functionCall: '', + timestamp: '2024-01-01T00:00:00.000Z', + isFunctionCall: false, + isFunctionCallResponse: false, + thoughtSignature: 123 + }; + + expect(ConversationContent.isConversationContentData(invalidData)).toBe(false); + }); + it('should handle edge case with empty strings', () => { const validData = { role: '', @@ -397,6 +484,13 @@ describe('ConversationContent', () => { expect(content.toolId).toBe('tool-456'); }); + + it('should allow thoughtSignature to be modified', () => { + const content = new ConversationContent('assistant'); + content.thoughtSignature = 'newSignature=='; + + expect(content.thoughtSignature).toBe('newSignature=='); + }); }); describe('edge cases', () => { diff --git a/__tests__/Services/ConversationNamingService.test.ts b/__tests__/Services/ConversationNamingService.test.ts index 028ba4c..fe7b89c 100644 --- a/__tests__/Services/ConversationNamingService.test.ts +++ b/__tests__/Services/ConversationNamingService.test.ts @@ -81,9 +81,9 @@ describe('ConversationNamingService', () => { expect(result2).toBe('Test Title'); }); - it('should limit to 6 words', async () => { + it('should not limit words', async () => { const result = await (service as any).validateName('One Two Three Four Five Six Seven Eight'); - expect(result).toBe('One Two Three Four Five Six'); + expect(result).toBe('One Two Three Four Five Six Seven Eight'); }); it('should handle duplicate names with incrementing index', async () => { @@ -109,9 +109,9 @@ describe('ConversationNamingService', () => { }).rejects.toThrow('Stack limit reached'); }); - it('should handle names with multiple spaces correctly', async () => { + it('should preserve multiple spaces in names', async () => { const result = await (service as any).validateName('Test Title With Spaces'); - expect(result).toBe('Test Title With Spaces'); + expect(result).toBe('Test Title With Spaces'); }); it('should return unique name when no duplicates exist', async () => { @@ -252,13 +252,13 @@ describe('ConversationNamingService', () => { ); }); - it('should limit name to 6 words', async () => { + it('should not limit words in generated name', async () => { mockNamingProvider.generateName.mockResolvedValue('One Two Three Four Five Six Seven Eight Nine'); mockVaultService.exists.mockReturnValue(false); await service.requestName(conversation, 'Test', onNameChanged); - expect(conversation.title).toBe('One Two Three Four Five Six'); + expect(conversation.title).toBe('One Two Three Four Five Six Seven Eight Nine'); }); }); });