mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
51a30613a3
commit
d36815c214
27 changed files with 3493 additions and 270 deletions
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
307
__tests__/AIClasses/AIFunctionCall.test.ts
Normal file
307
__tests__/AIClasses/AIFunctionCall.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
511
__tests__/AIClasses/BaseAIClass.test.ts
Normal file
511
__tests__/AIClasses/BaseAIClass.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1565
__tests__/AIClasses/CrossProviderIntegration.test.ts
Normal file
1565
__tests__/AIClasses/CrossProviderIntegration.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue