From 18d0741ec92779d90464689f8f5a79c468f077a1 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Wed, 31 Dec 2025 22:56:22 +0000 Subject: [PATCH] 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 --- AIClasses/BaseAIClass.ts | 40 ++--- AIClasses/Claude/Claude.ts | 5 +- .../AIFunctionDefinitions.ts | 4 - AIClasses/Gemini/Gemini.ts | 5 +- AIClasses/OpenAI/OpenAI.ts | 5 +- AIPrompts/PlanningAgentSystemPrompt.ts | 8 - AIPrompts/SystemPrompt.ts | 25 +-- Helpers/ResponseHelper.ts | 29 ++++ Services/AIControllerService.ts | 75 +++++++-- Services/AIFunctionService.ts | 10 +- Services/ChatService.ts | 4 + Services/InputService.ts | 2 +- Types/ExecutionPlan.ts | 4 +- __tests__/AIClasses/BaseAIClass.test.ts | 6 +- __tests__/AIClasses/Claude.test.ts | 109 ++++++++++--- .../CrossProviderIntegration.test.ts | 149 ++++++++++++------ __tests__/AIClasses/Gemini.test.ts | 116 ++++++++++---- __tests__/AIClasses/OpenAI.test.ts | 77 +++++++-- 18 files changed, 481 insertions(+), 192 deletions(-) diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 02dbe08..6f71cc3 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -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, diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index b402a40..5924ff6 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -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() !== "") { diff --git a/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts b/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts index b45df0f..b199764 100644 --- a/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts +++ b/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts @@ -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'); - } } \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 0f8b05e..b73f2e2 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -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() !== "") { diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 8ff28de..34ae48a 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -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) diff --git a/AIPrompts/PlanningAgentSystemPrompt.ts b/AIPrompts/PlanningAgentSystemPrompt.ts index fc71097..ef4d7e0 100644 --- a/AIPrompts/PlanningAgentSystemPrompt.ts +++ b/AIPrompts/PlanningAgentSystemPrompt.ts @@ -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()} `; \ No newline at end of file diff --git a/AIPrompts/SystemPrompt.ts b/AIPrompts/SystemPrompt.ts index 5a1676d..d2c4457 100644 --- a/AIPrompts/SystemPrompt.ts +++ b/AIPrompts/SystemPrompt.ts @@ -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 do—treat 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 diff --git a/Helpers/ResponseHelper.ts b/Helpers/ResponseHelper.ts index bcdaca2..9046de5 100644 --- a/Helpers/ResponseHelper.ts +++ b/Helpers/ResponseHelper.ts @@ -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; + } } \ No newline at end of file diff --git a/Services/AIControllerService.ts b/Services/AIControllerService.ts index 2ef9ae1..6d01c85 100644 --- a/Services/AIControllerService.ts +++ b/Services/AIControllerService.ts @@ -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; public constructor() { this.aiPrompt = Resolve(Services.IPrompt); @@ -36,6 +37,10 @@ export class AIControllerService { this.ai = Resolve(Services.IAIClass); } + public setSaveCallback(callback: (conversation: Conversation) => Promise) { + 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 { 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 { - 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; } } \ No newline at end of file diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index eac3921..6300797 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -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: { diff --git a/Services/ChatService.ts b/Services/ChatService.ts index ba10a55..43e6138 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -44,6 +44,10 @@ export class ChatService { this.eventService = Resolve(Services.EventService); this.abortService = Resolve(Services.AbortService); this.semaphore = new Semaphore(1, false); + + this.aiControllerService.setSaveCallback(async (conversation) => { + await this.saveConversation(conversation); + }); } public onNameChanged: ((name: string) => void) | undefined = undefined; diff --git a/Services/InputService.ts b/Services/InputService.ts index 296d0fa..3ed8603 100644 --- a/Services/InputService.ts +++ b/Services/InputService.ts @@ -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 } } diff --git a/Types/ExecutionPlan.ts b/Types/ExecutionPlan.ts index ae59b2c..11908fd 100644 --- a/Types/ExecutionPlan.ts +++ b/Types/ExecutionPlan.ts @@ -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 }) diff --git a/__tests__/AIClasses/BaseAIClass.test.ts b/__tests__/AIClasses/BaseAIClass.test.ts index 43ff5ea..2c43e95 100644 --- a/__tests__/AIClasses/BaseAIClass.test.ts +++ b/__tests__/AIClasses/BaseAIClass.test.ts @@ -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' }) diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index f0f85de..65e1e5c 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -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 = ` { "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 = ` { "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 ); }); diff --git a/__tests__/AIClasses/CrossProviderIntegration.test.ts b/__tests__/AIClasses/CrossProviderIntegration.test.ts index 5491ff8..75c2563 100644 --- a/__tests__/AIClasses/CrossProviderIntegration.test.ts +++ b/__tests__/AIClasses/CrossProviderIntegration.test.ts @@ -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('