From e3fd53f710b83d30b9fe9f09704a034c2a0625f8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 13 Feb 2026 01:48:36 +0000 Subject: [PATCH] refactor: remove legacy message transformation pipeline - Remove buildCleanConversationHistory from Task.ts (effectively a no-op since all providers are AI SDK providers and preserveReasoning is always true) - Standardize all providers on sanitizeMessagesForProvider for message sanitization (xai, gemini, bedrock, openai-native, openai-codex) - Delete dead transform files with zero production callers: anthropic-filter.ts, r1-format.ts, openai-format.ts, mistral-format.ts - Remove convertToAiSdkMessages function and its unused imports from ~20 providers (imported by all, called by none) - Remove reasoning-preservation.test.ts (tested removed code) - Clean up stale comments referencing removed functions - Backward compatibility with old conversation histories is already handled by convertAnthropicToRooMessages at the persistence layer --- .../__tests__/anthropic-vertex.spec.ts | 7 +- src/api/providers/__tests__/anthropic.spec.ts | 10 +- src/api/providers/anthropic-vertex.ts | 1 - src/api/providers/anthropic.ts | 1 - src/api/providers/azure.ts | 8 +- src/api/providers/baseten.ts | 8 +- src/api/providers/bedrock.ts | 19 +- src/api/providers/deepseek.ts | 8 +- src/api/providers/fireworks.ts | 8 +- src/api/providers/gemini.ts | 22 +- src/api/providers/lm-studio.ts | 8 +- src/api/providers/minimax.ts | 1 - src/api/providers/mistral.ts | 2 +- src/api/providers/native-ollama.ts | 1 - src/api/providers/openai-codex.ts | 20 +- src/api/providers/openai-compatible.ts | 8 +- src/api/providers/openai-native.ts | 46 +- src/api/providers/openai.ts | 1 - src/api/providers/openrouter.ts | 7 +- src/api/providers/requesty.ts | 8 +- src/api/providers/sambanova.ts | 1 - src/api/providers/vercel-ai-gateway.ts | 1 - src/api/providers/vertex.ts | 1 - src/api/providers/xai.ts | 13 +- src/api/providers/zai.ts | 8 +- src/api/transform/__tests__/ai-sdk.spec.ts | 615 -------- .../__tests__/anthropic-filter.spec.ts | 144 -- .../__tests__/mistral-format.spec.ts | 341 ----- .../transform/__tests__/openai-format.spec.ts | 1349 ----------------- src/api/transform/__tests__/r1-format.spec.ts | 619 -------- src/api/transform/ai-sdk.ts | 288 ---- src/api/transform/anthropic-filter.ts | 52 - src/api/transform/mistral-format.ts | 182 --- src/api/transform/openai-format.ts | 555 ------- src/api/transform/r1-format.ts | 244 --- src/core/task/Task.ts | 165 +- .../__tests__/reasoning-preservation.test.ts | 402 ----- 37 files changed, 58 insertions(+), 5116 deletions(-) delete mode 100644 src/api/transform/__tests__/anthropic-filter.spec.ts delete mode 100644 src/api/transform/__tests__/mistral-format.spec.ts delete mode 100644 src/api/transform/__tests__/openai-format.spec.ts delete mode 100644 src/api/transform/__tests__/r1-format.spec.ts delete mode 100644 src/api/transform/anthropic-filter.ts delete mode 100644 src/api/transform/mistral-format.ts delete mode 100644 src/api/transform/openai-format.ts delete mode 100644 src/api/transform/r1-format.ts delete mode 100644 src/core/task/__tests__/reasoning-preservation.test.ts diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 6d50270ed0..f9a8ad1f23 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -37,8 +37,11 @@ vitest.mock("@ai-sdk/google-vertex/anthropic", () => ({ })) // Mock ai-sdk transform utilities +vitest.mock("../../transform/sanitize-messages", () => ({ + sanitizeMessagesForProvider: vitest.fn().mockImplementation((msgs: any[]) => msgs), +})) + vitest.mock("../../transform/ai-sdk", () => ({ - convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]), convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined), processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) { if (part.type === "text-delta") { @@ -59,7 +62,7 @@ vitest.mock("../../transform/ai-sdk", () => ({ })) // Import mocked modules -import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" import { Anthropic } from "@anthropic-ai/sdk" // Helper: create a mock provider function diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index d65506135b..e41d5e3a25 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -32,8 +32,11 @@ vitest.mock("@ai-sdk/anthropic", () => ({ })) // Mock ai-sdk transform utilities +vitest.mock("../../transform/sanitize-messages", () => ({ + sanitizeMessagesForProvider: vitest.fn().mockImplementation((msgs: any[]) => msgs), +})) + vitest.mock("../../transform/ai-sdk", () => ({ - convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]), convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined), processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) { if (part.type === "text-delta") { @@ -54,7 +57,7 @@ vitest.mock("../../transform/ai-sdk", () => ({ })) // Import mocked modules -import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" import { Anthropic } from "@anthropic-ai/sdk" // Helper: create a mock provider function @@ -82,9 +85,6 @@ describe("AnthropicHandler", () => { // Re-set mock defaults after clearAllMocks mockCreateAnthropic.mockReturnValue(mockProviderFn) - vitest - .mocked(convertToAiSdkMessages) - .mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]) vitest.mocked(convertToolsForAiSdk).mockReturnValue(undefined) vitest.mocked(mapToolChoice).mockReturnValue(undefined) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index d95d5d7786..ee70152fa8 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -19,7 +19,6 @@ import { shouldUseReasoningBudget } from "../../shared/api" import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 81189a7868..e8b90c7481 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -17,7 +17,6 @@ import { shouldUseReasoningBudget } from "../../shared/api" import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index c039f0f373..374a6e1697 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -6,13 +6,7 @@ import { azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/ty import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 601e93b6c5..69b261fe74 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -6,13 +6,7 @@ import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/ import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 3513971915..c337abf03b 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createAmazonBedrock, type AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { fromIni } from "@aws-sdk/credential-providers" import OpenAI from "openai" @@ -25,7 +25,6 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, @@ -34,6 +33,7 @@ import { } from "../transform/ai-sdk" import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { shouldUseReasoningBudget } from "../../shared/api" import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" @@ -194,19 +194,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ): ApiStream { const modelConfig = this.getModel() - // Filter out provider-specific meta entries (e.g., { type: "reasoning" }) - // that are not valid Anthropic MessageParam values - type ReasoningMetaLike = { type?: string } - const filteredMessages = messages.filter((message) => { - const meta = message as ReasoningMetaLike - if (meta.type === "reasoning") { - return false - } - return true - }) - - // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 72c582dfef..f9f49e4ec4 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -6,13 +6,7 @@ import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 468c1f1840..603da3bd81 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -6,13 +6,7 @@ import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-c import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 50518b0b3b..fed6375cf1 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google" -import { streamText, generateText, NoOutputGeneratedError, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai" import { type ModelInfo, @@ -14,7 +14,6 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, @@ -25,6 +24,7 @@ import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { t } from "i18next" import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { BaseProvider } from "./base-provider" @@ -77,22 +77,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) : info.defaultTemperature - // The message list can include provider-specific meta entries such as - // `{ type: "reasoning", ... }` that are intended only for providers like - // openai-native. Gemini should never see those; they are not valid - // Anthropic.MessageParam values and will cause failures. - type ReasoningMetaLike = { type?: string } - - const filteredMessages = messages.filter((message) => { - const meta = message as ReasoningMetaLike - if (meta.type === "reasoning") { - return false - } - return true - }) - - // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to OpenAI format first, then to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 9731174f54..905f1f4da5 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -13,13 +13,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 4bb62f73af..ec26364ee5 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -9,7 +9,6 @@ import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index aa77b3a762..c91d91d0f9 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -12,7 +12,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" -import { convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 25b7070cbe..697c445a65 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -7,7 +7,6 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index e9720049d1..32b145e3e7 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -16,7 +16,6 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, @@ -25,6 +24,7 @@ import { } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -165,23 +165,17 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const provider = await this.createProvider(accessToken, metadata?.taskId) const languageModel = this.getLanguageModel(provider) - // Step 1: Collect encrypted reasoning items and their positions before filtering. + // Step 1: Collect encrypted reasoning items before sanitization strips them. const encryptedReasoningItems = collectEncryptedReasoningItems(messages) - // Step 2: Filter out standalone encrypted reasoning items (they lack role). - const standardMessages = messages.filter( - (msg) => - (msg as unknown as Record).type !== "reasoning" || - !(msg as unknown as Record).encrypted_content, - ) + // Step 2: Sanitize messages for the provider API (allowlist: role, content, providerOptions). + // This also filters out standalone RooReasoningMessage items (no role field). + const sanitizedMessages = sanitizeMessagesForProvider(messages) // Step 3: Strip plain-text reasoning blocks from assistant content arrays. - const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) + const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) as ModelMessage[] - // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] - - // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts. + // Step 4: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts. if (encryptedReasoningItems.length > 0) { injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[]) } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 3deeba6cf9..7c378f7bc6 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -12,13 +12,7 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { applyToolCacheOptions } from "../transform/cache-breakpoints" diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 5bc7ae8382..09fb9939e1 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -19,16 +19,11 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -38,7 +33,7 @@ export type OpenAiNativeModel = ReturnType /** * An encrypted reasoning item extracted from the conversation history. - * These are standalone items injected by `buildCleanConversationHistory` with + * These are standalone RooReasoningMessage items with * `{ type: "reasoning", encrypted_content: "...", id: "...", summary: [...] }`. */ export interface EncryptedReasoningItem { @@ -52,12 +47,13 @@ export interface EncryptedReasoningItem { * Strip plain-text reasoning blocks from assistant message content arrays. * * Plain-text reasoning blocks (`{ type: "reasoning", text: "..." }`) inside - * assistant content arrays would be converted by `convertToAiSdkMessages` - * into AI SDK reasoning parts WITHOUT `providerOptions.openai.itemId`. - * The `@ai-sdk/openai` Responses provider rejects those with console warnings. + * assistant content arrays would become AI SDK reasoning parts WITHOUT + * `providerOptions.openai.itemId`. The `@ai-sdk/openai` Responses provider + * rejects those with console warnings. * - * This function removes them BEFORE conversion. If an assistant message's - * content becomes empty after filtering, the message is removed entirely. + * This function removes them before sending to the API. If an assistant + * message's content becomes empty after filtering, the message is removed + * entirely. */ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessage[] { return messages.reduce((acc, msg) => { @@ -88,9 +84,8 @@ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessag /** * Collect encrypted reasoning items from the messages array. * - * These are standalone items with `type: "reasoning"` and `encrypted_content`, - * injected by `buildCleanConversationHistory` for OpenAI Responses API - * reasoning continuity. + * These are standalone RooReasoningMessage items with `type: "reasoning"` + * and `encrypted_content`, used for OpenAI Responses API reasoning continuity. */ export function collectEncryptedReasoningItems(messages: RooMessage[]): EncryptedReasoningItem[] { const items: EncryptedReasoningItem[] = [] @@ -419,26 +414,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastEncryptedContent = undefined this.lastServiceTier = undefined - // Step 1: Collect encrypted reasoning items and their positions before filtering. - // These are standalone items injected by buildCleanConversationHistory: - // { type: "reasoning", encrypted_content: "...", id: "...", summary: [...] } + // Step 1: Collect encrypted reasoning items before sanitization strips them. const encryptedReasoningItems = collectEncryptedReasoningItems(messages) - // Step 2: Filter out standalone encrypted reasoning items (they lack role - // and would break convertToAiSdkMessages which expects user/assistant/tool). - const standardMessages = messages.filter( - (msg) => (msg as any).type !== "reasoning" || !(msg as any).encrypted_content, - ) + // Step 2: Sanitize messages for the provider API (allowlist: role, content, providerOptions). + // This also filters out standalone RooReasoningMessage items (no role field). + const sanitizedMessages = sanitizeMessagesForProvider(messages) // Step 3: Strip plain-text reasoning blocks from assistant content arrays. // These would be converted to AI SDK reasoning parts WITHOUT // providerOptions.openai.itemId, which the Responses provider rejects. - const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) + const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) as ModelMessage[] - // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] - - // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning + // Step 4: Re-inject encrypted reasoning as properly-formed AI SDK reasoning // parts with providerOptions.openai.itemId and reasoningEncryptedContent. if (encryptedReasoningItems.length > 0) { injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[]) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index c6b3ec489c..9879b1ce13 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -17,7 +17,6 @@ import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index f9a1c685a3..578d36fa11 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -17,12 +17,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" import { getModelParams } from "../transform/model-params" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - processAiSdkStreamPart, - yieldResponseMessage, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, processAiSdkStreamPart, yieldResponseMessage } from "../transform/ai-sdk" import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { BaseProvider } from "./base-provider" diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 4bc05751aa..b49428e4c9 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -7,13 +7,7 @@ import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefau import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 456d2b6775..6e71a558a1 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -7,7 +7,6 @@ import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-c import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 4c9726860f..56cbf68914 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -12,7 +12,6 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index c46b43ecb8..669044b3ec 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -14,7 +14,6 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 057fbecdb2..3d3df048a5 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -6,16 +6,11 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@ import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" @@ -140,8 +135,8 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler const { temperature, reasoning } = this.getModel() const languageModel = this.getLanguageModel() - // Convert messages to AI SDK format - const aiSdkMessages = messages + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index d052de842d..af1f8cbd7b 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -14,13 +14,7 @@ import { import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 6d30099b08..8824889603 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -1,7 +1,5 @@ -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, consumeAiSdkStream, @@ -18,619 +16,6 @@ vitest.mock("ai", () => ({ })) describe("AI SDK conversion utilities", () => { - describe("convertToAiSdkMessages", () => { - it("converts simple string messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ role: "user", content: "Hello" }) - expect(result[1]).toEqual({ role: "assistant", content: "Hi there" }) - }) - - it("converts user messages with text content blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text", text: "Hello world" }], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: [{ type: "text", text: "Hello world" }], - }) - }) - - it("converts user messages with image content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64encodeddata", - }, - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - image: "data:image/png;base64,base64encodeddata", - mimeType: "image/png", - }, - ], - }) - }) - - it("converts user messages with URL image content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - source: { - type: "url", - url: "https://example.com/image.png", - }, - } as any, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - image: "https://example.com/image.png", - }, - ], - }) - }) - - it("converts tool results into separate tool role messages with resolved tool names", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: "call_123", - toolName: "read_file", - input: { path: "test.ts" }, - }, - ], - }) - // Tool results now go to role: "tool" messages per AI SDK v6 schema - expect(result[1]).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_123", - toolName: "read_file", - output: { type: "text", value: "Tool result content" }, - }, - ], - }) - }) - - it("uses unknown_tool for tool results without matching tool call", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_orphan", - content: "Orphan result", - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - // Tool results go to role: "tool" messages - expect(result[0]).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_orphan", - toolName: "unknown_tool", - output: { type: "text", value: "Orphan result" }, - }, - ], - }) - }) - - it("separates tool results and text content into different messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "File contents here", - }, - { - type: "text", - text: "Please analyze this file", - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: "call_123", - toolName: "read_file", - input: { path: "test.ts" }, - }, - ], - }) - // Tool results go first in a "tool" message - expect(result[1]).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_123", - toolName: "read_file", - output: { type: "text", value: "File contents here" }, - }, - ], - }) - // Text content goes in a separate "user" message - expect(result[2]).toEqual({ - role: "user", - content: [{ type: "text", text: "Please analyze this file" }], - }) - }) - - it("converts assistant messages with tool use", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me read that file" }, - { - type: "tool_use", - id: "call_456", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "text", text: "Let me read that file" }, - { - type: "tool-call", - toolCallId: "call_456", - toolName: "read_file", - input: { path: "test.ts" }, - }, - ], - }) - }) - - it("handles empty assistant content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [{ type: "text", text: "" }], - }) - }) - - it("converts assistant reasoning blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning" as any, text: "Thinking..." }, - { type: "text", text: "Answer" }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "Answer" }, - ], - }) - }) - - it("converts assistant thinking blocks to reasoning", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "thinking" as any, thinking: "Deep thought", signature: "sig" }, - { type: "text", text: "OK" }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Deep thought", - providerOptions: { - bedrock: { signature: "sig" }, - anthropic: { signature: "sig" }, - }, - }, - { type: "text", text: "OK" }, - ], - }) - }) - - it("converts assistant message-level reasoning_content to reasoning part", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Answer" }], - reasoning_content: "Thinking...", - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "Answer" }, - ], - }) - }) - - it("prefers message-level reasoning_content over reasoning blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning" as any, text: "BLOCK" }, - { type: "text", text: "Answer" }, - ], - reasoning_content: "MSG", - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "reasoning", text: "MSG" }, - { type: "text", text: "Answer" }, - ], - }) - }) - - it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me check that." }, - { - type: "tool_use", - id: "tool-1", - name: "read_file", - input: { path: "test.txt" }, - }, - { type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] - expect(assistantMsg.role).toBe("assistant") - - const content = assistantMsg.content as any[] - expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through) - - const toolCallPart = content.find((p: any) => p.type === "tool-call") - expect(toolCallPart).toBeDefined() - expect(toolCallPart.providerOptions).toEqual({ - google: { thoughtSignature: "encrypted-sig-abc" }, - vertex: { thoughtSignature: "encrypted-sig-abc" }, - }) - }) - - it("attaches thoughtSignature only to the first tool-call in parallel calls", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-1", - name: "get_weather", - input: { city: "Paris" }, - }, - { - type: "tool_use", - id: "tool-2", - name: "get_weather", - input: { city: "London" }, - }, - { type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - const content = (result[0] as any).content as any[] - - const toolCalls = content.filter((p: any) => p.type === "tool-call") - expect(toolCalls).toHaveLength(2) - - // Only the first tool call should have the signature - expect(toolCalls[0].providerOptions).toEqual({ - google: { thoughtSignature: "sig-parallel" }, - vertex: { thoughtSignature: "sig-parallel" }, - }) - // Second tool call should NOT have the signature - expect(toolCalls[1].providerOptions).toBeUndefined() - }) - - it("does not attach providerOptions when no thoughtSignature block is present", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Using tool" }, - { - type: "tool_use", - id: "tool-1", - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - const content = (result[0] as any).content as any[] - const toolCallPart = content.find((p: any) => p.type === "tool-call") - - expect(toolCallPart).toBeDefined() - expect(toolCallPart.providerOptions).toBeUndefined() - }) - - it("attaches valid reasoning_details as providerOptions.openrouter, filtering invalid entries", () => { - const validEncrypted = { - type: "reasoning.encrypted", - data: "encrypted_blob_data", - id: "tool_call_123", - format: "google-gemini-v1", - index: 0, - } - const invalidEncrypted = { - // type is "reasoning.encrypted" but has text instead of data — - // this is a plaintext summary mislabeled as encrypted by Gemini/OpenRouter. - // The provider's ReasoningDetailEncryptedSchema requires `data: string`, - // so including this causes the entire Zod safeParse to fail. - type: "reasoning.encrypted", - text: "Plaintext reasoning summary", - id: "tool_call_123", - format: "google-gemini-v1", - index: 0, - } - const textWithSignature = { - type: "reasoning.text", - text: "Some reasoning content", - signature: "stale-signature-from-previous-model", - } - - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Using a tool" }, - { - type: "tool_use", - id: "tool_call_123", - name: "attempt_completion", - input: { result: "done" }, - }, - ], - reasoning_details: [validEncrypted, invalidEncrypted, textWithSignature], - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.role).toBe("assistant") - expect(assistantMsg.providerOptions).toBeDefined() - expect(assistantMsg.providerOptions.openrouter).toBeDefined() - const details = assistantMsg.providerOptions.openrouter.reasoning_details - // Only the valid entries should survive filtering (invalidEncrypted dropped) - expect(details).toHaveLength(2) - expect(details[0]).toEqual(validEncrypted) - // Signatures should be preserved as-is for same-model Anthropic conversations via OpenRouter - expect(details[1]).toEqual(textWithSignature) - }) - - it("does not attach providerOptions when no reasoning_details are present", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Just text" }], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.providerOptions).toBeUndefined() - }) - - it("does not attach providerOptions when reasoning_details is an empty array", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Just text" }], - reasoning_details: [], - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.providerOptions).toBeUndefined() - }) - - it("preserves both reasoning_details and thoughtSignature providerOptions", () => { - const reasoningDetails = [ - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "tool_call_abc", - format: "google-gemini-v1", - index: 0, - }, - ] - - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "thoughtSignature", thoughtSignature: "sig-xyz" } as any, - { type: "text", text: "Using tool" }, - { - type: "tool_use", - id: "tool_call_abc", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - reasoning_details: reasoningDetails, - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - // Message-level providerOptions carries reasoning_details - expect(assistantMsg.providerOptions.openrouter.reasoning_details).toEqual(reasoningDetails) - // Part-level providerOptions carries thoughtSignature on the first tool-call - const toolCallPart = assistantMsg.content.find((p: any) => p.type === "tool-call") - expect(toolCallPart.providerOptions.google.thoughtSignature).toBe("sig-xyz") - }) - }) - describe("convertToolsForAiSdk", () => { it("returns undefined for empty tools", () => { expect(convertToolsForAiSdk(undefined)).toBeUndefined() diff --git a/src/api/transform/__tests__/anthropic-filter.spec.ts b/src/api/transform/__tests__/anthropic-filter.spec.ts deleted file mode 100644 index 46ad1a1952..0000000000 --- a/src/api/transform/__tests__/anthropic-filter.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -import { filterNonAnthropicBlocks, VALID_ANTHROPIC_BLOCK_TYPES } from "../anthropic-filter" - -describe("anthropic-filter", () => { - describe("VALID_ANTHROPIC_BLOCK_TYPES", () => { - it("should contain all valid Anthropic types", () => { - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("text")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("image")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("tool_use")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("tool_result")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("thinking")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("redacted_thinking")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("document")).toBe(true) - }) - - it("should not contain internal or provider-specific types", () => { - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("reasoning")).toBe(false) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("thoughtSignature")).toBe(false) - }) - }) - - describe("filterNonAnthropicBlocks", () => { - it("should pass through messages with string content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toEqual(messages) - }) - - it("should pass through messages with valid Anthropic blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - { - role: "assistant", - content: [{ type: "text", text: "Hi there!" }], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toEqual(messages) - }) - - it("should filter out reasoning blocks from messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "reasoning" as any, text: "Internal reasoning" }, - { type: "text", text: "Response" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(2) - expect(result[1].content).toEqual([{ type: "text", text: "Response" }]) - }) - - it("should filter out thoughtSignature blocks from messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "thoughtSignature", thoughtSignature: "encrypted-sig" } as any, - { type: "text", text: "Response" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(2) - expect(result[1].content).toEqual([{ type: "text", text: "Response" }]) - }) - - it("should remove messages that become empty after filtering", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [{ type: "reasoning" as any, text: "Only reasoning" }], - }, - { role: "user", content: "Continue" }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("Continue") - }) - - it("should handle mixed content with multiple invalid block types", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning", text: "Reasoning" } as any, - { type: "text", text: "Text 1" }, - { type: "thoughtSignature", thoughtSignature: "sig" } as any, - { type: "text", text: "Text 2" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toEqual([ - { type: "text", text: "Text 1" }, - { type: "text", text: "Text 2" }, - ]) - }) - - it("should filter out any unknown block types", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "unknown_future_type", data: "some data" } as any, - { type: "text", text: "Valid text" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toEqual([{ type: "text", text: "Valid text" }]) - }) - }) -}) diff --git a/src/api/transform/__tests__/mistral-format.spec.ts b/src/api/transform/__tests__/mistral-format.spec.ts deleted file mode 100644 index 290bea1ec5..0000000000 --- a/src/api/transform/__tests__/mistral-format.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -// npx vitest run api/transform/__tests__/mistral-format.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" - -import { convertToMistralMessages, normalizeMistralToolCallId } from "../mistral-format" - -describe("normalizeMistralToolCallId", () => { - it("should strip non-alphanumeric characters and truncate to 9 characters", () => { - // OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f" - expect(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f") - }) - - it("should handle Anthropic-style tool call IDs", () => { - // Anthropic-style tool call ID - expect(normalizeMistralToolCallId("toolu_01234567890abcdef")).toBe("toolu0123") - }) - - it("should pad short IDs to 9 characters", () => { - expect(normalizeMistralToolCallId("abc")).toBe("abc000000") - expect(normalizeMistralToolCallId("tool-1")).toBe("tool10000") - }) - - it("should handle IDs that are exactly 9 alphanumeric characters", () => { - expect(normalizeMistralToolCallId("abcd12345")).toBe("abcd12345") - }) - - it("should return consistent results for the same input", () => { - const id = "call_5019f900a247472bacde0b82" - expect(normalizeMistralToolCallId(id)).toBe(normalizeMistralToolCallId(id)) - }) - - it("should handle edge cases", () => { - // Empty string - expect(normalizeMistralToolCallId("")).toBe("000000000") - - // Only non-alphanumeric characters - expect(normalizeMistralToolCallId("---___---")).toBe("000000000") - - // Mixed special characters - expect(normalizeMistralToolCallId("a-b_c.d@e")).toBe("abcde0000") - }) -}) - -describe("convertToMistralMessages", () => { - it("should convert simple text messages for user and assistant roles", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(2) - expect(mistralMessages[0]).toEqual({ - role: "user", - content: "Hello", - }) - expect(mistralMessages[1]).toEqual({ - role: "assistant", - content: "Hi there!", - }) - }) - - it("should handle user messages with image content", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What is in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("user") - - const content = mistralMessages[0].content as Array<{ - type: string - text?: string - imageUrl?: { url: string } - }> - - expect(Array.isArray(content)).toBe(true) - expect(content).toHaveLength(2) - expect(content[0]).toEqual({ type: "text", text: "What is in this image?" }) - expect(content[1]).toEqual({ - type: "image_url", - imageUrl: { url: "data:image/jpeg;base64,base64data" }, - }) - }) - - it("should handle user messages with only tool results", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - // Tool results are converted to Mistral "tool" role messages - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("tool") - expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe( - normalizeMistralToolCallId("weather-123"), - ) - expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") - }) - - it("should handle user messages with mixed content (text, image, and tool results)", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Here's the weather data and an image:", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "imagedata123", - }, - }, - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - // Mistral doesn't allow user messages after tool messages, so only tool results are converted - // User content (text/images) is intentionally skipped when there are tool results - expect(mistralMessages).toHaveLength(1) - - // Only the tool result should be present - expect(mistralMessages[0].role).toBe("tool") - expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe( - normalizeMistralToolCallId("weather-123"), - ) - expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") - }) - - it("should handle assistant messages with text content", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll help you with that question.", - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBe("I'll help you with that question.") - }) - - it("should handle assistant messages with tool use", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me check the weather for you.", - }, - { - type: "tool_use", - id: "weather-123", - name: "get_weather", - input: { city: "London" }, - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBe("Let me check the weather for you.") - }) - - it("should handle multiple text blocks in assistant messages", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "First paragraph of information.", - }, - { - type: "text", - text: "Second paragraph with more details.", - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBe("First paragraph of information.\nSecond paragraph with more details.") - }) - - it("should handle a conversation with mixed message types", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What's in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "imagedata", - }, - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "This image shows a landscape with mountains.", - }, - { - type: "tool_use", - id: "search-123", - name: "search_info", - input: { query: "mountain types" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "search-123", - content: "Found information about different mountain types.", - }, - ], - }, - { - role: "assistant", - content: "Based on the search results, I can tell you more about the mountains in the image.", - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - // Tool results are now converted to tool messages - expect(mistralMessages).toHaveLength(4) - - // User message with image - expect(mistralMessages[0].role).toBe("user") - const userContent = mistralMessages[0].content as Array<{ - type: string - text?: string - imageUrl?: { url: string } - }> - expect(Array.isArray(userContent)).toBe(true) - expect(userContent).toHaveLength(2) - - // Assistant message with text and toolCalls - expect(mistralMessages[1].role).toBe("assistant") - expect(mistralMessages[1].content).toBe("This image shows a landscape with mountains.") - - // Tool result message - expect(mistralMessages[2].role).toBe("tool") - expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe( - normalizeMistralToolCallId("search-123"), - ) - expect(mistralMessages[2].content).toBe("Found information about different mountain types.") - - // Final assistant message - expect(mistralMessages[3]).toEqual({ - role: "assistant", - content: "Based on the search results, I can tell you more about the mountains in the image.", - }) - }) - - it("should handle empty content in assistant messages", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "search-123", - name: "search_info", - input: { query: "test query" }, - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBeUndefined() - }) -}) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts deleted file mode 100644 index 51628601ea..0000000000 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ /dev/null @@ -1,1349 +0,0 @@ -// npx vitest run api/transform/__tests__/openai-format.spec.ts - -import OpenAI from "openai" -import type { RooMessage } from "../../../core/task-persistence/rooMessage" - -import { - convertToOpenAiMessages, - consolidateReasoningDetails, - sanitizeGeminiMessages, - ReasoningDetail, -} from "../openai-format" -import { normalizeMistralToolCallId } from "../mistral-format" - -describe("convertToOpenAiMessages", () => { - it("should convert simple text messages", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(2) - expect(openAiMessages[0]).toEqual({ - role: "user", - content: "Hello", - }) - expect(openAiMessages[1]).toEqual({ - role: "assistant", - content: "Hi there!", - }) - }) - - it("should handle messages with image content", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What is in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ - type: string - text?: string - image_url?: { url: string } - }> - - expect(Array.isArray(content)).toBe(true) - expect(content).toHaveLength(2) - expect(content[0]).toEqual({ type: "text", text: "What is in this image?" }) - expect(content[1]).toEqual({ - type: "image_url", - image_url: { url: "data:image/jpeg;base64,base64data" }, - }) - }) - - it("should preserve AI SDK image data URLs without double-prefixing", () => { - const messages: any[] = [ - { - role: "user", - content: [ - { - type: "image", - image: "data:image/png;base64,already_encoded", - mediaType: "image/png", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(messages) - const content = openAiMessages[0].content as Array<{ type: string; image_url?: { url: string } }> - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,already_encoded" }, - }) - }) - - it("should preserve AI SDK image http URLs without converting to data URLs", () => { - const messages: any[] = [ - { - role: "user", - content: [ - { - type: "image", - image: "https://example.com/image.png", - mediaType: "image/png", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(messages) - const content = openAiMessages[0].content as Array<{ type: string; image_url?: { url: string } }> - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "https://example.com/image.png" }, - }) - }) - - it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me check the weather.", - }, - { - type: "tool_use", - id: "weather-123", - name: "get_weather", - input: { city: "London" }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.role).toBe("assistant") - expect(assistantMessage.content).toBe("Let me check the weather.") - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls![0]).toEqual({ - id: "weather-123", // Not normalized without normalizeToolCallId function - type: "function", - function: { - name: "get_weather", - arguments: JSON.stringify({ city: "London" }), - }, - }) - }) - - it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without normalizeToolCallId function - expect(toolMessage.content).toBe("Current temperature in London: 20°C") - }) - - it("should normalize tool call IDs when normalizeToolCallId function is provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_5019f900a247472bacde0b82", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "file contents", - }, - ], - }, - ] - - // With normalizeToolCallId function - should normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - normalizeToolCallId: normalizeMistralToolCallId, - }) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - - const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - }) - - it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_5019f900a247472bacde0b82", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "file contents", - }, - ], - }, - ] - - // Without normalizeToolCallId function - should NOT normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, {}) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe("call_5019f900a247472bacde0b82") - - const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82") - }) - - it("should use custom normalization function when provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "toolu_123", - name: "test_tool", - input: {}, - }, - ], - }, - ] - - // Custom normalization function that prefixes with "custom_" - const customNormalizer = (id: string) => `custom_${id}` - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { normalizeToolCallId: customNormalizer }) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123") - }) - - it("should use empty string for content when assistant message has only tool calls (Gemini compatibility)", () => { - // This test ensures that assistant messages with only tool_use blocks (no text) - // have content set to "" instead of undefined. Gemini (via OpenRouter) requires - // every message to have at least one "parts" field, which fails if content is undefined. - // See: ROO-425 - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.role).toBe("assistant") - // Content should be an empty string, NOT undefined - expect(assistantMessage.content).toBe("") - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls![0].id).toBe("tool-123") - }) - - it('should use "(empty)" placeholder for tool result with empty content (Gemini compatibility)', () => { - // This test ensures that tool messages with empty content get a placeholder instead - // of an empty string. Gemini (via OpenRouter) requires function responses to have - // non-empty content in the "parts" field, and an empty string causes validation failure - // with error: "Unable to submit request because it must include at least one parts field" - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "", // Empty string content - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("tool-123") - // Content should be "(empty)" placeholder, NOT empty string - expect(toolMessage.content).toBe("(empty)") - }) - - it('should use "(empty)" placeholder for tool result with undefined content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-456", - // content is undefined/not provided - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.content).toBe("(empty)") - }) - - it('should use "(empty)" placeholder for tool result with empty array content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-789", - content: [], // Empty array - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.content).toBe("(empty)") - }) - - describe("empty text block filtering", () => { - it("should filter out empty text blocks from user messages (Gemini compatibility)", () => { - // This test ensures that user messages with empty text blocks are filtered out - // to prevent "must include at least one parts field" error from Gemini (via OpenRouter). - // Empty text blocks can occur in edge cases during message construction. - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty text block should be filtered out - }, - { - type: "text", - text: "Hello, how are you?", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ type: string; text?: string }> - // Should only have the non-empty text block - expect(content).toHaveLength(1) - expect(content[0]).toEqual({ type: "text", text: "Hello, how are you?" }) - }) - - it("should not create user message when all text blocks are empty (Gemini compatibility)", () => { - // If all text blocks are empty, no user message should be created - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty - }, - { - type: "text", - text: "", // Also empty - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - // No messages should be created since all content is empty - expect(openAiMessages).toHaveLength(0) - }) - - it("should preserve image blocks when filtering empty text blocks", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty text block should be filtered out - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ - type: string - image_url?: { url: string } - }> - // Should only have the image block - expect(content).toHaveLength(1) - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,base64data" }, - }) - }) - }) - - describe("mergeToolResultText option", () => { - it("should merge text content into last tool message when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce only one tool message with merged content - expect(openAiMessages).toHaveLength(1) - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("tool-123") - expect(toolMessage.content).toBe( - "Tool result content\n\n\nSome context\n", - ) - }) - - it("should merge text into last tool message when multiple tool results exist", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_1", - content: "First result", - }, - { - type: "tool_result", - tool_use_id: "call_2", - content: "Second result", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce two tool messages, with text merged into the last one - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result") - expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( - "Second result\n\nContext", - ) - }) - - it("should NOT merge text when images are present (fall back to user message)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce a tool message AND a user message (because image is present) - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") - expect(openAiMessages[1].role).toBe("user") - }) - - it("should create separate user message when mergeToolResultText is false", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false }) - - // Should produce a tool message AND a separate user message (default behavior) - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( - "Tool result content", - ) - expect(openAiMessages[1].role).toBe("user") - }) - - it("should work with normalizeToolCallId when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "Tool result content", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - mergeToolResultText: true, - normalizeToolCallId: normalizeMistralToolCallId, - }) - - // Should merge AND normalize the ID - expect(openAiMessages).toHaveLength(1) - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - expect(toolMessage.content).toBe( - "Tool result content\n\nContext", - ) - }) - - it("should handle user messages with only text content (no tool results)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello, how are you?", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce a normal user message - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - }) - }) - - describe("reasoning_details transformation", () => { - it("should preserve reasoning_details when assistant content is a string", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: "Why don't scientists trust atoms? Because they make up everything!", - reasoning_details: [ - { - type: "reasoning.summary", - summary: "The user asked for a joke.", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data_here", - id: "rs_abc", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.role).toBe("assistant") - expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!") - expect(assistantMessage.reasoning_details).toHaveLength(2) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") - expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc") - }) - - it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: "Ok.", - reasoning_details: [ - { - type: "reasoning.summary", - id: "rs_should_be_stripped", - format: "openai-responses-v1", - index: 0, - summary: "internal", - data: "gAAAAA...", - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") - expect(assistantMessage.reasoning_details[0].id).toBeUndefined() - }) - - it("should pass through all reasoning_details without extracting to top-level reasoning", () => { - // This simulates the stored format after receiving from xAI/Roo API - // The provider (roo.ts) now consolidates all reasoning into reasoning_details - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "I'll help you with that." }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: '\n\n## Reviewing task progress', - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", - id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.role).toBe("assistant") - - // Should NOT have top-level reasoning field - we only use reasoning_details now - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details preserving all fields - expect(assistantMessage.reasoning_details).toHaveLength(2) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[0].summary).toBe( - '\n\n## Reviewing task progress', - ) - expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") - expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") - expect(assistantMessage.reasoning_details[1].data).toBe( - "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", - ) - }) - - it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => { - // IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for - // reasoning blocks with format "openai-responses-v1" because we don't use - // `store: true` (we handle conversation state client-side). The error message is: - // "'{id}' not found. Items are not persisted when `store` is set to false." - const anthropicMessages = [ - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh", - name: "attempt_completion", - input: { - result: "Why did the developer go broke?\n\nBecause they used up all their cache.", - }, - }, - ], - reasoning_details: [ - { - type: "reasoning.summary", - id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5", - format: "openai-responses-v1", - index: 0, - summary: "It looks like I need to make sure I'm using the tool every time.", - data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - we only use reasoning_details now - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through reasoning_details preserving most fields BUT stripping id - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - // id should be STRIPPED for openai-responses-v1 format to avoid 404 errors - expect(assistantMessage.reasoning_details[0].id).toBeUndefined() - expect(assistantMessage.reasoning_details[0].summary).toBe( - "It looks like I need to make sure I'm using the tool every time.", - ) - expect(assistantMessage.reasoning_details[0].data).toBe( - "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", - ) - expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") - - // Should have tool_calls - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh") - }) - - it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => { - // For other formats like xai-responses-v1, we should preserve the id - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response" }], - reasoning_details: [ - { - type: "reasoning.encrypted", - id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", - format: "xai-responses-v1", - data: "encrypted_data_here", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should preserve id for xai-responses-v1 format - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") - expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1") - }) - - it("should handle assistant messages with tool_calls and reasoning_details", () => { - // This simulates a message with both tool calls and reasoning - const anthropicMessages = [ - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_62462410", - name: "read_file", - input: { files: [{ path: "alphametics.go" }] }, - }, - ], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "## Reading the file to understand the structure", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data_here", - id: "rs_12345", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(2) - - // Should have tool_calls - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls[0].id).toBe("call_62462410") - expect(assistantMessage.tool_calls[0].function.name).toBe("read_file") - }) - - it("should pass through reasoning_details with only encrypted blocks", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response text" }], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "rs_only_encrypted", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should still pass through reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") - }) - - it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response text" }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "Just a summary, no encrypted content", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through reasoning_details preserving the summary block - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content") - }) - - it("should handle messages without reasoning_details", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Simple response" }], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should not have reasoning or reasoning_details - expect(assistantMessage.reasoning).toBeUndefined() - expect(assistantMessage.reasoning_details).toBeUndefined() - }) - - it("should pass through multiple reasoning_details blocks preserving all fields", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response" }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "First part of thinking. ", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.summary", - summary: "Second part of thinking.", - format: "xai-responses-v1", - index: 1, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "rs_multi", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(3) - expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ") - expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.") - expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") - }) - }) -}) - -describe("consolidateReasoningDetails", () => { - it("should return empty array for empty input", () => { - expect(consolidateReasoningDetails([])).toEqual([]) - }) - - it("should return empty array for undefined input", () => { - expect(consolidateReasoningDetails(undefined as any)).toEqual([]) - }) - - it("should filter out corrupted encrypted blocks (missing data field)", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.encrypted", - // Missing data field - this should be filtered out - id: "rs_corrupted", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Valid reasoning", - id: "rs_valid", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Should only have the text block, not the corrupted encrypted block - expect(result).toHaveLength(1) - expect(result[0].type).toBe("reasoning.text") - expect(result[0].text).toBe("Valid reasoning") - }) - - it("should concatenate text from multiple entries with same index", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "First part. ", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Second part.", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(1) - expect(result[0].text).toBe("First part. Second part.") - }) - - it("should keep only the last encrypted block per index", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.encrypted", - data: "first_encrypted_data", - id: "rs_1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "second_encrypted_data", - id: "rs_2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Should only have one encrypted block - the last one - expect(result).toHaveLength(1) - expect(result[0].type).toBe("reasoning.encrypted") - expect(result[0].data).toBe("second_encrypted_data") - expect(result[0].id).toBe("rs_2") - }) - - it("should keep last signature and id from multiple entries", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "Part 1", - signature: "sig_1", - id: "id_1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Part 2", - signature: "sig_2", - id: "id_2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(1) - expect(result[0].signature).toBe("sig_2") - expect(result[0].id).toBe("id_2") - }) - - it("should group by index correctly", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "Index 0 text", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Index 1 text", - format: "google-gemini-v1", - index: 1, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(2) - expect(result.find((r) => r.index === 0)?.text).toBe("Index 0 text") - expect(result.find((r) => r.index === 1)?.text).toBe("Index 1 text") - }) - - it("should handle summary blocks", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.summary", - summary: "Summary part 1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.summary", - summary: "Summary part 2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Summary should be concatenated when there's no text - expect(result).toHaveLength(1) - expect(result[0].summary).toBe("Summary part 1Summary part 2") - }) -}) - -describe("sanitizeGeminiMessages", () => { - it("should return messages unchanged for non-Gemini models", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hello" }, - ] - - const result = sanitizeGeminiMessages(messages, "anthropic/claude-3-5-sonnet") - - expect(result).toEqual(messages) - }) - - it("should drop tool calls without reasoning_details for Gemini models", () => { - const messages = [ - { role: "system", content: "You are helpful" }, - { - role: "assistant", - content: "Let me read the file", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { name: "read_file", arguments: '{"path":"test.ts"}' }, - }, - ], - // No reasoning_details - }, - { role: "tool", tool_call_id: "call_123", content: "file contents" }, - ] as OpenAI.Chat.ChatCompletionMessageParam[] - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - // Should have 2 messages: system and assistant (with content but no tool_calls) - // Tool message should be dropped - expect(result).toHaveLength(2) - expect(result[0].role).toBe("system") - expect(result[1].role).toBe("assistant") - expect((result[1] as any).tool_calls).toBeUndefined() - }) - - it("should filter reasoning_details to only include entries matching tool call IDs", () => { - const messages = [ - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "valid_data", - id: "call_abc", // Matches tool call - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "mismatched_data", - id: "call_xyz", // Does NOT match any tool call - format: "google-gemini-v1", - index: 1, - }, - ], - }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.tool_calls).toHaveLength(1) - expect(assistantMsg.reasoning_details).toHaveLength(1) - expect(assistantMsg.reasoning_details[0].id).toBe("call_abc") - }) - - it("should drop tool calls without matching reasoning_details", () => { - const messages = [ - { - role: "assistant", - content: "Some text", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "tool_a", arguments: "{}" }, - }, - { - id: "call_def", - type: "function", - function: { name: "tool_b", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "data_for_abc", - id: "call_abc", // Only matches first tool call - format: "google-gemini-v1", - index: 0, - }, - ], - }, - { role: "tool", tool_call_id: "call_abc", content: "result a" }, - { role: "tool", tool_call_id: "call_def", content: "result b" }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - // Should have: assistant with 1 tool_call, 1 tool message - expect(result).toHaveLength(2) - - const assistantMsg = result[0] as any - expect(assistantMsg.tool_calls).toHaveLength(1) - expect(assistantMsg.tool_calls[0].id).toBe("call_abc") - - // Only the tool result for call_abc should remain - expect(result[1].role).toBe("tool") - expect((result[1] as any).tool_call_id).toBe("call_abc") - }) - - it("should include reasoning_details without id (legacy format)", () => { - const messages = [ - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.text", - text: "Some reasoning without id", - format: "google-gemini-v1", - index: 0, - // No id field - }, - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "call_abc", - format: "google-gemini-v1", - index: 0, - }, - ], - }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - // Both details should be included (one by matching id, one by having no id) - expect(assistantMsg.reasoning_details.length).toBeGreaterThanOrEqual(1) - }) - - it("should preserve messages without tool_calls", () => { - const messages = [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] as OpenAI.Chat.ChatCompletionMessageParam[] - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toEqual(messages) - }) -}) diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts deleted file mode 100644 index 3d875e9392..0000000000 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ /dev/null @@ -1,619 +0,0 @@ -// npx vitest run api/transform/__tests__/r1-format.spec.ts - -import { convertToR1Format } from "../r1-format" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -describe("convertToR1Format", () => { - it("should convert basic text messages", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should merge consecutive messages with same role", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "user", content: "How are you?" }, - { role: "assistant", content: "Hi!" }, - { role: "assistant", content: "I'm doing well" }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "user", content: "Hello\nHow are you?" }, - { role: "assistant", content: "Hi!\nI'm doing well" }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should handle image content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { - role: "user", - content: [ - { - type: "image_url", - image_url: { - url: "data:image/jpeg;base64,base64data", - }, - }, - ], - }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should handle mixed text and image content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Check this image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Check this image:" }, - { - type: "image_url", - image_url: { - url: "data:image/jpeg;base64,base64data", - }, - }, - ], - }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should merge mixed content messages with same role", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "First image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "image1", - }, - }, - ], - }, - { - role: "user", - content: [ - { type: "text", text: "Second image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "image2", - }, - }, - ], - }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "First image:" }, - { - type: "image_url", - image_url: { - url: "data:image/jpeg;base64,image1", - }, - }, - { type: "text", text: "Second image:" }, - { - type: "image_url", - image_url: { - url: "data:image/png;base64,image2", - }, - }, - ], - }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should handle empty messages array", () => { - expect(convertToR1Format([])).toEqual([]) - }) - - it("should handle messages with empty content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "" }, - { role: "assistant", content: "" }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "user", content: "" }, - { role: "assistant", content: "" }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - describe("tool calls support for DeepSeek interleaved thinking", () => { - it("should convert assistant messages with tool_use to OpenAI format", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "What's the weather?" }, - { - role: "assistant", - content: [ - { type: "text", text: "Let me check the weather for you." }, - { - type: "tool_use", - id: "call_123", - name: "get_weather", - input: { location: "San Francisco" }, - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ role: "user", content: "What's the weather?" }) - expect(result[1]).toMatchObject({ - role: "assistant", - content: "Let me check the weather for you.", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"San Francisco"}', - }, - }, - ], - }) - }) - - it("should convert user messages with tool_result to OpenAI tool messages", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "What's the weather?" }, - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_123", - name: "get_weather", - input: { location: "San Francisco" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "72°F and sunny", - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ role: "user", content: "What's the weather?" }) - expect(result[1]).toMatchObject({ - role: "assistant", - content: null, - tool_calls: expect.any(Array), - }) - expect(result[2]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "72°F and sunny", - }) - }) - - it("should handle tool_result with array content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_456", - content: [ - { type: "text", text: "Line 1" }, - { type: "text", text: "Line 2" }, - ], - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_456", - content: "Line 1\nLine 2", - }) - }) - - it("should preserve reasoning_content on assistant messages", () => { - const input = [ - { role: "user" as const, content: "Think about this" }, - { - role: "assistant" as const, - content: "Here's my answer", - reasoning_content: "Let me analyze step by step...", - }, - ] - - const result = convertToR1Format(input as Anthropic.Messages.MessageParam[]) - - expect(result).toHaveLength(2) - expect((result[1] as any).reasoning_content).toBe("Let me analyze step by step...") - }) - - it("should handle mixed tool_result and text in user message", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_789", - content: "Tool result", - }, - { - type: "text", - text: "Please continue", - }, - ], - }, - ] - - const result = convertToR1Format(input) - - // Should produce two messages: tool message first, then user message - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_789", - content: "Tool result", - }) - expect(result[1]).toEqual({ - role: "user", - content: "Please continue", - }) - }) - - it("should handle multiple tool calls in single assistant message", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_1", - name: "tool_a", - input: { param: "a" }, - }, - { - type: "tool_use", - id: "call_2", - name: "tool_b", - input: { param: "b" }, - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(1) - expect((result[0] as any).tool_calls).toHaveLength(2) - expect((result[0] as any).tool_calls[0].id).toBe("call_1") - expect((result[0] as any).tool_calls[1].id).toBe("call_2") - }) - - it("should not merge assistant messages that have tool calls", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_1", - name: "tool_a", - input: {}, - }, - ], - }, - { - role: "assistant", - content: "Follow up response", - }, - ] - - const result = convertToR1Format(input) - - // Should NOT merge because first message has tool calls - expect(result).toHaveLength(2) - expect((result[0] as any).tool_calls).toBeDefined() - expect(result[1]).toEqual({ - role: "assistant", - content: "Follow up response", - }) - }) - - describe("mergeToolResultText option for DeepSeek interleaved thinking", () => { - it("should merge text content into last tool message when mergeToolResultText is true", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce only one tool message with merged content - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result content\n\n\nSome context\n", - }) - }) - - it("should NOT merge text when mergeToolResultText is false (default behavior)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - { - type: "text", - text: "Please continue", - }, - ], - }, - ] - - // Without option (default behavior) - const result = convertToR1Format(input) - - // Should produce two messages: tool message + user message - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result content", - }) - expect(result[1]).toEqual({ - role: "user", - content: "Please continue", - }) - }) - - it("should merge text into last tool message when multiple tool results exist", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_1", - content: "First result", - }, - { - type: "tool_result", - tool_use_id: "call_2", - content: "Second result", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce two tool messages, with text merged into the last one - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_1", - content: "First result", - }) - expect(result[1]).toEqual({ - role: "tool", - tool_call_id: "call_2", - content: "Second result\n\nContext", - }) - }) - - it("should NOT merge when there are images (images need user message)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result", - }, - { - type: "text", - text: "Check this image", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "imagedata", - }, - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce tool message + user message with image - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result", - }) - expect(result[1]).toMatchObject({ - role: "user", - content: expect.arrayContaining([ - { type: "text", text: "Check this image" }, - { type: "image_url", image_url: expect.any(Object) }, - ]), - }) - }) - - it("should NOT merge when there are no tool results (text-only should remain user message)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Just a regular message", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce user message as normal - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: "Just a regular message", - }) - }) - - it("should preserve reasoning_content on assistant messages in same conversation", () => { - const input = [ - { role: "user" as const, content: "Start" }, - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_123", - name: "test_tool", - input: {}, - }, - ], - reasoning_content: "Let me think about this...", - }, - { - role: "user" as const, - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_123", - content: "Result", - }, - { - type: "text" as const, - text: "Context", - }, - ], - }, - ] - - const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], { - mergeToolResultText: true, - }) - - // Should have: user, assistant (with reasoning + tool_calls), tool - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ role: "user", content: "Start" }) - expect((result[1] as any).reasoning_content).toBe("Let me think about this...") - expect((result[1] as any).tool_calls).toBeDefined() - // Tool message should have merged content - expect(result[2]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Result\n\nContext", - }) - // Most importantly: NO user message after tool message - expect(result.filter((m) => m.role === "user")).toHaveLength(1) - }) - }) - }) -}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 3f879566a0..4c7abb9875 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -3,299 +3,11 @@ * These utilities are designed to be reused across different AI SDK providers. */ -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai" import type { AssistantModelMessage } from "ai" import type { ApiStreamChunk, ApiStream } from "./stream" -/** - * Options for converting Anthropic messages to AI SDK format. - */ -export interface ConvertToAiSdkMessagesOptions { - /** - * Optional function to transform the converted messages. - * Useful for transformations like flattening message content for models that require string content. - */ - transform?: (messages: ModelMessage[]) => ModelMessage[] -} - -/** - * Convert Anthropic messages to AI SDK ModelMessage format. - * Handles text, images, tool uses, and tool results. - * - * @param messages - Array of Anthropic message parameters - * @param options - Optional conversion options including post-processing function - * @returns Array of AI SDK ModelMessage objects - */ -export function convertToAiSdkMessages( - messages: Anthropic.Messages.MessageParam[], - options?: ConvertToAiSdkMessagesOptions, -): ModelMessage[] { - const modelMessages: ModelMessage[] = [] - - // First pass: build a map of tool call IDs to tool names from assistant messages - const toolCallIdToName = new Map() - for (const message of messages) { - if (message.role === "assistant" && typeof message.content !== "string") { - for (const part of message.content) { - if (part.type === "tool_use") { - toolCallIdToName.set(part.id, part.name) - } - } - } - } - - for (const message of messages) { - if (typeof message.content === "string") { - modelMessages.push({ - role: message.role, - content: message.content, - }) - } else { - if (message.role === "user") { - const parts: Array< - { type: "text"; text: string } | { type: "image"; image: string; mimeType?: string } - > = [] - const toolResults: Array<{ - type: "tool-result" - toolCallId: string - toolName: string - output: { type: "text"; value: string } - }> = [] - - for (const part of message.content) { - if (part.type === "text") { - parts.push({ type: "text", text: part.text }) - } else if (part.type === "image") { - // Handle both base64 and URL source types - const source = part.source as { type: string; media_type?: string; data?: string; url?: string } - if (source.type === "base64" && source.media_type && source.data) { - parts.push({ - type: "image", - image: `data:${source.media_type};base64,${source.data}`, - mimeType: source.media_type, - }) - } else if (source.type === "url" && source.url) { - parts.push({ - type: "image", - image: source.url, - }) - } - } else if (part.type === "tool_result") { - // Convert tool results to string content - let content: string - if (typeof part.content === "string") { - content = part.content - } else { - content = - part.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } - // Look up the tool name from the tool call ID - const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool" - toolResults.push({ - type: "tool-result", - toolCallId: part.tool_use_id, - toolName, - output: { type: "text", value: content || "(empty)" }, - }) - } - } - - // AI SDK requires tool results in separate "tool" role messages - // UserContent only supports: string | Array - // ToolContent (for role: "tool") supports: Array - if (toolResults.length > 0) { - modelMessages.push({ - role: "tool", - content: toolResults, - } as ModelMessage) - } - - // Add user message with only text/image content (no tool results) - if (parts.length > 0) { - modelMessages.push({ - role: "user", - content: parts, - } as ModelMessage) - } - } else if (message.role === "assistant") { - const textParts: string[] = [] - const reasoningParts: string[] = [] - const reasoningContent = (() => { - const maybe = (message as unknown as { reasoning_content?: unknown }).reasoning_content - return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined - })() - const toolCalls: Array<{ - type: "tool-call" - toolCallId: string - toolName: string - input: unknown - providerOptions?: Record> - }> = [] - - // Capture thinking signature for Anthropic-protocol providers (Bedrock, Anthropic). - // Task.ts stores thinking blocks as { type: "thinking", thinking: "...", signature: "..." }. - // The signature must be passed back via providerOptions on reasoning parts. - let thinkingSignature: string | undefined - - // Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping). - // Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks. - let thoughtSignature: string | undefined - for (const part of message.content) { - const partAny = part as unknown as { type?: string; thoughtSignature?: string } - if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) { - thoughtSignature = partAny.thoughtSignature - } - } - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - continue - } - - if (part.type === "tool_use") { - const toolCall: (typeof toolCalls)[number] = { - type: "tool-call", - toolCallId: part.id, - toolName: part.name, - input: part.input, - } - - // Attach thoughtSignature as providerOptions on tool-call parts. - // The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature - // and attaches it to the Gemini functionCall part. - // Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature. - if (thoughtSignature && toolCalls.length === 0) { - toolCall.providerOptions = { - google: { thoughtSignature }, - vertex: { thoughtSignature }, - } - } - - toolCalls.push(toolCall) - continue - } - - // Some providers (DeepSeek, Gemini, etc.) require reasoning to be round-tripped. - // Task stores reasoning as a content block (type: "reasoning") and Anthropic extended - // thinking as (type: "thinking"). Convert both to AI SDK's reasoning part. - if ((part as unknown as { type?: string }).type === "reasoning") { - // If message-level reasoning_content is present, treat it as canonical and - // avoid mixing it with content-block reasoning (which can cause duplication). - if (reasoningContent) continue - - const text = (part as unknown as { text?: string }).text - if (typeof text === "string" && text.length > 0) { - reasoningParts.push(text) - } - continue - } - - if ((part as unknown as { type?: string }).type === "thinking") { - if (reasoningContent) continue - - const thinkingPart = part as unknown as { thinking?: string; signature?: string } - if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) { - reasoningParts.push(thinkingPart.thinking) - } - // Capture the signature for round-tripping (Anthropic/Bedrock thinking). - if (thinkingPart.signature) { - thinkingSignature = thinkingPart.signature - } - continue - } - } - - const content: Array< - | { type: "reasoning"; text: string; providerOptions?: Record> } - | { type: "text"; text: string } - | { - type: "tool-call" - toolCallId: string - toolName: string - input: unknown - providerOptions?: Record> - } - > = [] - - if (reasoningContent) { - content.push({ type: "reasoning", text: reasoningContent }) - } else if (reasoningParts.length > 0) { - const reasoningPart: (typeof content)[number] = { - type: "reasoning", - text: reasoningParts.join(""), - } - // Attach thinking signature for Anthropic/Bedrock round-tripping. - // The AI SDK's @ai-sdk/amazon-bedrock reads providerOptions.bedrock.signature - // and attaches it to reasoningContent.reasoningText.signature in the Bedrock request. - if (thinkingSignature) { - reasoningPart.providerOptions = { - bedrock: { signature: thinkingSignature }, - anthropic: { signature: thinkingSignature }, - } - } - content.push(reasoningPart) - } - - if (textParts.length > 0) { - content.push({ type: "text", text: textParts.join("\n") }) - } - content.push(...toolCalls) - - // Carry reasoning_details through to providerOptions for OpenRouter round-tripping - // (used by Gemini 3, xAI, etc. for encrypted reasoning chain continuity). - // The @openrouter/ai-sdk-provider reads message-level providerOptions.openrouter.reasoning_details - // and validates them against ReasoningDetailUnionSchema (a strict Zod union). - // Invalid entries (e.g. type "reasoning.encrypted" without a `data` field) must be - // filtered out here, otherwise the entire safeParse fails and NO reasoning_details - // are included in the outgoing request. - const rawReasoningDetails = (message as unknown as { reasoning_details?: Record[] }) - .reasoning_details - const validReasoningDetails = rawReasoningDetails?.filter((detail) => { - switch (detail.type) { - case "reasoning.encrypted": - return typeof detail.data === "string" && detail.data.length > 0 - case "reasoning.text": - return typeof detail.text === "string" - case "reasoning.summary": - return typeof detail.summary === "string" - default: - return false - } - }) - - const assistantMessage: Record = { - role: "assistant", - content: content.length > 0 ? content : [{ type: "text", text: "" }], - } - - if (validReasoningDetails && validReasoningDetails.length > 0) { - assistantMessage.providerOptions = { - openrouter: { reasoning_details: validReasoningDetails }, - } - } - - modelMessages.push(assistantMessage as ModelMessage) - } - } - } - - // Apply transform if provided - if (options?.transform) { - return options.transform(modelMessages) - } - - return modelMessages -} - /** * Options for flattening AI SDK messages. */ diff --git a/src/api/transform/anthropic-filter.ts b/src/api/transform/anthropic-filter.ts deleted file mode 100644 index 2bfc6dccfd..0000000000 --- a/src/api/transform/anthropic-filter.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -/** - * Set of content block types that are valid for Anthropic API. - * Only these types will be passed through to the API. - * See: https://docs.anthropic.com/en/api/messages - */ -export const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ - "text", - "image", - "tool_use", - "tool_result", - "thinking", - "redacted_thinking", - "document", -]) - -/** - * Filters out non-Anthropic content blocks from messages before sending to Anthropic/Vertex API. - * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. - * This automatically filters out: - * - Internal "reasoning" blocks (Roo Code's internal representation) - * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) - * - Any other unknown block types - */ -export function filterNonAnthropicBlocks( - messages: Anthropic.Messages.MessageParam[], -): Anthropic.Messages.MessageParam[] { - return messages - .map((message) => { - if (typeof message.content === "string") { - return message - } - - const filteredContent = message.content.filter((block) => { - const blockType = (block as { type: string }).type - // Only keep block types that Anthropic recognizes - return VALID_ANTHROPIC_BLOCK_TYPES.has(blockType) - }) - - // If all content was filtered out, return undefined to filter the message later - if (filteredContent.length === 0) { - return undefined - } - - return { - ...message, - content: filteredContent, - } - }) - .filter((message): message is Anthropic.Messages.MessageParam => message !== undefined) -} diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts deleted file mode 100644 index d32f84d6e0..0000000000 --- a/src/api/transform/mistral-format.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage" -import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage" -import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage" -import { UserMessage } from "@mistralai/mistralai/models/components/usermessage" - -/** - * Normalizes a tool call ID to be compatible with Mistral's strict ID requirements. - * Mistral requires tool call IDs to be: - * - Only alphanumeric characters (a-z, A-Z, 0-9) - * - Exactly 9 characters in length - * - * This function extracts alphanumeric characters from the original ID and - * pads/truncates to exactly 9 characters, ensuring deterministic output. - * - * @param id - The original tool call ID (e.g., "call_5019f900a247472bacde0b82" or "toolu_123") - * @returns A normalized 9-character alphanumeric ID compatible with Mistral - */ -export function normalizeMistralToolCallId(id: string): string { - // Extract only alphanumeric characters - const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "") - - // Take first 9 characters, or pad with zeros if shorter - if (alphanumeric.length >= 9) { - return alphanumeric.slice(0, 9) - } - - // Pad with zeros to reach 9 characters - return alphanumeric.padEnd(9, "0") -} - -export type MistralMessage = - | (SystemMessage & { role: "system" }) - | (UserMessage & { role: "user" }) - | (AssistantMessage & { role: "assistant" }) - | (ToolMessage & { role: "tool" }) - -// Type for Mistral tool calls in assistant messages -type MistralToolCallMessage = { - id: string - type: "function" - function: { - name: string - arguments: string - } -} - -export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] { - const mistralMessages: MistralMessage[] = [] - - for (const anthropicMessage of anthropicMessages) { - if (typeof anthropicMessage.content === "string") { - mistralMessages.push({ - role: anthropicMessage.role, - content: anthropicMessage.content, - }) - } else { - if (anthropicMessage.role === "user") { - const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ - nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] - toolMessages: Anthropic.ToolResultBlockParam[] - }>( - (acc, part) => { - if (part.type === "tool_result") { - acc.toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - acc.nonToolMessages.push(part) - } // user cannot send tool_use messages - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // If there are tool results, handle them - // Mistral's message order is strict: user → assistant → tool → assistant - // We CANNOT put user messages after tool messages - if (toolMessages.length > 0) { - // Convert tool_result blocks to Mistral tool messages - for (const toolResult of toolMessages) { - let resultContent: string - if (typeof toolResult.content === "string") { - resultContent = toolResult.content - } else if (Array.isArray(toolResult.content)) { - // Extract text from content blocks - resultContent = toolResult.content - .filter((block): block is Anthropic.TextBlockParam => block.type === "text") - .map((block) => block.text) - .join("\n") - } else { - resultContent = "" - } - - mistralMessages.push({ - role: "tool", - toolCallId: normalizeMistralToolCallId(toolResult.tool_use_id), - content: resultContent, - } as ToolMessage & { role: "tool" }) - } - // Note: We intentionally skip any non-tool user content when there are tool results - // because Mistral doesn't allow user messages after tool messages - } else if (nonToolMessages.length > 0) { - // Only add user content if there are NO tool results - mistralMessages.push({ - role: "user", - content: nonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - imageUrl: { - url: `data:${part.source.media_type};base64,${part.source.data}`, - }, - } - } - return { type: "text", text: part.text } - }), - }) - } - } else if (anthropicMessage.role === "assistant") { - const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ - nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] - toolMessages: Anthropic.ToolUseBlockParam[] - }>( - (acc, part) => { - if (part.type === "tool_use") { - acc.toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - acc.nonToolMessages.push(part) - } // assistant cannot send tool_result messages - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - let content: string | undefined - if (nonToolMessages.length > 0) { - content = nonToolMessages - .map((part) => { - if (part.type === "image") { - return "" // impossible as the assistant cannot send images - } - return part.text - }) - .join("\n") - } - - // Convert tool_use blocks to Mistral toolCalls format - let toolCalls: MistralToolCallMessage[] | undefined - if (toolMessages.length > 0) { - toolCalls = toolMessages.map((toolUse) => ({ - id: normalizeMistralToolCallId(toolUse.id), - type: "function" as const, - function: { - name: toolUse.name, - arguments: - typeof toolUse.input === "string" ? toolUse.input : JSON.stringify(toolUse.input), - }, - })) - } - - // Mistral requires either content or toolCalls to be non-empty - // If we have toolCalls but no content, we need to handle this properly - const assistantMessage: AssistantMessage & { role: "assistant" } = { - role: "assistant", - content, - } - - if (toolCalls && toolCalls.length > 0) { - ;( - assistantMessage as AssistantMessage & { - role: "assistant" - toolCalls?: MistralToolCallMessage[] - } - ).toolCalls = toolCalls - } - - mistralMessages.push(assistantMessage) - } - } - } - - return mistralMessages -} diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts deleted file mode 100644 index 4a49d30e46..0000000000 --- a/src/api/transform/openai-format.ts +++ /dev/null @@ -1,555 +0,0 @@ -import OpenAI from "openai" -import { - type RooMessage, - type RooRoleMessage, - type AnyToolCallBlock, - type AnyToolResultBlock, - isRooRoleMessage, - isAnyToolCallBlock, - isAnyToolResultBlock, - getToolCallId, - getToolCallName, - getToolCallInput, - getToolResultCallId, - getToolResultContent, -} from "../../core/task-persistence/rooMessage" - -/** - * Type for OpenRouter's reasoning detail elements. - * @see https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response - */ -export type ReasoningDetail = { - /** - * Type of reasoning detail. - * @see https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types - */ - type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text" - text?: string - summary?: string - data?: string // Encrypted reasoning data - signature?: string | null - id?: string | null // Unique identifier for the reasoning detail - /** - * Format of the reasoning detail: - * - "unknown" - Format is not specified - * - "openai-responses-v1" - OpenAI responses format version 1 - * - "anthropic-claude-v1" - Anthropic Claude format version 1 (default) - * - "google-gemini-v1" - Google Gemini format version 1 - * - "xai-responses-v1" - xAI responses format version 1 - */ - format?: string - index?: number // Sequential index of the reasoning detail -} - -/** - * Consolidates reasoning_details by grouping by index and type. - * - Filters out corrupted encrypted blocks (missing `data` field) - * - For text blocks: concatenates text, keeps last signature/id/format - * - For encrypted blocks: keeps only the last one per index - * - * @param reasoningDetails - Array of reasoning detail objects - * @returns Consolidated array of reasoning details - * @see https://github.com/cline/cline/issues/8214 - */ -export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] { - if (!reasoningDetails || reasoningDetails.length === 0) { - return [] - } - - // Group by index - const groupedByIndex = new Map() - - for (const detail of reasoningDetails) { - // Drop corrupted encrypted reasoning blocks that would otherwise trigger: - // "Invalid input: expected string, received undefined" for reasoning_details.*.data - // See: https://github.com/cline/cline/issues/8214 - if (detail.type === "reasoning.encrypted" && !detail.data) { - continue - } - - const index = detail.index ?? 0 - if (!groupedByIndex.has(index)) { - groupedByIndex.set(index, []) - } - groupedByIndex.get(index)!.push(detail) - } - - // Consolidate each group - const consolidated: ReasoningDetail[] = [] - - for (const [index, details] of groupedByIndex.entries()) { - // Concatenate all text parts - let concatenatedText = "" - let concatenatedSummary = "" - let signature: string | undefined - let id: string | undefined - let format = "unknown" - let type = "reasoning.text" - - for (const detail of details) { - if (detail.text) { - concatenatedText += detail.text - } - if (detail.summary) { - concatenatedSummary += detail.summary - } - // Keep the signature from the last item that has one - if (detail.signature) { - signature = detail.signature - } - // Keep the id from the last item that has one - if (detail.id) { - id = detail.id - } - // Keep format and type from any item (they should all be the same) - if (detail.format) { - format = detail.format - } - if (detail.type) { - type = detail.type - } - } - - // Create consolidated entry for text - if (concatenatedText) { - const consolidatedEntry: ReasoningDetail = { - type: type, - text: concatenatedText, - signature: signature ?? undefined, - id: id ?? undefined, - format: format, - index: index, - } - consolidated.push(consolidatedEntry) - } - - // Create consolidated entry for summary (used by some providers) - if (concatenatedSummary && !concatenatedText) { - const consolidatedEntry: ReasoningDetail = { - type: type, - summary: concatenatedSummary, - signature: signature ?? undefined, - id: id ?? undefined, - format: format, - index: index, - } - consolidated.push(consolidatedEntry) - } - - // For encrypted chunks (data), only keep the last one - let lastDataEntry: ReasoningDetail | undefined - for (const detail of details) { - if (detail.data) { - lastDataEntry = { - type: detail.type, - data: detail.data, - signature: detail.signature ?? undefined, - id: detail.id ?? undefined, - format: detail.format, - index: index, - } - } - } - if (lastDataEntry) { - consolidated.push(lastDataEntry) - } - } - - return consolidated -} - -/** - * A RooRoleMessage that may carry `reasoning_details` from OpenAI/OpenRouter providers. - * Used to type-narrow instead of `as any` when accessing reasoning metadata. - */ -type MessageWithReasoningDetails = RooRoleMessage & { reasoning_details?: ReasoningDetail[] } - -/** - * Sanitizes OpenAI messages for Gemini models by filtering reasoning_details - * to only include entries that match the tool call IDs. - * - * Gemini models require thought signatures for tool calls. When switching providers - * mid-conversation, historical tool calls may not include Gemini reasoning details, - * which can poison the next request. This function: - * 1. Filters reasoning_details to only include entries matching tool call IDs - * 2. Drops tool_calls that lack any matching reasoning_details - * 3. Removes corresponding tool result messages for dropped tool calls - * - * @param messages - Array of OpenAI chat completion messages - * @param modelId - The model ID to check if sanitization is needed - * @returns Sanitized array of messages (unchanged if not a Gemini model) - * @see https://github.com/cline/cline/issues/8214 - */ -export function sanitizeGeminiMessages( - messages: OpenAI.Chat.ChatCompletionMessageParam[], - modelId: string, -): OpenAI.Chat.ChatCompletionMessageParam[] { - // Only sanitize for Gemini models - if (!modelId.includes("gemini")) { - return messages - } - - const droppedToolCallIds = new Set() - const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [] - - for (const msg of messages) { - if (msg.role === "assistant") { - const anyMsg = msg as any - const toolCalls = anyMsg.tool_calls as OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined - const reasoningDetails = anyMsg.reasoning_details as ReasoningDetail[] | undefined - - if (Array.isArray(toolCalls) && toolCalls.length > 0) { - const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0 - - if (!hasReasoningDetails) { - // No reasoning_details at all - drop all tool calls - for (const tc of toolCalls) { - if (tc?.id) { - droppedToolCallIds.add(tc.id) - } - } - // Keep any textual content, but drop the tool_calls themselves - if (anyMsg.content) { - sanitized.push({ role: "assistant", content: anyMsg.content } as any) - } - continue - } - - // Filter reasoning_details to only include entries matching tool call IDs - // This prevents mismatched reasoning details from poisoning the request - const validToolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] - const validReasoningDetails: ReasoningDetail[] = [] - - for (const tc of toolCalls) { - // Check if there's a reasoning_detail with matching id - const matchingDetails = reasoningDetails.filter((d) => d.id === tc.id) - - if (matchingDetails.length > 0) { - validToolCalls.push(tc) - validReasoningDetails.push(...matchingDetails) - } else { - // No matching reasoning_detail - drop this tool call - if (tc?.id) { - droppedToolCallIds.add(tc.id) - } - } - } - - // Also include reasoning_details that don't have an id (legacy format) - const detailsWithoutId = reasoningDetails.filter((d) => !d.id) - validReasoningDetails.push(...detailsWithoutId) - - // Build the sanitized message - const sanitizedMsg: any = { - role: "assistant", - content: anyMsg.content ?? "", - } - - if (validReasoningDetails.length > 0) { - sanitizedMsg.reasoning_details = consolidateReasoningDetails(validReasoningDetails) - } - - if (validToolCalls.length > 0) { - sanitizedMsg.tool_calls = validToolCalls - } - - sanitized.push(sanitizedMsg) - continue - } - } - - if (msg.role === "tool") { - const anyMsg = msg as any - if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) { - // Skip tool result for dropped tool call - continue - } - } - - sanitized.push(msg) - } - - return sanitized -} - -/** - * Options for converting messages to OpenAI format. - */ -export interface ConvertToOpenAiMessagesOptions { - /** - * Optional function to normalize tool call IDs for providers with strict ID requirements. - * When provided, this function will be applied to all tool call IDs. - * This allows callers to declare provider-specific ID format requirements. - */ - normalizeToolCallId?: (id: string) => string - /** - * If true, merge text content after tool results into the last tool message - * instead of creating a separate user message. This is critical for providers - * with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where - * a user message after tool results causes the model to drop all previous - * reasoning_content. Default is false for backward compatibility. - */ - mergeToolResultText?: boolean -} - -/** - * Converts RooMessage[] to OpenAI chat completion messages. - * Handles both AI SDK format (tool-call/tool-result) and legacy Anthropic format - * (tool_use/tool_result) for backward compatibility with persisted data. - */ -export function convertToOpenAiMessages( - messages: RooMessage[], - options?: ConvertToOpenAiMessagesOptions, -): OpenAI.Chat.ChatCompletionMessageParam[] { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] - - const mapReasoningDetails = (details: unknown): any[] | undefined => { - if (!Array.isArray(details)) { - return undefined - } - - return details.map((detail: any) => { - // Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API - // requires `store: true` to persist reasoning blocks. Since we manage - // conversation state client-side, we don't use `store: true`, and sending - // back the `id` field causes a 404 error. - if (detail?.format === "openai-responses-v1" && detail?.id) { - const { id, ...rest } = detail - return rest - } - return detail - }) - } - - // Use provided normalization function or identity function - const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id) - - /** Get image data URL from either AI SDK or legacy format. */ - const getImageDataUrl = (part: { - type: string - image?: string - mediaType?: string - source?: { media_type?: string; data?: string } - }): string => { - // AI SDK format: - // - raw base64 + mediaType: construct data URL - // - existing data/http(s) URL in image: pass through unchanged - if (part.image) { - const image = part.image.trim() - if (image.startsWith("data:") || /^https?:\/\//i.test(image)) { - return image - } - if (part.mediaType) { - return `data:${part.mediaType};base64,${image}` - } - } - // Legacy Anthropic format: { type: "image", source: { media_type, data } } - if (part.source?.media_type && part.source?.data) { - return `data:${part.source.media_type};base64,${part.source.data}` - } - return "" - } - - for (const message of messages) { - // Skip RooReasoningMessage (no role property) - if (!("role" in message)) { - continue - } - - if (typeof message.content === "string") { - // String content: simple text message - const messageWithDetails = message as MessageWithReasoningDetails - const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { - role: message.role as "user" | "assistant", - content: message.content, - } - - if (message.role === "assistant") { - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - baseMessage.reasoning_details = mapped - } - } - - openAiMessages.push(baseMessage) - } else if (message.role === "tool") { - // RooToolMessage: each tool-result → OpenAI tool message - if (Array.isArray(message.content)) { - for (const part of message.content) { - if (isAnyToolResultBlock(part as { type: string })) { - const resultBlock = part as AnyToolResultBlock - const rawContent = getToolResultContent(resultBlock) - let content: string - if (typeof rawContent === "string") { - content = rawContent - } else if (rawContent && typeof rawContent === "object" && "value" in rawContent) { - content = String((rawContent as { value: unknown }).value) - } else { - content = rawContent ? JSON.stringify(rawContent) : "" - } - openAiMessages.push({ - role: "tool", - tool_call_id: normalizeId(getToolResultCallId(resultBlock)), - content: content || "(empty)", - }) - } - } - } - } else if (message.role === "user") { - // User message: separate tool results from text/image content - // Persisted data may contain legacy Anthropic tool_result blocks alongside AI SDK parts, - // so we widen the element type to handle all possible block shapes. - const contentArray: Array<{ type: string }> = Array.isArray(message.content) - ? (message.content as unknown as Array<{ type: string }>) - : [] - - const nonToolMessages: Array<{ type: string; text?: unknown; [k: string]: unknown }> = [] - const toolMessages: AnyToolResultBlock[] = [] - - for (const part of contentArray) { - if (isAnyToolResultBlock(part)) { - toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - nonToolMessages.push(part as { type: string; text?: unknown; [k: string]: unknown }) - } - } - - // Process tool result messages FIRST - toolMessages.forEach((toolMessage) => { - const rawContent = getToolResultContent(toolMessage) - let content: string - - if (typeof rawContent === "string") { - content = rawContent - } else if (Array.isArray(rawContent)) { - content = - rawContent - .map((part: { type: string; text?: string }) => { - if (part.type === "image") { - return "(see following user message for image)" - } - return part.text - }) - .join("\n") ?? "" - } else if (rawContent && typeof rawContent === "object" && "value" in rawContent) { - content = String((rawContent as { value: unknown }).value) - } else { - content = rawContent ? JSON.stringify(rawContent) : "" - } - - openAiMessages.push({ - role: "tool", - tool_call_id: normalizeId(getToolResultCallId(toolMessage)), - content: content || "(empty)", - }) - }) - - // Process non-tool messages - // Filter out empty text blocks to prevent "must include at least one parts field" error - const filteredNonToolMessages = nonToolMessages.filter( - (part) => part.type === "image" || (part.type === "text" && part.text), - ) - - if (filteredNonToolMessages.length > 0) { - const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text") - const hasToolMessages = toolMessages.length > 0 - const shouldMergeIntoToolMessage = options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent - - if (shouldMergeIntoToolMessage) { - const lastToolMessage = openAiMessages[ - openAiMessages.length - 1 - ] as OpenAI.Chat.ChatCompletionToolMessageParam - if (lastToolMessage?.role === "tool") { - const additionalText = filteredNonToolMessages.map((part) => String(part.text ?? "")).join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - openAiMessages.push({ - role: "user", - content: filteredNonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - image_url: { - url: getImageDataUrl( - part as { - type: string - image?: string - mediaType?: string - source?: { media_type?: string; data?: string } - }, - ), - }, - } - } - return { type: "text", text: String(part.text ?? "") } - }), - }) - } - } - } else if (message.role === "assistant") { - // Assistant message: separate tool calls from text content - // Persisted data may contain legacy Anthropic tool_use blocks, so we widen - // the element type to accommodate both AI SDK and legacy block shapes. - const contentArray: Array<{ type: string }> = Array.isArray(message.content) - ? (message.content as unknown as Array<{ type: string }>) - : [] - - const nonToolMessages: Array<{ type: string; text?: unknown }> = [] - const toolCallMessages: AnyToolCallBlock[] = [] - - for (const part of contentArray) { - if (isAnyToolCallBlock(part)) { - toolCallMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - nonToolMessages.push(part as { type: string; text?: unknown }) - } - } - - // Process non-tool messages - let content: string | undefined - if (nonToolMessages.length > 0) { - content = nonToolMessages - .map((part) => { - if (part.type === "image") { - return "" - } - return part.text as string - }) - .join("\n") - } - - // Process tool call messages - let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolCallMessages.map((tc) => ({ - id: normalizeId(getToolCallId(tc)), - type: "function" as const, - function: { - name: getToolCallName(tc), - arguments: JSON.stringify(getToolCallInput(tc)), - }, - })) - - const messageWithDetails = message as MessageWithReasoningDetails - - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { - reasoning_details?: any[] - } = { - role: "assistant", - content: content ?? "", - } - - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - baseMessage.reasoning_details = mapped - } - - if (tool_calls.length > 0) { - baseMessage.tool_calls = tool_calls - } - - openAiMessages.push(baseMessage) - } - } - - return openAiMessages -} diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts deleted file mode 100644 index 8231e24f76..0000000000 --- a/src/api/transform/r1-format.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText -type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage -type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam -type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam -type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam -type Message = OpenAI.Chat.ChatCompletionMessageParam -type AnthropicMessage = Anthropic.Messages.MessageParam - -/** - * Extended assistant message type to support DeepSeek's interleaved thinking. - * DeepSeek's API returns reasoning_content alongside content and tool_calls, - * and requires it to be passed back in subsequent requests within the same turn. - */ -export type DeepSeekAssistantMessage = AssistantMessage & { - reasoning_content?: string -} - -/** - * Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role. - * This is required for DeepSeek Reasoner which does not support successive messages with the same role. - * - * For DeepSeek's interleaved thinking mode: - * - Preserves reasoning_content on assistant messages for tool call continuations - * - Tool result messages are converted to OpenAI tool messages - * - reasoning_content from previous assistant messages is preserved until a new user turn - * - Text content after tool_results (like environment_details) is merged into the last tool message - * to avoid creating user messages that would cause reasoning_content to be dropped - * - * @param messages Array of Anthropic messages - * @param options Optional configuration for message conversion - * @param options.mergeToolResultText If true, merge text content after tool_results into the last - * tool message instead of creating a separate user message. - * This is critical for DeepSeek's interleaved thinking mode. - * @returns Array of OpenAI messages where consecutive messages with the same role are combined - */ -export function convertToR1Format( - messages: AnthropicMessage[], - options?: { mergeToolResultText?: boolean }, -): Message[] { - const result: Message[] = [] - - for (const message of messages) { - // Check if the message has reasoning_content (for DeepSeek interleaved thinking) - const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string } - const reasoningContent = messageWithReasoning.reasoning_content - - if (message.role === "user") { - // Handle user messages - may contain tool_result blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] - const toolResults: { tool_use_id: string; content: string }[] = [] - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "image") { - imageParts.push({ - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }) - } else if (part.type === "tool_result") { - // Convert tool_result to OpenAI tool message format - let content: string - if (typeof part.content === "string") { - content = part.content - } else if (Array.isArray(part.content)) { - content = - part.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } else { - content = "" - } - toolResults.push({ - tool_use_id: part.tool_use_id, - content, - }) - } - } - - // Add tool messages first (they must follow assistant tool_use) - for (const toolResult of toolResults) { - const toolMessage: ToolMessage = { - role: "tool", - tool_call_id: toolResult.tool_use_id, - content: toolResult.content, - } - result.push(toolMessage) - } - - // Handle text/image content after tool results - if (textParts.length > 0 || imageParts.length > 0) { - // For DeepSeek interleaved thinking: when mergeToolResultText is enabled and we have - // tool results followed by text, merge the text into the last tool message to avoid - // creating a user message that would cause reasoning_content to be dropped. - // This is critical because DeepSeek drops all reasoning_content when it sees a user message. - const shouldMergeIntoToolMessage = - options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 - - if (shouldMergeIntoToolMessage) { - // Merge text content into the last tool message - const lastToolMessage = result[result.length - 1] as ToolMessage - if (lastToolMessage?.role === "tool") { - const additionalText = textParts.join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - // Standard behavior: add user message with text/image content - let content: UserMessage["content"] - if (imageParts.length > 0) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) - } - parts.push(...imageParts) - content = parts - } else { - content = textParts.join("\n") - } - - // Check if we can merge with the last message - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - // Merge with existing user message - if (typeof lastMessage.content === "string" && typeof content === "string") { - lastMessage.content += `\n${content}` - } else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - const newContent = Array.isArray(content) - ? content - : [{ type: "text" as const, text: content }] - lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] - } - } else { - result.push({ role: "user", content }) - } - } - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - ;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({ - type: "text", - text: message.content, - }) - } - } else { - result.push({ role: "user", content: message.content }) - } - } - } else if (message.role === "assistant") { - // Handle assistant messages - may contain tool_use blocks and reasoning blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] - let extractedReasoning: string | undefined - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "tool_use") { - toolCalls.push({ - id: part.id, - type: "function", - function: { - name: part.name, - arguments: JSON.stringify(part.input), - }, - }) - } else if ((part as any).type === "reasoning" && (part as any).text) { - // Extract reasoning from content blocks (Task stores it this way) - extractedReasoning = (part as any).text - } - } - - // Use reasoning from content blocks if not provided at top level - const finalReasoning = reasoningContent || extractedReasoning - - const assistantMessage: DeepSeekAssistantMessage = { - role: "assistant", - content: textParts.length > 0 ? textParts.join("\n") : null, - ...(toolCalls.length > 0 && { tool_calls: toolCalls }), - // Preserve reasoning_content for DeepSeek interleaved thinking - ...(finalReasoning && { reasoning_content: finalReasoning }), - } - - // Check if we can merge with the last message (only if no tool calls) - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) { - // Merge text content - if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") { - lastMessage.content += `\n${assistantMessage.content}` - } else if (assistantMessage.content) { - const lastContent = lastMessage.content || "" - lastMessage.content = `${lastContent}\n${assistantMessage.content}` - } - // Preserve reasoning_content from the new message if present - if (finalReasoning) { - ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning - } - } else { - result.push(assistantMessage) - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - lastMessage.content = message.content - } - // Preserve reasoning_content from the new message if present - if (reasoningContent) { - ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent - } - } else { - const assistantMessage: DeepSeekAssistantMessage = { - role: "assistant", - content: message.content, - ...(reasoningContent && { reasoning_content: reasoningContent }), - } - result.push(assistantMessage) - } - } - } - } - - return result -} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0f0930c794..d03c793095 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4384,10 +4384,9 @@ export class Task extends EventEmitter implements TaskLike { // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) - const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages) // Breakpoints 3-4: Apply cache breakpoints to the last 2 non-assistant messages - applyCacheBreakpoints(cleanConversationHistory.filter(isRooRoleMessage)) + applyCacheBreakpoints(messagesWithoutImages.filter(isRooRoleMessage)) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( @@ -4470,7 +4469,7 @@ export class Task extends EventEmitter implements TaskLike { // Reset the flag after using it this.skipPrevResponseIdOnce = false - const stream = this.api.createMessage(systemPrompt, cleanConversationHistory, metadata) + const stream = this.api.createMessage(systemPrompt, messagesWithoutImages, metadata) const iterator = stream[Symbol.asyncIterator]() // Set up abort handling - when the signal is aborted, clean up the controller reference @@ -4640,166 +4639,6 @@ export class Task extends EventEmitter implements TaskLike { return checkpointSave(this, force, suppressMessage) } - /** - * Prepares conversation history for the API request by sanitizing stored - * RooMessage items into valid AI SDK ModelMessage format. - * - * Condense/truncation filtering is handled upstream by getEffectiveApiHistory. - * This method: - * - * - Removes RooReasoningMessage items (standalone encrypted reasoning with no `role`) - * - Converts custom content blocks in assistant messages to valid AI SDK parts: - * - `thinking` (Anthropic) → `reasoning` part with signature in providerOptions - * - `redacted_thinking` (Anthropic) → stripped (no AI SDK equivalent) - * - `thoughtSignature` (Gemini) → extracted and attached to first tool-call providerOptions - * - `reasoning` with `encrypted_content` but no `text` → stripped (invalid reasoning part) - * - Carries `reasoning_details` (OpenRouter) through to providerOptions - * - Strips all reasoning when the provider does not support it - */ - private buildCleanConversationHistory(messages: RooMessage[]): RooMessage[] { - const preserveReasoning = this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider() - - return messages - .filter((msg) => { - // Always remove standalone RooReasoningMessage items (no `role` field → invalid ModelMessage) - if (isRooReasoningMessage(msg)) { - return false - } - return true - }) - .map((msg) => { - if (!isRooAssistantMessage(msg) || !Array.isArray(msg.content)) { - return msg - } - - // Detect native AI SDK format: content parts already have providerOptions - // (stored directly from result.response.messages). These don't need legacy sanitization. - const isNativeFormat = (msg.content as Array<{ providerOptions?: unknown }>).some( - (p) => p.providerOptions, - ) - - if (isNativeFormat) { - // Native format: only strip reasoning if the provider doesn't support it - if (!preserveReasoning) { - const filtered = (msg.content as Array<{ type: string }>).filter((p) => p.type !== "reasoning") - return { - ...msg, - content: filtered.length > 0 ? filtered : [{ type: "text" as const, text: "" }], - } as unknown as RooMessage - } - // Pass through unchanged — already in valid AI SDK format - return msg - } - - // Legacy path: sanitize old-format messages with custom block types - // (thinking, redacted_thinking, thoughtSignature) - - // Extract thoughtSignature block (Gemini 3) before filtering - let thoughtSignature: string | undefined - for (const part of msg.content) { - const partAny = part as unknown as { type?: string; thoughtSignature?: string } - if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) { - thoughtSignature = partAny.thoughtSignature - } - } - - const sanitized: Array<{ type: string; [key: string]: unknown }> = [] - let appliedThoughtSignature = false - - for (const part of msg.content) { - const partType = (part as { type: string }).type - - if (partType === "thinking") { - // Anthropic extended thinking → AI SDK reasoning part - if (!preserveReasoning) continue - const thinkingPart = part as unknown as { thinking?: string; signature?: string } - if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) { - const reasoningPart: Record = { - type: "reasoning", - text: thinkingPart.thinking, - } - if (thinkingPart.signature) { - reasoningPart.providerOptions = { - anthropic: { signature: thinkingPart.signature }, - bedrock: { signature: thinkingPart.signature }, - } - } - sanitized.push(reasoningPart as (typeof sanitized)[number]) - } - continue - } - - if (partType === "redacted_thinking") { - // No AI SDK equivalent — strip - continue - } - - if (partType === "thoughtSignature") { - // Extracted above, will be attached to first tool-call — strip block - continue - } - - if (partType === "reasoning") { - if (!preserveReasoning) continue - const reasoningPart = part as unknown as { text?: string; encrypted_content?: string } - // Only valid if it has a `text` field (AI SDK schema requires it) - if (typeof reasoningPart.text === "string" && reasoningPart.text.length > 0) { - sanitized.push(part as (typeof sanitized)[number]) - } - // Blocks with encrypted_content but no text are invalid → skip - continue - } - - if (partType === "tool-call" && thoughtSignature && !appliedThoughtSignature) { - // Attach Gemini thoughtSignature to the first tool-call - const toolCall = { ...(part as object) } as Record - toolCall.providerOptions = { - ...((toolCall.providerOptions as Record) ?? {}), - google: { thoughtSignature }, - vertex: { thoughtSignature }, - } - sanitized.push(toolCall as (typeof sanitized)[number]) - appliedThoughtSignature = true - continue - } - - // text, tool-call, tool-result, file — pass through - sanitized.push(part as (typeof sanitized)[number]) - } - - const content = sanitized.length > 0 ? sanitized : [{ type: "text" as const, text: "" }] - - // Carry reasoning_details through to providerOptions for OpenRouter round-tripping - const rawReasoningDetails = (msg as unknown as { reasoning_details?: Record[] }) - .reasoning_details - const validReasoningDetails = rawReasoningDetails?.filter((detail) => { - switch (detail.type) { - case "reasoning.encrypted": - return typeof detail.data === "string" && detail.data.length > 0 - case "reasoning.text": - return typeof detail.text === "string" - case "reasoning.summary": - return typeof detail.summary === "string" - default: - return false - } - }) - - const result: Record = { - ...msg, - content, - } - - if (validReasoningDetails && validReasoningDetails.length > 0) { - result.providerOptions = { - ...((msg as unknown as { providerOptions?: Record }).providerOptions ?? {}), - openrouter: { reasoning_details: validReasoningDetails }, - } - } - - return result as unknown as RooMessage - }) - } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) } diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts deleted file mode 100644 index 4dce6f04fd..0000000000 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import type { ClineProvider } from "../../webview/ClineProvider" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" - -// All vi.mock() calls are hoisted to the top of the file by Vitest -// and are applied before any imports are resolved - -// Mock vscode module before importing Task -vi.mock("vscode", () => ({ - workspace: { - createFileSystemWatcher: vi.fn(() => ({ - onDidCreate: vi.fn(), - onDidChange: vi.fn(), - onDidDelete: vi.fn(), - dispose: vi.fn(), - })), - getConfiguration: vi.fn(() => ({ - get: vi.fn(() => true), - })), - openTextDocument: vi.fn(), - applyEdit: vi.fn(), - }, - RelativePattern: vi.fn((base, pattern) => ({ base, pattern })), - window: { - createOutputChannel: vi.fn(() => ({ - appendLine: vi.fn(), - dispose: vi.fn(), - })), - createTextEditorDecorationType: vi.fn(() => ({ - dispose: vi.fn(), - })), - showTextDocument: vi.fn(), - activeTextEditor: undefined, - }, - Uri: { - file: vi.fn((path) => ({ fsPath: path })), - parse: vi.fn((str) => ({ toString: () => str })), - }, - Range: vi.fn(), - Position: vi.fn(), - WorkspaceEdit: vi.fn(() => ({ - replace: vi.fn(), - insert: vi.fn(), - delete: vi.fn(), - })), - ViewColumn: { - One: 1, - Two: 2, - Three: 3, - }, -})) - -// Mock other dependencies -vi.mock("../../services/mcp/McpServerManager", () => ({ - McpServerManager: { - getInstance: vi.fn().mockResolvedValue(null), - }, -})) - -vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ - TerminalRegistry: { - releaseTerminalsForTask: vi.fn(), - }, -})) - -vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureTaskCreated: vi.fn(), - captureTaskRestarted: vi.fn(), - captureConversationMessage: vi.fn(), - captureLlmCompletion: vi.fn(), - captureConsecutiveMistakeError: vi.fn(), - }, - }, -})) - -// Mock @roo-code/cloud to prevent socket.io-client initialization issues -vi.mock("@roo-code/cloud", () => ({ - CloudService: { - isEnabled: () => false, - }, - BridgeOrchestrator: { - subscribeToTask: vi.fn(), - }, -})) - -// Mock delay to prevent actual delays -vi.mock("delay", () => ({ - __esModule: true, - default: vi.fn().mockResolvedValue(undefined), -})) - -// Mock p-wait-for to prevent hanging on async conditions -vi.mock("p-wait-for", () => ({ - default: vi.fn().mockResolvedValue(undefined), -})) - -// Mock execa -vi.mock("execa", () => ({ - execa: vi.fn(), -})) - -// Mock fs/promises -vi.mock("fs/promises", () => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue("[]"), - unlink: vi.fn().mockResolvedValue(undefined), - rmdir: vi.fn().mockResolvedValue(undefined), -})) - -// Mock mentions -vi.mock("../../mentions", () => ({ - parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })), - openMention: vi.fn(), - getLatestTerminalOutput: vi.fn(), -})) - -// Mock extract-text -vi.mock("../../../integrations/misc/extract-text", () => ({ - extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), -})) - -// Mock getEnvironmentDetails -vi.mock("../../environment/getEnvironmentDetails", () => ({ - getEnvironmentDetails: vi.fn().mockResolvedValue(""), -})) - -// Mock RooIgnoreController -vi.mock("../../ignore/RooIgnoreController") - -// Mock condense -vi.mock("../../condense", () => ({ - summarizeConversation: vi.fn().mockResolvedValue({ - messages: [], - summary: "summary", - cost: 0, - newContextTokens: 1, - }), -})) - -// Mock storage utilities -vi.mock("../../../utils/storage", () => ({ - getTaskDirectoryPath: vi - .fn() - .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), - getSettingsDirectoryPath: vi - .fn() - .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), -})) - -// Mock fs utilities -vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockReturnValue(false), -})) - -// Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works -import { Task } from "../Task" - -describe("Task reasoning preservation", () => { - let mockProvider: Partial - let mockApiConfiguration: ProviderSettings - - beforeEach(() => { - // Mock provider with necessary methods - mockProvider = { - postStateToWebview: vi.fn().mockResolvedValue(undefined), - postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), - getState: vi.fn().mockResolvedValue({ - mode: "code", - experiments: {}, - }), - context: { - globalStorageUri: { fsPath: "/test/storage" }, - extensionPath: "/test/extension", - } as any, - log: vi.fn(), - updateTaskHistory: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - } - - mockApiConfiguration = { - apiProvider: "anthropic", - apiKey: "test-key", - } as ProviderSettings - }) - - it("should store native AI SDK format messages directly when providerOptions present", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - task.api = { - getResponseId: vi.fn().mockReturnValue("resp_123"), - } as any - - task.apiConversationHistory = [] - - // Simulate a native AI SDK response message (has providerOptions on reasoning part) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Let me think about this...", - providerOptions: { - anthropic: { signature: "sig_abc123" }, - }, - }, - { type: "text", text: "Here is my response." }, - ], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - expect(stored.role).toBe("assistant") - expect(stored.id).toBe("resp_123") - // Content preserved exactly as-is (no manual block injection) - expect(stored.content).toEqual([ - { - type: "reasoning", - text: "Let me think about this...", - providerOptions: { - anthropic: { signature: "sig_abc123" }, - }, - }, - { type: "text", text: "Here is my response." }, - ]) - }) - - it("should store messages without providerOptions via fallback path", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - task.api = { - getResponseId: vi.fn().mockReturnValue(undefined), - getEncryptedContent: vi.fn().mockReturnValue(undefined), - } as any - - task.apiConversationHistory = [] - - // Non-AI-SDK message (no providerOptions on content parts) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: "Here is my response." }], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - expect(stored.role).toBe("assistant") - expect(stored.content).toEqual([{ type: "text", text: "Here is my response." }]) - }) - - it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => { - // Create a task instance - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Mock the API to return a model with preserveReasoning enabled - const mockModelInfo: ModelInfo = { - contextWindow: 16000, - supportsPromptCache: true, - preserveReasoning: true, - } - - task.api = { - getModel: vi.fn().mockReturnValue({ - id: "test-model", - info: mockModelInfo, - }), - } as any - - // Mock the API conversation history - task.apiConversationHistory = [] - - const assistantMessage = "Here is my response." - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: assistantMessage }], - }) - - // Verify no reasoning blocks were added when no reasoning is present - expect((task.apiConversationHistory[0] as any).content).toEqual([ - { type: "text", text: "Here is my response." }, - ]) - }) - - it("should embed encrypted reasoning as first assistant content block", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - // Mock API handler to provide encrypted reasoning data and response id - task.api = { - getEncryptedContent: vi.fn().mockReturnValue({ - encrypted_content: "encrypted_payload", - id: "rs_test", - }), - getResponseId: vi.fn().mockReturnValue("resp_test"), - } as any - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: "Here is my response." }], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - expect(stored.role).toBe("assistant") - expect(Array.isArray(stored.content)).toBe(true) - expect(stored.id).toBe("resp_test") - - const [reasoningBlock, textBlock] = stored.content - - expect(reasoningBlock).toMatchObject({ - type: "reasoning", - encrypted_content: "encrypted_payload", - id: "rs_test", - }) - - expect(textBlock).toMatchObject({ - type: "text", - text: "Here is my response.", - }) - }) - - it("should store native format with redacted thinking in providerOptions", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - task.api = { - getResponseId: vi.fn().mockReturnValue("resp_456"), - } as any - - task.apiConversationHistory = [] - - // Simulate native format with redacted thinking (as AI SDK provides it) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Visible reasoning...", - providerOptions: { - anthropic: { signature: "sig_visible" }, - }, - }, - { - type: "reasoning", - text: "", - providerOptions: { - anthropic: { redactedData: "redacted_payload_abc" }, - }, - }, - { type: "text", text: "My answer." }, - ], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - // All content preserved as-is including redacted reasoning - expect(stored.content).toHaveLength(3) - expect(stored.content[0].providerOptions.anthropic.signature).toBe("sig_visible") - expect(stored.content[1].providerOptions.anthropic.redactedData).toBe("redacted_payload_abc") - }) -})