feat: add Gemini thought signature support and OpenAI Responses API migration

Implement thought signature tracking for Gemini function calls to support Gemini 3 requirements. Migrate OpenAI integration from Chat Completions to Responses API with proper input/output item handling. Add cross-provider compatibility via legacy text format fallback for conversations without thought signatures or tool IDs. Improve chat auto-scroll behavior and conversation validation. Add and update AI class and conversation tests.
This commit is contained in:
Andrew Beal 2025-12-10 21:27:58 +00:00
parent 51a30613a3
commit d36815c214
27 changed files with 3493 additions and 270 deletions

View file

@ -5,11 +5,13 @@ export class AIFunctionCall {
public readonly name: AIFunction;
public readonly arguments: Record<string, unknown>;
public readonly toolId?: string;
public readonly thoughtSignature?: string;
constructor(name: AIFunction, args: Record<string, unknown>, toolId?: string) {
constructor(name: AIFunction, args: Record<string, unknown>, 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
}
});
}

View file

@ -44,7 +44,7 @@ export abstract class BaseAIClass implements IAIClass {
): AsyncGenerator<IStreamChunk, void, unknown>;
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
});
}

View file

@ -106,7 +106,8 @@ export class Claude extends BaseAIClass {
functionCall = new AIFunctionCall(
aiFunctionFromString(this.accumulatedFunctionName),
args as Record<string, object>,
this.accumulatedFunctionId || undefined
this.accumulatedFunctionId || undefined,
undefined // thoughtSignature not used by Claude
);
} catch (error) {
Exception.log(error);

View file

@ -16,6 +16,7 @@ export class Gemini extends BaseAIClass {
private accumulatedFunctionName: string | null = null;
private accumulatedFunctionArgs: Record<string, unknown> = {};
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<string, object>
this.accumulatedFunctionArgs as Record<string, object>,
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}`;
}
}

View file

@ -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<string, unknown>;
const args = JSON.parse(itemDoneEvent.item.arguments) as Record<string, unknown>;
functionCall = new AIFunctionCall(
aiFunctionFromString(toolCall.function.name),
aiFunctionFromString(itemDoneEvent.item.name),
args as Record<string, object>,
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[] {

View file

@ -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"];
}
}
/**
* 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;

View file

@ -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 @@
}
</script>
<div class="chat-area" bind:this={chatContainer}>
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll}>
{#each messages as message, index}
{#if !message.isFunctionCallResponse && message.content.trim() !== ""}
{#if message.role === Role.User}

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
const chatService: ChatService = Resolve<ChatService>(Services.ChatService);
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
const abortService: AbortService = Resolve<AbortService>(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");
}
});
}

View file

@ -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;
}
}
}

View file

@ -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")
);
}

View file

@ -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

View file

@ -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;

View file

@ -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();

View file

@ -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);

View file

@ -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
);
});

View file

@ -72,7 +72,7 @@ export class ConversationNamingService {
}
private async validateName(generatedName: string): Promise<string> {
const cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, "").split(/\s+/).slice(0, 6).join(" ");
const cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, "");
let index = 1;
let availableTitle = cleanedTitle;

View file

@ -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;
}

View file

@ -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);

View file

@ -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,

View file

@ -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();
});
});
});

View file

@ -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);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -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();
});
});
});

View file

@ -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', () => {

View file

@ -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');

View file

@ -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', () => {

View file

@ -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');
});
});
});