diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 1a4c7f6518d..c27b4457f76 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -112,6 +112,39 @@ describe("convertToOpenAiMessages", () => { }) }) + it("should strip null values from tool call arguments to prevent Jinja template errors", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "followup-123", + name: "ask_followup_question", + input: { + question: "Pick one", + follow_up: [ + { text: "Option A", mode: null }, + { text: "Option B", mode: "code" }, + ], + }, + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + const toolCall = assistantMessage.tool_calls![0] as any + const args = JSON.parse(toolCall.function.arguments) + + // null mode should be stripped (becomes undefined, omitted from JSON) + expect(args.follow_up[0]).toEqual({ text: "Option A" }) + expect(args.follow_up[0].mode).toBeUndefined() + // non-null mode should be preserved + expect(args.follow_up[1]).toEqual({ text: "Option B", mode: "code" }) + }) + it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 8974dd599ba..150c57cc8a5 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -467,8 +467,13 @@ export function convertToOpenAiMessages( type: "function", function: { name: toolMessage.name, - // json string - arguments: JSON.stringify(toolMessage.input), + // Serialize as JSON, stripping null values to prevent Jinja template + // errors on local models (e.g. "Cannot convert value of type + // Optional to Jinja Value"). Null in tool args typically means + // "not provided" and should be omitted instead. + arguments: JSON.stringify(toolMessage.input, (_key, value) => + value === null ? undefined : value, + ), }, })) diff --git a/src/core/prompts/tools/native-tools/ask_followup_question.ts b/src/core/prompts/tools/native-tools/ask_followup_question.ts index b0591206ade..4532a97d6aa 100644 --- a/src/core/prompts/tools/native-tools/ask_followup_question.ts +++ b/src/core/prompts/tools/native-tools/ask_followup_question.ts @@ -7,7 +7,7 @@ Parameters: - follow_up: (required) A list of 2-4 suggested answers. Suggestions must be complete, actionable answers without placeholders. Optionally include mode to switch modes (code/architect/etc.) Example: Asking for file path -{ "question": "What is the path to the frontend-config.json file?", "follow_up": [{ "text": "./src/frontend-config.json", "mode": null }, { "text": "./config/frontend-config.json", "mode": null }, { "text": "./frontend-config.json", "mode": null }] } +{ "question": "What is the path to the frontend-config.json file?", "follow_up": [{ "text": "./src/frontend-config.json" }, { "text": "./config/frontend-config.json" }, { "text": "./frontend-config.json" }] } Example: Asking with mode switch { "question": "Would you like me to implement this feature?", "follow_up": [{ "text": "Yes, implement it now", "mode": "code" }, { "text": "No, just plan it out", "mode": "architect" }] }` @@ -25,7 +25,12 @@ export default { function: { name: "ask_followup_question", description: ASK_FOLLOWUP_QUESTION_DESCRIPTION, - strict: true, + // Note: strict mode is intentionally disabled for this tool. + // With strict: true, OpenAI requires ALL properties to be in the 'required' array, + // which forces the LLM to always provide explicit values (even null) for optional params. + // Local models using Jinja chat templates cannot handle null values in tool call arguments, + // causing "Cannot convert value of type Optional to Jinja Value" errors. + // By disabling strict mode, the LLM can omit the optional `mode` parameter entirely. parameters: { type: "object", properties: { @@ -44,11 +49,11 @@ export default { description: FOLLOW_UP_TEXT_DESCRIPTION, }, mode: { - type: ["string", "null"], + type: "string", description: FOLLOW_UP_MODE_DESCRIPTION, }, }, - required: ["text", "mode"], + required: ["text"], additionalProperties: false, }, minItems: 1, diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index 22cdbcf5de2..66053d6aa8f 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -39,10 +39,18 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { return } - // Transform follow_up suggestions to the format expected by task.ask + // Transform follow_up suggestions to the format expected by task.ask. + // Omit `mode` when it's null/undefined to avoid Jinja template errors + // on local models that can't handle null values in tool call arguments. const follow_up_json = { question, - suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })), + suggest: follow_up.map((s) => { + const suggestion: { answer: string; mode?: string } = { answer: s.text } + if (s.mode != null) { + suggestion.mode = s.mode + } + return suggestion + }), } task.consecutiveMistakeCount = 0 diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 63bfad8a3d0..6c6391bbea8 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -82,6 +82,37 @@ describe("askFollowupQuestionTool", () => { ) }) + it("should strip null mode values from suggestions to prevent Jinja template errors", async () => { + const block: ToolUse = { + type: "tool_use", + name: "ask_followup_question", + params: { + question: "What would you like to do?", + }, + nativeArgs: { + question: "What would you like to do?", + follow_up: [ + { text: "Option A", mode: null as any }, + { text: "Option B", mode: "code" }, + ], + }, + partial: false, + } + + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + }) + + // mode: null should be stripped, mode: "code" should be preserved + expect(mockCline.ask).toHaveBeenCalledWith( + "followup", + expect.stringContaining('"suggest":[{"answer":"Option A"},{"answer":"Option B","mode":"code"}]'), + false, + ) + }) + it("should handle mixed suggestions with and without mode attributes", async () => { const block: ToolUse = { type: "tool_use",