refactor: improve function call/response parsing and filtering

- Move parseFunctionCall/parseFunctionResponse to ResponseHelper
- Enhance orphaned call/response filtering with detailed debug logs
- Add toolId to conversation content for better tracking
- Fix planning workflow execution mechanics and step numbering
- Remove unused planning agent appendix and detailedAppendixForPlanningAgent
- Add conversation save callbacks throughout AI controller loops
- Improve multi-agent function handling to avoid exceptions
- Update all tests to include toolId fields for proper filtering
This commit is contained in:
Andrew Beal 2025-12-31 22:56:22 +00:00
parent 2c7e5b41b6
commit 18d0741ec9
18 changed files with 481 additions and 192 deletions

View file

@ -10,7 +10,6 @@ import type { Attachment } from "Conversations/Attachment";
import type { SettingsService } from "Services/SettingsService";
import type { StreamingService } from "Services/StreamingService";
import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes";
import { StringTools } from "Helpers/StringTools";
import { Exception } from "Helpers/Exception";
import { ApiError, ApiErrorType } from "Types/ApiError";
import type { AbortService } from "Services/AbortService";
@ -74,7 +73,6 @@ export abstract class BaseAIClass implements IAIClass {
protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] {
return conversationContent.filter((content, index, array) => {
if (!content.content && !content.functionCall && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) {
Exception.warn(`Filtered out message that had no content, this was likely deliberate`);
return false; // Filter out empty content
}
@ -82,7 +80,11 @@ export abstract class BaseAIClass implements IAIClass {
const previousItem = array[index - 1];
const hasValidCall = previousItem && previousItem.functionCall && content.toolId === previousItem.toolId;
if (!hasValidCall) {
Exception.warn(`Filtered out function response that had no matching function call:\n${content.functionResponse}`);
Exception.warn(`[Filter Debug] Filtered orphaned function response at index ${index}/${array.length}:\n` +
` ToolId: ${content.toolId}\n` +
` Previous item has functionCall: ${previousItem?.functionCall ? 'yes' : 'no'}\n` +
` Previous item toolId: ${previousItem?.toolId}\n` +
` Response: ${content.functionResponse}`);
}
return hasValidCall;
}
@ -99,38 +101,16 @@ export abstract class BaseAIClass implements IAIClass {
const nextItem = array[index + 1];
const hasValidResponse = nextItem && nextItem.functionResponse && content.toolId === nextItem.toolId;
if (!hasValidResponse) {
Exception.warn(`Filtered out function call that had no matching function response:\n${content.functionCall}`);
Exception.warn(`[Filter Debug] Filtered orphaned function call at index ${index}/${array.length}:\n` +
` ToolId: ${content.toolId}\n` +
` Next item has functionResponse: ${nextItem?.functionResponse ? 'yes' : 'no'}\n` +
` Next item toolId: ${nextItem?.toolId}\n` +
` Call: ${content.functionCall}`);
}
return hasValidResponse;
});
}
protected parseFunctionCall(functionCallJson: string): StoredFunctionCall | null {
if (!StringTools.isValidJson(functionCallJson)) {
Exception.log(`Invalid JSON in functionCall field:\n${functionCallJson}`);
return null;
}
try {
return JSON.parse(functionCallJson) as StoredFunctionCall;
} catch (error) {
Exception.log(error);
return null;
}
}
protected parseFunctionResponse(responseJson: string): StoredFunctionResponse | null {
if (!StringTools.isValidJson(responseJson)) {
Exception.log(`Invalid JSON in function response content:\n${responseJson}`);
return null;
}
try {
return JSON.parse(responseJson) as StoredFunctionResponse;
} catch (error) {
Exception.log(error);
return null;
}
}
protected throwRetryableError(message: string, code?: string, errorType?: ApiErrorType): never {
throw new ApiError({
type: errorType || ApiErrorType.SERVER_ERROR,

View file

@ -14,6 +14,7 @@ import { MimeType, toMimeType } from "Enums/MimeType";
import { isTextFile } from "Enums/FileType";
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
import { ApiError, ApiErrorType } from "Types/ApiError";
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
export class Claude extends BaseAIClass {
@ -190,7 +191,7 @@ export class Claude extends BaseAIClass {
// Add function call if present
if (content.functionCall) {
const parsedContent = this.parseFunctionCall(content.functionCall);
const parsedContent = parseFunctionCall(content.functionCall);
if (parsedContent) {
if (parsedContent.functionCall.id && parsedContent.functionCall.id.trim() !== "") {
@ -233,7 +234,7 @@ export class Claude extends BaseAIClass {
// Add function response if present
if (content.functionResponse) {
const parsedContent = this.parseFunctionResponse(content.functionResponse);
const parsedContent = parseFunctionResponse(content.functionResponse);
if (parsedContent) {
if (parsedContent.id && parsedContent.id.trim() !== "") {

View file

@ -66,8 +66,4 @@ export abstract class AIFunctionDefinitions {
return `| ${definition.name} | ${description} |`;
}).join("\n");
}
public static detailedAppendixForPlanningAgent(): string {
return this.definitionsList.map(definition => JSON.stringify(definition, null, 2)).join('\n\n');
}
}

View file

@ -15,6 +15,7 @@ import { isTextFile } from "Enums/FileType";
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
import { Exception } from "Helpers/Exception";
import { ApiError, ApiErrorType } from "Types/ApiError";
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
export class Gemini extends BaseAIClass {
@ -224,7 +225,7 @@ export class Gemini extends BaseAIClass {
// Add function call if present
if (content.functionCall) {
const parsedContent = this.parseFunctionCall(content.functionCall);
const parsedContent = parseFunctionCall(content.functionCall);
if (parsedContent) {
if (content.thoughtSignature && content.thoughtSignature.trim() !== "") {
@ -268,7 +269,7 @@ export class Gemini extends BaseAIClass {
// Add function response if present
if (content.functionResponse) {
const parsedContent = this.parseFunctionResponse(content.functionResponse);
const parsedContent = parseFunctionResponse(content.functionResponse);
if (parsedContent) {
if (parsedContent.id && parsedContent.id.trim() !== "") {

View file

@ -13,6 +13,7 @@ import { ApiError, ApiErrorType } from "Types/ApiError";
import { MimeType, toMimeType } from "Enums/MimeType";
import { isTextFile } from "Enums/FileType";
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
export class OpenAI extends BaseAIClass {
@ -198,7 +199,7 @@ export class OpenAI extends BaseAIClass {
// Case 1: Assistant message with function call
if (content.functionCall) {
const parsedContent = this.parseFunctionCall(content.functionCall);
const parsedContent = parseFunctionCall(content.functionCall);
if (parsedContent) {
// Check if function call has required id field (for OpenAI Responses API)
@ -262,7 +263,7 @@ export class OpenAI extends BaseAIClass {
// Case 3: Function call response
if (content.functionResponse) {
const parsedContent = this.parseFunctionResponse(content.functionResponse);
const parsedContent = parseFunctionResponse(content.functionResponse);
if (parsedContent) {
// Check if response has required id field (for OpenAI Responses API)

View file

@ -186,12 +186,4 @@ Before returning any plan, verify:
- Creates a new summary note with proper wiki-links
**Remember**: You are the strategic intelligence of the system. The main agent executes; you ensure it executes optimally.
---
## Appendix: Complete Tool Specifications
Below are the complete function definitions available to the main agent, specified in JSON format as per Anthropic's documentation standards.
${AIFunctionDefinitions.detailedAppendixForPlanningAgent()}
`;

View file

@ -73,16 +73,23 @@ Ask yourself:
#### Planning Workflow
When strategic guidance is needed:
1. **Request a plan** with a clear goal description and relevant context
2. **Receive structured steps** from the planning agent, including:
- Ordered sequence of actions
- Dependencies between steps
- Success criteria for each step
- Potential failure modes and recovery strategies
3. **Execute steps sequentially**, gathering ground truth at each stage
4. **Monitor progress** against the plan's success criteria
5. **Request replanning** if conditions change or obstacles emerge
2. **Receive structured steps** from the planning agent
3. **Execute the plan** following the execution mechanics below
4. **Monitor progress** and request replanning if needed
#### Execution Mechanics
When you receive a plan, it includes both an overview of all steps and detailed guidance for the immediate next action.
**Core loop:**
- Each step provides a directive telling you what to dotreat this as your primary instruction
- After completing a step, signal completion to receive the next step's details
- Continue until all steps are finished
**Handling issues:**
- If circumstances change and the current plan no longer makes sense, request a revised plan
- If the overall goal becomes unachievable or irrelevant, cancel the plan entirely
### 3. ADAPTIVE REPLANNING

View file

@ -1,4 +1,7 @@
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes";
import { StringTools } from "./StringTools";
import { Exception } from "./Exception";
// handle the rare event where a function call is also included in content (gemini sometimes does this)
export function sanitizeFunctionCallContent(content: string, functionCall: AIFunctionCall | null): string {
@ -57,4 +60,30 @@ export function sanitizeFunctionCallContent(content: string, functionCall: AIFun
sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim();
return sanitized;
}
export function parseFunctionCall(functionCallJson: string): StoredFunctionCall | null {
if (!StringTools.isValidJson(functionCallJson)) {
Exception.log(`Invalid JSON in functionCall field:\n${functionCallJson}`);
return null;
}
try {
return JSON.parse(functionCallJson) as StoredFunctionCall;
} catch (error) {
Exception.log(error);
return null;
}
}
export function parseFunctionResponse(responseJson: string): StoredFunctionResponse | null {
if (!StringTools.isValidJson(responseJson)) {
Exception.log(`Invalid JSON in function response content:\n${responseJson}`);
return null;
}
try {
return JSON.parse(responseJson) as StoredFunctionResponse;
} catch (error) {
Exception.log(error);
return null;
}
}

View file

@ -8,7 +8,7 @@ import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
import type { AIFunctionService } from "./AIFunctionService";
import { Copy, replaceCopy } from "Enums/Copy";
import { sanitizeFunctionCallContent } from "Helpers/ResponseHelper";
import { parseFunctionCall, sanitizeFunctionCallContent } from "Helpers/ResponseHelper";
import type { IPrompt } from "AIPrompts/IPrompt";
import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions";
import { AIFunction, isAIFunction } from "Enums/AIFunction";
@ -26,6 +26,7 @@ export class AIControllerService {
private readonly aiFunctionService: AIFunctionService;
private planningConversation: Conversation;
private onSaveConversation?: (conversation: Conversation) => Promise<void>;
public constructor() {
this.aiPrompt = Resolve<IPrompt>(Services.IPrompt);
@ -36,6 +37,10 @@ export class AIControllerService {
this.ai = Resolve<IAIClass>(Services.IAIClass);
}
public setSaveCallback(callback: (conversation: Conversation) => Promise<void>) {
this.onSaveConversation = callback;
}
public async runMainAgent(conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks) {
if (!this.ai) { // this shouldn't ever happen
Exception.throw("Error: No AI provider has been set!");
@ -53,7 +58,7 @@ export class AIControllerService {
return { shouldExit: completedSuccessfully };
}
this.updateThought({ functionCall, shouldContinue: false }, callbacks);
this.updateThought(functionCall, callbacks);
const functionResponse = await this.aiFunctionService.performAIFunction(functionCall);
conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
@ -80,6 +85,7 @@ export class AIControllerService {
let planningIteration = 0;
let continueExecution = true;
let planExecutionCancelled = false;
this.planningConversation = new Conversation();
this.planningConversation.contents.push(new ConversationContent({
@ -106,6 +112,7 @@ export class AIControllerService {
// Stop if cancelled, otherwise continue if replanning was requested OR execution was incomplete
if (executionResult.planExecutionCancelled) {
planExecutionCancelled = true;
continueExecution = false;
} else {
continueExecution = executionResult.shouldReplan || executionResult.isIncomplete;
@ -134,12 +141,14 @@ export class AIControllerService {
}));
}
return true; // Planning and execution completed - terminate main agent
// If plan was cancelled, return false to give control back to main agent for summary
// Otherwise return true to terminate the main agent loop
return !planExecutionCancelled;
}
private async runPlanningAgent(planningConversation: Conversation, callbacks: IChatServiceCallbacks): Promise<ExecutionPlan> {
let capturedPlan: ExecutionPlan | null = null;
await this.runAgentLoop(planningConversation, callbacks, async (functionCall) => {
const functionCallName = functionCall.name;
@ -148,14 +157,20 @@ export class AIControllerService {
capturedPlan = parseResult.success
? new ExecutionPlan(parseResult.data)
: new ExecutionPlan({ steps: [] });
// Add response before exiting to avoid orphaned function call
planningConversation.addFunctionResponse(new AIFunctionResponse(
functionCallName,
{ message: "Plan received" },
functionCall.toolId
));
return { shouldExit: true }; // Exit once plan is submitted
}
this.updateThought({ functionCall, shouldContinue: false }, callbacks);
this.updateThought(functionCall, callbacks);
const functionResponse = await this.aiFunctionService.performAIFunction(functionCall);
planningConversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
}, false);
}, true);
return capturedPlan ?? new ExecutionPlan({ steps: [] });
}
@ -211,6 +226,12 @@ export class AIControllerService {
}
// Capture replan data and exit execution loop to trigger replanning
replanData = parseResult.data;
// Add response before exiting to avoid orphaned function call
conversation.addFunctionResponse(new AIFunctionResponse(
functionCallName,
{ message: "Replanning initiated" },
functionCall.toolId
));
return { shouldExit: true };
}
@ -233,7 +254,7 @@ export class AIControllerService {
return { shouldExit: false };
}
this.updateThought({ functionCall, shouldContinue: false }, callbacks);
this.updateThought(functionCall, callbacks);
const functionResponse = await this.aiFunctionService.performAIFunction(functionCall);
conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
@ -244,26 +265,37 @@ export class AIControllerService {
}
private async runAgentLoop(conversation: Conversation, callbacks: IChatServiceCallbacks,
handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }>, clearThoughtOnContent: boolean = true
handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }>, isPlanningConversation: boolean = false
): Promise<void> {
let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, clearThoughtOnContent);
let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningConversation);
if (!isPlanningConversation) {
await this.onSaveConversation?.(conversation);
}
while (response.functionCall || response.shouldContinue) {
if (response.functionCall) {
const result = await handleFunctionCall(response.functionCall);
if (result.shouldExit) {
if (!isPlanningConversation) {
await this.onSaveConversation?.(conversation);
}
return;
}
} else {
callbacks.onThoughtUpdate(Copy.AIThoughtMessage);
}
response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, clearThoughtOnContent);
response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningConversation);
if (!isPlanningConversation) {
await this.onSaveConversation?.(conversation);
}
}
}
private updateThought(response: { functionCall: AIFunctionCall | null, shouldContinue: boolean }, callbacks: IChatServiceCallbacks) {
const userMessage = response.functionCall?.arguments.user_message;
private updateThought(functionCall: AIFunctionCall | null, callbacks: IChatServiceCallbacks) {
const userMessage = functionCall?.arguments.user_message;
if (userMessage && typeof userMessage === "string") {
callbacks.onThoughtUpdate(userMessage);
}
@ -282,7 +314,7 @@ export class AIControllerService {
return conversation;
}
private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks, clearThoughtOnContent: boolean = true
private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks, isPlanningConversation: boolean
): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> {
if (!this.ai) { // this should never happen
return { functionCall: null, shouldContinue: false };
@ -315,7 +347,7 @@ export class AIControllerService {
accumulatedContent += chunk.content;
conversationContent.content = accumulatedContent;
if (accumulatedContent.trim() !== "" && clearThoughtOnContent) {
if (accumulatedContent.trim() !== "" && !isPlanningConversation) {
callbacks.onThoughtUpdate(null);
}
}
@ -329,6 +361,7 @@ export class AIControllerService {
conversationContent.content = sanitizedContent;
if (capturedFunctionCall) {
conversationContent.functionCall = capturedFunctionCall.toConversationString();
conversationContent.toolId = capturedFunctionCall.toolId;
if (capturedFunctionCall.thoughtSignature) {
conversationContent.thoughtSignature = capturedFunctionCall.thoughtSignature;
}
@ -376,7 +409,15 @@ export class AIControllerService {
}
private createPlanningResponse(conversationContent: ConversationContent, executionPlan: ExecutionPlan): AIFunctionResponse | undefined {
const createParseResult = CreatePlanArgsSchema.safeParse(conversationContent.functionCall);
if (!conversationContent.functionCall) {
return undefined;
}
const parsedFunctionCall = parseFunctionCall(conversationContent.functionCall);
if (!parsedFunctionCall) {
return undefined;
}
const createParseResult = CreatePlanArgsSchema.safeParse(parsedFunctionCall.functionCall.args);
if (createParseResult.success) {
return new AIFunctionResponse(
AIFunction.CreatePlan,
@ -384,7 +425,7 @@ export class AIControllerService {
conversationContent.toolId
);
}
const replanParseResult = ReplanArgsSchema.safeParse(conversationContent.functionCall);
const replanParseResult = ReplanArgsSchema.safeParse(parsedFunctionCall.functionCall.args);
if (replanParseResult.success) {
return new AIFunctionResponse(
AIFunction.Replan,
@ -392,5 +433,7 @@ export class AIControllerService {
conversationContent.toolId
);
}
return undefined;
}
}

View file

@ -120,14 +120,18 @@ export class AIFunctionService {
case AIFunction.RequestWebSearch:
return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId)
// multi-agent functions are handled elsewhere - this shouldn't ever get hit
// multi-agent functions are handled elsewhere - this shouldn't really ever get hit
case AIFunction.CreatePlan:
case AIFunction.Replan:
case AIFunction.SubmitPlan:
case AIFunction.CompleteStep:
case AIFunction.CancelPlan: {
Exception.throw(`Multi-agent function ${functionCall.name} should not be handled by AIFunctionService`);
break;
Exception.log(`Multi-agent function ${functionCall.name} should not be handled by AIFunctionService`);
return new AIFunctionResponse(
functionCall.name,
{ error: `Failed to execute ${functionCall.name}.` },
functionCall.toolId
);
}
default: {

View file

@ -44,6 +44,10 @@ export class ChatService {
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.semaphore = new Semaphore(1, false);
this.aiControllerService.setSaveCallback(async (conversation) => {
await this.saveConversation(conversation);
});
}
public onNameChanged: ((name: string) => void) | undefined = undefined;

View file

@ -80,7 +80,7 @@ export class InputService {
}
}
} catch (error) {
Exception.log(error);
Exception.warn(error); // Just a warning as some pasted content seem to give nonsense URIs
}
}

View file

@ -10,7 +10,7 @@ export class ExecutionPlan {
public constructor(plan: SubmitPlanArgs) {
for (const [index, step] of plan.steps.entries()) {
this.executionSteps.push(new ExecutionStep(
index,
index + 1,
step.description,
step.instruction,
step.context
@ -100,7 +100,7 @@ export class ExecutionPlan {
return {
plan: this.executionSteps.map((step, index) => `${index + 1}. ${step.description}`),
firstStep: {
step: firstStep.step + 1,
step: firstStep.step,
description: firstStep.description,
instruction: firstStep.instruction,
...(firstStep.context && { context: firstStep.context })

View file

@ -376,7 +376,8 @@ describe('BaseAIClass Shared Methods', () => {
functionResponse: JSON.stringify({
id: 'call_with_response',
functionResponse: { name: 'search', response: [] }
})
}),
toolId: 'call_with_response'
})
];
@ -423,7 +424,8 @@ describe('BaseAIClass Shared Methods', () => {
functionResponse: JSON.stringify({
id: 'complete1',
functionResponse: { name: 'read', response: 'data' }
})
}),
toolId: 'complete1'
}),
new ConversationContent({ role: Role.User, content: 'End', displayContent: 'End' })

View file

@ -330,6 +330,20 @@ describe('Claude', () => {
});
it('should convert function response to tool_result format', async () => {
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_123',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_123'
});
const responseContent = JSON.stringify({
id: 'call_123',
functionResponse: {
@ -340,14 +354,15 @@ describe('Claude', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_123'
});
const result = await (claude as any).extractContents([functionResponseContent]);
const result = await (claude as any).extractContents([functionCallContent, functionResponseContent]);
expect(result).toHaveLength(1);
expect(result[0].content).toHaveLength(1);
expect(result[0].content[0]).toEqual({
expect(result).toHaveLength(2);
expect(result[1].content).toHaveLength(1);
expect(result[1].content[0]).toEqual({
type: 'tool_result',
tool_use_id: 'call_123',
content: JSON.stringify(['file1.txt', 'file2.txt'])
@ -380,20 +395,35 @@ describe('Claude', () => {
it('should handle invalid JSON in function response gracefully', async () => {
const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {});
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_invalid',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_invalid'
});
const invalidContent = new ConversationContent({
role: Role.User,
content: 'invalid json {',
displayContent: 'invalid json {',
functionResponse: 'invalid json {'
functionResponse: 'invalid json {',
toolId: 'call_invalid'
});
const result = await (claude as any).extractContents([invalidContent]);
const result = await (claude as any).extractContents([functionCallContent, invalidContent]);
// Should fallback to text
expect(result).toHaveLength(1);
expect(result[0].content).toHaveLength(1);
expect(result[0].content[0].type).toBe('text');
expect(result[0].content[0].text).toBe('invalid json {');
expect(result).toHaveLength(2);
expect(result[1].content).toHaveLength(1);
expect(result[1].content[0].type).toBe('text');
expect(result[1].content[0].text).toBe('invalid json {');
expect(exceptionSpy).toHaveBeenCalled();
exceptionSpy.mockRestore();
@ -502,6 +532,20 @@ describe('Claude', () => {
});
it('should convert function response without ID to legacy text format', async () => {
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_legacy',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_legacy'
});
const responseContent = JSON.stringify({
functionResponse: {
name: 'search_vault_files',
@ -513,14 +557,15 @@ describe('Claude', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_legacy'
});
const result = await (claude as any).extractContents([functionResponseContent]);
const result = await (claude as any).extractContents([functionCallContent, functionResponseContent]);
expect(result).toHaveLength(1);
expect(result[0].content).toHaveLength(1);
expect(result[0].content[0].type).toBe('text');
expect(result).toHaveLength(2);
expect(result[1].content).toHaveLength(1);
expect(result[1].content[0].type).toBe('text');
const expected = `<!-- Historical tool result. This action was ALREADY COMPLETED. -->
{
"name": "search_vault_files",
@ -529,10 +574,24 @@ describe('Claude', () => {
"file2.txt"
]
}`;
expect(result[0].content[0].text).toBe(expected);
expect(result[1].content[0].text).toBe(expected);
});
it('should convert function response with empty ID to legacy text format', async () => {
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_empty',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_empty'
});
const responseContent = JSON.stringify({
id: '', // Empty ID
functionResponse: {
@ -544,14 +603,15 @@ describe('Claude', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_empty'
});
const result = await (claude as any).extractContents([functionResponseContent]);
const result = await (claude as any).extractContents([functionCallContent, functionResponseContent]);
expect(result).toHaveLength(1);
expect(result[0].content).toHaveLength(1);
expect(result[0].content[0].type).toBe('text');
expect(result).toHaveLength(2);
expect(result[1].content).toHaveLength(1);
expect(result[1].content[0].type).toBe('text');
const expected = `<!-- Historical tool result. This action was ALREADY COMPLETED. -->
{
"name": "search_vault_files",
@ -560,7 +620,7 @@ describe('Claude', () => {
"file2.txt"
]
}`;
expect(result[0].content[0].text).toBe(expected);
expect(result[1].content[0].text).toBe(expected);
});
it('should exclude orphaned function calls without responses', async () => {
@ -1131,7 +1191,8 @@ describe('Claude', () => {
'x-api-key': 'test-claude-key',
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
})
}),
expect.any(Function) // extractRetryDelay
);
});

View file

@ -146,6 +146,20 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
it('should convert function response without id to legacy text format', async () => {
// Function responses from Claude/OpenAI may not have the id field in content
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_cross1',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_cross1'
});
const responseContent = JSON.stringify({
functionResponse: {
name: 'search_vault_files',
@ -157,16 +171,17 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_cross1'
});
const result = await (gemini as any).extractContents([claudeResponse]);
const result = await (gemini as any).extractContents([functionCallContent, claudeResponse]);
expect(result[0].parts[0]).toHaveProperty('text');
expect(result[0].parts[0].text).toContain('<!-- Historical tool result');
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
expect(result[0].parts[0].text).toContain('"response": [');
expect(result[0].parts[0].text).toContain(' "note1.md"');
expect(result[0].parts[0].text).toContain(' "note2.md"');
expect(result[1].parts[0]).toHaveProperty('text');
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
expect(result[1].parts[0].text).toContain('"name": "search_vault_files"');
expect(result[1].parts[0].text).toContain('"response": [');
expect(result[1].parts[0].text).toContain(' "note1.md"');
expect(result[1].parts[0].text).toContain(' "note2.md"');
});
});
@ -237,7 +252,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_claude_123'
});
return content;
})(),
@ -262,7 +278,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
}),
timestamp: new Date(),
shouldDisplayContent: true,
thoughtSignature: 'geminiSignature123=='
thoughtSignature: 'geminiSignature123==',
toolId: 'gemini-response-1'
});
return content;
})(),
@ -280,7 +297,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'gemini-response-1'
});
return content;
})()
@ -342,7 +360,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_openai_456'
});
return content;
})()
@ -387,7 +406,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call-1'
});
return content;
})(),
@ -405,7 +425,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
}),
timestamp: new Date(),
shouldDisplayContent: true,
thoughtSignature: 'sig2=='
thoughtSignature: 'sig2==',
toolId: 'resp-2'
});
return content;
})(),
@ -419,7 +440,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'resp-2'
});
return content;
})(),
@ -483,7 +505,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'toolu_abc123'
});
return content;
})();
@ -537,7 +560,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_xyz789'
});
return content;
})();
@ -597,7 +621,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'toolu_1'
});
return content;
})(),
@ -638,7 +663,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_2'
});
return content;
})()
@ -789,6 +815,20 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
it('should handle OpenAI function response when switching to Gemini', async () => {
// OpenAI response with ID should work with Gemini
const openaiCall = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_123',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_123'
});
const openaiResponse = (() => {
const responseContent = JSON.stringify({
id: 'call_123',
@ -801,16 +841,17 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_123'
});
return content;
})();
const result = await (gemini as any).extractContents([openaiResponse]);
const result = await (gemini as any).extractContents([openaiCall, openaiResponse]);
// Gemini should accept the response with ID
expect(result).toHaveLength(1);
expect(result[0].parts[0]).toEqual({
expect(result).toHaveLength(2);
expect(result[1].parts[0]).toEqual({
functionResponse: {
name: 'search_vault_files',
response: ['file1.md', 'file2.md']
@ -834,7 +875,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
}),
timestamp: new Date(),
shouldDisplayContent: true,
thoughtSignature: 'gemini_signature_123=='
thoughtSignature: 'gemini_signature_123==',
toolId: 'gemini_tool_1'
});
const geminiResponse = (() => {
@ -849,7 +891,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'gemini_tool_1'
});
return content;
})();
@ -911,7 +954,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_xyz'
});
return content;
})(),
@ -976,7 +1020,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'toolu_1'
});
return content;
})(),
@ -1013,7 +1058,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_2'
});
return content;
})(),
@ -1062,7 +1108,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
}),
timestamp: new Date(),
shouldDisplayContent: true,
thoughtSignature: 'gemini_signature_1=='
thoughtSignature: 'gemini_signature_1==',
toolId: 'resp_1'
});
return content;
})(),
@ -1076,7 +1123,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'resp_1'
});
return content;
})(),
@ -1113,7 +1161,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'toolu_2'
});
return content;
})(),
@ -1150,7 +1199,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_3'
});
return content;
})()
@ -1211,7 +1261,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'toolu_round'
});
return content;
})(),
@ -1279,7 +1330,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_round'
});
return content;
})(),
@ -1340,7 +1392,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'toolu_step1'
});
return content;
})(),
@ -1376,7 +1429,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_step2'
});
return content;
})(),
@ -1397,7 +1451,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
}),
timestamp: new Date(),
shouldDisplayContent: true,
thoughtSignature: 'gemini_step3_signature=='
thoughtSignature: 'gemini_step3_signature==',
toolId: 'resp_step3'
});
return content;
})(),
@ -1411,7 +1466,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'resp_step3'
});
return content;
})(),
@ -1613,8 +1669,9 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
expect(openaiResult).toHaveLength(1);
});
it('should handle function response ID mismatch gracefully', async () => {
it('should filter out function response with mismatched toolId', async () => {
// Function call with one ID, response with different ID
// The new filtering logic should filter out the mismatched response
const conversation = [
(() => {
const content = new ConversationContent({
@ -1646,24 +1703,22 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call-456' // Mismatched toolId
});
return content;
})()
];
// Providers should still parse this (even if IDs don't match)
// The mismatched response should be filtered out, leaving only the orphaned function call
// But orphaned function calls (without responses) are also filtered unless they're the last item
const claude = new Claude();
const claudeResult = await (claude as any).extractContents(conversation);
expect(claudeResult).toHaveLength(2);
expect(claudeResult[0].content[0].id).toBe('call-123');
expect(claudeResult[1].content[0].tool_use_id).toBe('call-456');
expect(claudeResult).toHaveLength(0);
const openai = new OpenAI();
const openaiResult = await (openai as any).extractContents(conversation);
expect(openaiResult).toHaveLength(2);
expect(openaiResult[0].call_id).toBe('call-123');
expect(openaiResult[1].call_id).toBe('call-456');
expect(openaiResult).toHaveLength(0);
});
});
});

View file

@ -545,6 +545,20 @@ describe('Gemini', () => {
});
it('should convert function response to Gemini format', async () => {
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call-123',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call-123'
});
const responseContent = JSON.stringify({
id: 'call-123',
functionResponse: {
@ -556,15 +570,16 @@ describe('Gemini', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent, // displayContent for User role
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call-123'
});
const result = await (gemini as any).extractContents([functionResponseContent]);
const result = await (gemini as any).extractContents([functionCallContent, functionResponseContent]);
expect(result).toHaveLength(1);
expect(result[0].parts).toHaveLength(1);
expect(result).toHaveLength(2);
expect(result[1].parts).toHaveLength(1);
// Gemini API requires both 'name' and 'response' fields
expect(result[0].parts[0]).toEqual({
expect(result[1].parts[0]).toEqual({
functionResponse: {
name: 'search_vault_files',
response: ['file1.txt', 'file2.txt']
@ -573,6 +588,20 @@ describe('Gemini', () => {
});
it('should fall back to legacy text format for function response without id (cross-provider)', async () => {
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_legacy1',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_legacy1'
});
const responseContent = JSON.stringify({
functionResponse: {
name: 'search_vault_files',
@ -583,22 +612,37 @@ describe('Gemini', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_legacy1'
});
const result = await (gemini as any).extractContents([functionResponseContent]);
const result = await (gemini as any).extractContents([functionCallContent, 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('<!-- Historical tool result');
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
expect(result[0].parts[0].text).toContain('"response": [');
expect(result[0].parts[0].text).toContain(' "file1.txt"');
expect(result[0].parts[0].text).toContain(' "file2.txt"');
expect(result).toHaveLength(2);
expect(result[1].parts).toHaveLength(1);
expect(result[1].parts[0]).toHaveProperty('text');
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
expect(result[1].parts[0].text).toContain('"name": "search_vault_files"');
expect(result[1].parts[0].text).toContain('"response": [');
expect(result[1].parts[0].text).toContain(' "file1.txt"');
expect(result[1].parts[0].text).toContain(' "file2.txt"');
});
it('should fall back to legacy text format for function response with empty id', async () => {
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_legacy2',
name: 'read_file',
args: { path: 'test.md' }
}
}),
toolId: 'call_legacy2'
});
const responseContent = JSON.stringify({
id: '',
functionResponse: {
@ -610,17 +654,18 @@ describe('Gemini', () => {
role: Role.User,
content: responseContent,
displayContent: responseContent,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_legacy2'
});
const result = await (gemini as any).extractContents([functionResponseContent]);
const result = await (gemini as any).extractContents([functionCallContent, functionResponseContent]);
expect(result).toHaveLength(1);
expect(result[0].parts[0]).toHaveProperty('text');
expect(result[0].parts[0].text).toContain('<!-- Historical tool result');
expect(result[0].parts[0].text).toContain('"name": "read_file"');
expect(result[0].parts[0].text).toContain('"response": {');
expect(result[0].parts[0].text).toContain(' "content": "file contents"');
expect(result).toHaveLength(2);
expect(result[1].parts[0]).toHaveProperty('text');
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
expect(result[1].parts[0].text).toContain('"name": "read_file"');
expect(result[1].parts[0].text).toContain('"response": {');
expect(result[1].parts[0].text).toContain(' "content": "file contents"');
});
it('should handle invalid JSON in function call gracefully', async () => {
@ -649,19 +694,34 @@ describe('Gemini', () => {
it('should handle invalid JSON in function response gracefully', async () => {
const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {});
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_invalid',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_invalid'
});
const invalidContent = new ConversationContent({
role: Role.User,
content: 'invalid json {',
displayContent: 'invalid json {', // displayContent for User role
functionResponse: 'invalid json {'
functionResponse: 'invalid json {',
toolId: 'call_invalid'
});
const result = await (gemini as any).extractContents([invalidContent]);
const result = await (gemini as any).extractContents([functionCallContent, invalidContent]);
// Should fallback to text
expect(result).toHaveLength(1);
expect(result[0].parts).toHaveLength(1);
expect(result[0].parts[0]).toEqual({ text: 'invalid json {' });
expect(result).toHaveLength(2);
expect(result[1].parts).toHaveLength(1);
expect(result[1].parts[0]).toEqual({ text: 'invalid json {' });
expect(exceptionSpy).toHaveBeenCalled();
exceptionSpy.mockRestore();

View file

@ -377,6 +377,22 @@ describe('OpenAI', () => {
it('should convert function response to function_call_output format', async () => {
const conversation = new Conversation();
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_123',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_123'
});
conversation.contents.push(functionCallContent);
const responseContent = JSON.stringify({
id: 'call_123',
functionResponse: {
@ -386,7 +402,8 @@ describe('OpenAI', () => {
});
const functionResponseContent = new ConversationContent({
role: Role.User,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_123'
});
conversation.contents.push(functionResponseContent);
@ -400,9 +417,9 @@ describe('OpenAI', () => {
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
const requestBody = callArgs[1];
// Should have 1 function_call_output item
expect(requestBody.input).toHaveLength(1);
expect(requestBody.input[0]).toEqual({
// Should have 2 items: function call and function response
expect(requestBody.input).toHaveLength(2);
expect(requestBody.input[1]).toEqual({
type: 'function_call_output',
call_id: 'call_123',
output: '["file1.txt","file2.txt"]'
@ -439,9 +456,26 @@ describe('OpenAI', () => {
const exceptionSpy = vi.spyOn(Exception, 'log');
const conversation = new Conversation();
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_invalid',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_invalid'
});
conversation.contents.push(functionCallContent);
const invalidContent = new ConversationContent({
role: Role.User,
functionResponse: 'invalid json {'
functionResponse: 'invalid json {',
toolId: 'call_invalid'
});
conversation.contents.push(invalidContent);
@ -454,10 +488,11 @@ describe('OpenAI', () => {
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
const requestBody = callArgs[1];
const message = requestBody.input.find((m: any) => m.role === Role.User);
const messages = requestBody.input.filter((m: any) => m.role === Role.User);
expect(message.content).toBe('invalid json {');
expect(message.role).toBe(Role.User); // Falls back to original role
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('invalid json {');
expect(messages[0].role).toBe(Role.User); // Falls back to original role
expect(exceptionSpy).toHaveBeenCalled();
});
@ -730,6 +765,22 @@ describe('OpenAI', () => {
it('should handle complex function response objects', async () => {
const conversation = new Conversation();
const functionCallContent = new ConversationContent({
role: Role.Assistant,
content: '',
displayContent: '',
functionCall: JSON.stringify({
functionCall: {
id: 'call_123',
name: 'search_vault_files',
args: { query: 'test' }
}
}),
toolId: 'call_123'
});
conversation.contents.push(functionCallContent);
const complexResponse = {
files: ['file1.txt', 'file2.md'],
count: 2,
@ -744,7 +795,8 @@ describe('OpenAI', () => {
});
const functionResponseContent = new ConversationContent({
role: Role.User,
functionResponse: responseContent
functionResponse: responseContent,
toolId: 'call_123'
});
conversation.contents.push(functionResponseContent);
@ -758,8 +810,8 @@ describe('OpenAI', () => {
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
const requestBody = callArgs[1];
expect(requestBody.input).toHaveLength(1);
expect(requestBody.input[0]).toEqual({
expect(requestBody.input).toHaveLength(2);
expect(requestBody.input[1]).toEqual({
type: 'function_call_output',
call_id: 'call_123',
output: JSON.stringify(complexResponse)
@ -918,7 +970,8 @@ describe('OpenAI', () => {
expect.objectContaining({
'Authorization': 'Bearer test-openai-key',
'Content-Type': 'application/json'
})
}),
expect.any(Function) // extractRetryDelay
);
});