diff --git a/src/api/index.ts b/src/api/index.ts index 31da8302203..53aff562cf1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,8 +3,6 @@ import OpenAI from "openai" import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types" -import type { RooMessage } from "../core/task-persistence/rooMessage" - import { ApiStream } from "./transform/stream" import { @@ -91,7 +89,11 @@ export interface ApiHandlerCreateMessageMetadata { } export interface ApiHandler { - createMessage(systemPrompt: string, messages: RooMessage[], metadata?: ApiHandlerCreateMessageMetadata): ApiStream + createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream getModel(): { id: string; info: ModelInfo } diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 469f1a60ab2..3341a0f584b 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/anthropic-vertex.spec.ts import { AnthropicVertexHandler } from "../anthropic-vertex" @@ -185,7 +184,7 @@ describe("AnthropicVertexHandler", () => { }) describe("createMessage", () => { - const mockMessages: RooMessage[] = [ + const mockMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello", @@ -245,7 +244,7 @@ describe("AnthropicVertexHandler", () => { ) }) - it("should pass messages directly to streamText as ModelMessage[]", async () => { + it("should call convertToAiSdkMessages with the messages", async () => { mockStreamText.mockReturnValue(createMockStreamResult([])) const stream = handler.createMessage(systemPrompt, mockMessages) @@ -253,12 +252,7 @@ describe("AnthropicVertexHandler", () => { // consume } - // Messages are now already in ModelMessage format, passed directly to streamText - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - messages: mockMessages, - }), - ) + expect(convertToAiSdkMessages).toHaveBeenCalledWith(mockMessages) }) it("should pass tools through AI SDK conversion pipeline", async () => { diff --git a/src/api/providers/__tests__/azure.spec.ts b/src/api/providers/__tests__/azure.spec.ts index e95d7de46a6..587d941d0b8 100644 --- a/src/api/providers/__tests__/azure.spec.ts +++ b/src/api/providers/__tests__/azure.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls const { mockStreamText, mockGenerateText, mockCreateAzure } = vi.hoisted(() => ({ mockStreamText: vi.fn(), @@ -133,7 +132,7 @@ describe("AzureHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -377,7 +376,7 @@ describe("AzureHandler", () => { describe("tools", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Use a tool" }], diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts index 6e7b947cb5a..ced452f5a55 100644 --- a/src/api/providers/__tests__/base-provider.spec.ts +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" import { Anthropic } from "@anthropic-ai/sdk" import type { ModelInfo } from "@roo-code/types" @@ -8,7 +7,7 @@ import type { ApiStream } from "../../transform/stream" // Create a concrete implementation for testing class TestProvider extends BaseProvider { - createMessage(_systemPrompt: string, _messages: RooMessage[]): ApiStream { + createMessage(_systemPrompt: string, _messages: Anthropic.Messages.MessageParam[]): ApiStream { throw new Error("Not implemented") } diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts index 43b21f28dc4..e44b201f291 100644 --- a/src/api/providers/__tests__/baseten.spec.ts +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/baseten.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -102,7 +101,7 @@ describe("BasetenHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -282,7 +281,7 @@ describe("BasetenHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], @@ -390,7 +389,7 @@ describe("BasetenHandler", () => { describe("error handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 645202486c5..2cb09fc56db 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Mock TelemetryService before other imports const mockCaptureException = vi.fn() @@ -491,14 +490,17 @@ describe("AwsBedrockHandler", () => { it("should properly pass image content through to streamText via AI SDK messages", async () => { setupMockStreamText() - const messages: any[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ { type: "image", - image: `data:image/jpeg;base64,${mockImageData}`, - mimeType: "image/jpeg", + source: { + type: "base64", + data: mockImageData, + media_type: "image/jpeg", + }, }, { type: "text", @@ -528,7 +530,7 @@ describe("AwsBedrockHandler", () => { expect(userMsg).toBeDefined() expect(Array.isArray(userMsg.content)).toBe(true) - // Messages are already in AI SDK ImagePart format + // The AI SDK convertToAiSdkMessages converts images to { type: "image", image: "data:...", mimeType: "..." } const imagePart = userMsg.content.find((p: { type: string }) => p.type === "image") expect(imagePart).toBeDefined() expect(imagePart.image).toContain("data:image/jpeg;base64,") @@ -542,14 +544,17 @@ describe("AwsBedrockHandler", () => { it("should handle multiple images in a single message", async () => { setupMockStreamText() - const messages: any[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ { type: "image", - image: `data:image/jpeg;base64,${mockImageData}`, - mimeType: "image/jpeg", + source: { + type: "base64", + data: mockImageData, + media_type: "image/jpeg", + }, }, { type: "text", @@ -557,8 +562,11 @@ describe("AwsBedrockHandler", () => { }, { type: "image", - image: `data:image/png;base64,${mockImageData}`, - mimeType: "image/png", + source: { + type: "base64", + data: mockImageData, + media_type: "image/png", + }, }, { type: "text", @@ -753,7 +761,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: true, }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -786,7 +794,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: false, }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -820,7 +828,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: true, }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -873,7 +881,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: true, }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -1005,7 +1013,7 @@ describe("AwsBedrockHandler", () => { awsBedrockServiceTier: "PRIORITY", }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -1042,7 +1050,7 @@ describe("AwsBedrockHandler", () => { awsBedrockServiceTier: "FLEX", }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -1079,7 +1087,7 @@ describe("AwsBedrockHandler", () => { awsBedrockServiceTier: "PRIORITY", // Try to apply PRIORITY tier }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -1114,7 +1122,7 @@ describe("AwsBedrockHandler", () => { // No awsBedrockServiceTier specified }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Test message", @@ -1184,7 +1192,7 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello", @@ -1259,7 +1267,7 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello", diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 16bbfcb47a4..32bd3a029a1 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ mockStreamText: vi.fn(), @@ -174,7 +173,7 @@ describe("DeepSeekHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -401,7 +400,7 @@ describe("DeepSeekHandler", () => { describe("reasoning content with deepseek-reasoner", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -571,7 +570,7 @@ describe("DeepSeekHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index 867b5c0b2c9..77c4b10f45d 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/fireworks.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -364,7 +363,7 @@ describe("FireworksHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -731,7 +730,7 @@ describe("FireworksHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index c70b4be8d19..13875499ee6 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/gemini.spec.ts import { NoOutputGeneratedError } from "ai" @@ -103,7 +102,7 @@ describe("GeminiHandler", () => { }) describe("createMessage", () => { - const mockMessages: RooMessage[] = [ + const mockMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello", @@ -378,7 +377,7 @@ describe("GeminiHandler", () => { }) describe("error telemetry", () => { - const mockMessages: RooMessage[] = [ + const mockMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello", diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index ae582d946a2..83c1cfd4da3 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ mockStreamText: vi.fn(), mockGenerateText: vi.fn(), @@ -258,7 +257,7 @@ describe("LiteLLMHandler", () => { }) const systemPrompt = "You are a helpful assistant" - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] const generator = handler.createMessage(systemPrompt, messages) for await (const _chunk of generator) { diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index aaded984db1..0f6944e8dab 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls const { mockStreamText, mockGenerateText, mockWrapLanguageModel } = vi.hoisted(() => ({ mockStreamText: vi.fn(), @@ -61,7 +60,7 @@ describe("LmStudioHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 6c39016408c..683b931652c 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" import { describe, it, expect, beforeEach } from "vitest" import type { Anthropic } from "@anthropic-ai/sdk" @@ -23,7 +22,7 @@ const { mockGenerateText: vi.fn(), mockCreateAnthropic: vi.fn().mockReturnValue(mockModel), mockModel, - mockMergeEnvironmentDetailsForMiniMax: vi.fn((messages: RooMessage[]) => messages), + mockMergeEnvironmentDetailsForMiniMax: vi.fn((messages: Anthropic.Messages.MessageParam[]) => messages), mockHandleAiSdkError: vi.fn((error: unknown, providerName: string) => { const message = error instanceof Error ? error.message : String(error) return new Error(`${providerName}: ${message}`) @@ -97,7 +96,7 @@ async function collectChunks(stream: ApiStream): Promise { describe("MiniMaxHandler", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }], @@ -107,7 +106,9 @@ describe("MiniMaxHandler", () => { beforeEach(() => { vi.clearAllMocks() mockCreateAnthropic.mockReturnValue(mockModel) - mockMergeEnvironmentDetailsForMiniMax.mockImplementation((inputMessages: RooMessage[]) => inputMessages) + mockMergeEnvironmentDetailsForMiniMax.mockImplementation( + (inputMessages: Anthropic.Messages.MessageParam[]) => inputMessages, + ) mockHandleAiSdkError.mockImplementation((error: unknown, providerName: string) => { const message = error instanceof Error ? error.message : String(error) return new Error(`${providerName}: ${message}`) @@ -324,7 +325,7 @@ describe("MiniMaxHandler", () => { }) it("calls mergeEnvironmentDetailsForMiniMax before conversion", async () => { - const mergedMessages: RooMessage[] = [ + const mergedMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Merged message" }], diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 5f256900564..0cac881dffe 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls const { mockStreamText, mockGenerateText, mockCreateMistral } = vi.hoisted(() => ({ mockStreamText: vi.fn(), @@ -103,7 +102,7 @@ describe("MistralHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -330,7 +329,7 @@ describe("MistralHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index 340a405ef36..1bfd482fd94 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ mockStreamText: vi.fn(), @@ -122,7 +121,7 @@ describe("MoonshotHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -345,7 +344,7 @@ describe("MoonshotHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/openai-native-reasoning.spec.ts b/src/api/providers/__tests__/openai-native-reasoning.spec.ts index 7dfd6737a5e..ebad23ee118 100644 --- a/src/api/providers/__tests__/openai-native-reasoning.spec.ts +++ b/src/api/providers/__tests__/openai-native-reasoning.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/openai-native-reasoning.spec.ts import type { Anthropic } from "@anthropic-ai/sdk" @@ -17,50 +16,54 @@ describe("OpenAI Native reasoning helpers", () => { // ─────────────────────────────────────────────────────────── describe("stripPlainTextReasoningBlocks", () => { it("passes through user messages unchanged", () => { - const messages: RooMessage[] = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] const result = stripPlainTextReasoningBlocks(messages) expect(result).toEqual(messages) }) it("passes through assistant messages with only text blocks", () => { - const messages: RooMessage[] = [{ role: "assistant", content: [{ type: "text", text: "Hi there" }] }] + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] const result = stripPlainTextReasoningBlocks(messages) expect(result).toEqual(messages) }) it("passes through string-content assistant messages", () => { - const messages: RooMessage[] = [{ role: "assistant", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Hello" }] const result = stripPlainTextReasoningBlocks(messages) expect(result).toEqual(messages) }) it("strips plain-text reasoning blocks from assistant content", () => { - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ { type: "reasoning", text: "Let me think...", - } as any, + } as unknown as Anthropic.Messages.ContentBlockParam, { type: "text", text: "The answer is 42" }, ], }, ] const result = stripPlainTextReasoningBlocks(messages) expect(result).toHaveLength(1) - expect((result[0] as any).content).toEqual([{ type: "text", text: "The answer is 42" }]) + expect(result[0].content).toEqual([{ type: "text", text: "The answer is 42" }]) }) it("removes assistant messages whose content becomes empty after filtering", () => { - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ { type: "reasoning", text: "Thinking only...", - } as any, + } as unknown as Anthropic.Messages.ContentBlockParam, ], }, ] @@ -69,24 +72,24 @@ describe("OpenAI Native reasoning helpers", () => { }) it("preserves tool_use blocks alongside stripped reasoning", () => { - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ - { type: "reasoning", text: "Thinking..." } as any, + { type: "reasoning", text: "Thinking..." } as unknown as Anthropic.Messages.ContentBlockParam, { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, ], }, ] const result = stripPlainTextReasoningBlocks(messages) expect(result).toHaveLength(1) - expect((result[0] as any).content).toEqual([ + expect(result[0].content).toEqual([ { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, ]) }) it("does NOT strip blocks that have encrypted_content (those are not plain-text reasoning)", () => { - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ @@ -94,7 +97,7 @@ describe("OpenAI Native reasoning helpers", () => { type: "reasoning", text: "summary", encrypted_content: "abc123", - } as any, + } as unknown as Anthropic.Messages.ContentBlockParam, { type: "text", text: "Response" }, ], }, @@ -102,26 +105,32 @@ describe("OpenAI Native reasoning helpers", () => { const result = stripPlainTextReasoningBlocks(messages) expect(result).toHaveLength(1) // Both blocks should remain - expect((result[0] as any).content).toHaveLength(2) + expect(result[0].content).toHaveLength(2) }) it("handles multiple messages correctly", () => { - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Q1" }] }, { role: "assistant", - content: [{ type: "reasoning", text: "Think1" } as any, { type: "text", text: "A1" }], + content: [ + { type: "reasoning", text: "Think1" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "A1" }, + ], }, { role: "user", content: [{ type: "text", text: "Q2" }] }, { role: "assistant", - content: [{ type: "reasoning", text: "Think2" } as any, { type: "text", text: "A2" }], + content: [ + { type: "reasoning", text: "Think2" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "A2" }, + ], }, ] const result = stripPlainTextReasoningBlocks(messages) expect(result).toHaveLength(4) - expect((result[1] as any).content).toEqual([{ type: "text", text: "A1" }]) - expect((result[3] as any).content).toEqual([{ type: "text", text: "A2" }]) + expect(result[1].content).toEqual([{ type: "text", text: "A1" }]) + expect(result[3].content).toEqual([{ type: "text", text: "A2" }]) }) }) @@ -130,7 +139,7 @@ describe("OpenAI Native reasoning helpers", () => { // ─────────────────────────────────────────────────────────── describe("collectEncryptedReasoningItems", () => { it("returns empty array when no encrypted reasoning items exist", () => { - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, { role: "assistant", content: [{ type: "text", text: "Hi" }] }, ] @@ -148,7 +157,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "I thought about it" }], }, { role: "assistant", content: [{ type: "text", text: "Hi" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toHaveLength(1) @@ -178,7 +187,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Summary 2" }], }, { role: "assistant", content: [{ type: "text", text: "A2" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toHaveLength(2) @@ -192,7 +201,7 @@ describe("OpenAI Native reasoning helpers", () => { const messages = [ { type: "reasoning", id: "rs_x", text: "plain reasoning" }, { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toEqual([]) @@ -206,7 +215,7 @@ describe("OpenAI Native reasoning helpers", () => { encrypted_content: "enc_data", }, { role: "assistant", content: [{ type: "text", text: "Hi" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toHaveLength(1) @@ -239,7 +248,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "I considered the question" }], }, { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] // AI SDK messages (after filtering encrypted items + converting) const aiSdkMessages: ModelMessage[] = [ @@ -295,7 +304,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Thought 2" }], }, { role: "assistant", content: [{ type: "text", text: "A2" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Q1" }, @@ -353,7 +362,7 @@ describe("OpenAI Native reasoning helpers", () => { ], }, { role: "assistant", content: [{ type: "text", text: "Response" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Hi" }, @@ -388,7 +397,7 @@ describe("OpenAI Native reasoning helpers", () => { encrypted_content: "enc_nosummary", }, { role: "assistant", content: [{ type: "text", text: "Response" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Hi" }, @@ -428,7 +437,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Step B" }], }, { role: "assistant", content: [{ type: "text", text: "Done" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Hi" }, @@ -487,7 +496,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Thought after tool" }], }, { role: "assistant", content: [{ type: "text", text: "OK" }] }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] // AI SDK messages after conversion (tool_result splits into tool + user) const aiSdkMessages: ModelMessage[] = [ @@ -530,7 +539,7 @@ describe("OpenAI Native reasoning helpers", () => { id: "rs_orphan", encrypted_content: "enc_orphan", }, - ] as unknown as RooMessage[] + ] as unknown as Anthropic.Messages.MessageParam[] const aiSdkMessages: ModelMessage[] = [{ role: "user", content: "Hi" }] diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 0fbb9614be4..5742d7282bb 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/openai-native-usage.spec.ts const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ @@ -39,7 +38,7 @@ import type { ApiHandlerOptions } from "../../../shared/api" describe("OpenAiNativeHandler - usage metrics", () => { let handler: OpenAiNativeHandler const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [{ role: "user", content: "Hello!" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] beforeEach(() => { handler = new OpenAiNativeHandler({ diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 568ed9ce97b..d31b969cf9a 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/openai-native.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -42,7 +41,7 @@ describe("OpenAiNativeHandler", () => { let handler: OpenAiNativeHandler let mockOptions: ApiHandlerOptions const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index 19ba1dc2751..042c411f388 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/openai-usage-tracking.spec.ts import { Anthropic } from "@anthropic-ai/sdk" @@ -54,7 +53,7 @@ describe("OpenAiHandler with usage tracking fix", () => { describe("usage metrics with streaming", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 7ae2be56302..2399cbb4397 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/openai.spec.ts const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ @@ -155,7 +154,7 @@ describe("OpenAiHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -446,7 +445,7 @@ describe("OpenAiHandler", () => { }) describe("error handling", () => { - const testMessages: RooMessage[] = [ + const testMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -579,7 +578,7 @@ describe("OpenAiHandler", () => { const azureHandler = new OpenAiHandler(makeAzureOptions()) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", @@ -610,7 +609,7 @@ describe("OpenAiHandler", () => { openAiStreamingEnabled: false, }) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", @@ -685,7 +684,7 @@ describe("OpenAiHandler", () => { modelMaxTokens: 32000, }) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", @@ -721,7 +720,7 @@ describe("OpenAiHandler", () => { includeMaxTokens: false, }) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", @@ -751,7 +750,7 @@ describe("OpenAiHandler", () => { includeMaxTokens: true, }) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index c2d8e8b1326..ba039459202 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // pnpm --filter roo-cline test api/providers/__tests__/openrouter.spec.ts vitest.mock("vscode", () => ({})) @@ -269,7 +268,7 @@ describe("OpenRouterHandler", () => { }) const systemPrompt = "test system prompt" - const messages: RooMessage[] = [{ role: "user" as const, content: "test message" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] const generator = handler.createMessage(systemPrompt, messages) const chunks = [] diff --git a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts index b2d0a0bcef7..852df0c1400 100644 --- a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts +++ b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/qwen-code-native-tools.spec.ts const { @@ -262,7 +261,7 @@ describe("QwenCodeHandler (AI SDK)", () => { }) const handler = new QwenCodeHandler({ apiModelId: "qwen3-coder-plus", qwenCodeOauthPath: oauthPath }) - const messages: RooMessage[] = [{ role: "user", content: "Hi" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] const chunks = await collectStreamChunks(handler.createMessage("System", messages)) @@ -290,7 +289,7 @@ describe("QwenCodeHandler (AI SDK)", () => { }) const handler = new QwenCodeHandler({ apiModelId: "qwen3-coder-plus" }) - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] const chunks = await collectStreamChunks(handler.createMessage("System", messages)) @@ -366,7 +365,7 @@ describe("QwenCodeHandler (AI SDK)", () => { }) const handler = new QwenCodeHandler({ apiModelId: "qwen3-coder-plus" }) - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] await collectStreamChunks(handler.createMessage("System", messages)) diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 0967eefdd78..3be0b4451e1 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/requesty.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -135,7 +134,7 @@ describe("RequestyHandler", () => { describe("createMessage", () => { const systemPrompt = "test system prompt" - const messages: RooMessage[] = [{ role: "user" as const, content: "test message" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] it("generates correct stream chunks", async () => { async function* mockFullStream() { @@ -266,7 +265,9 @@ describe("RequestyHandler", () => { }) describe("native tool support", () => { - const toolMessages: RooMessage[] = [{ role: "user" as const, content: "What's the weather?" }] + const toolMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user" as const, content: "What's the weather?" }, + ] it("should include tools in request when tools are provided", async () => { const mockTools = [ diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 1764a6d8d48..9bac9b459c2 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -4,7 +4,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { rooDefaultModelId } from "@roo-code/types" import { ApiHandlerOptions } from "../../../shared/api" -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // Mock the AI SDK const mockStreamText = vitest.fn() @@ -139,7 +138,7 @@ describe("RooHandler", () => { let handler: RooHandler let mockOptions: ApiHandlerOptions const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello!", @@ -298,7 +297,7 @@ describe("RooHandler", () => { it("should handle multiple messages in conversation", async () => { mockStreamText.mockReturnValue(createMockStreamResult()) - const multipleMessages: RooMessage[] = [ + const multipleMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "First response" }, { role: "user", content: "Second message" }, diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 6c9e9931928..51bc256b769 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/sambanova.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -117,7 +116,7 @@ describe("SambaNovaHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -455,7 +454,7 @@ describe("SambaNovaHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], @@ -570,7 +569,7 @@ describe("SambaNovaHandler", () => { describe("error handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index f482c7cf2d4..d3190a944dd 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/vercel-ai-gateway.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -171,7 +170,7 @@ describe("VercelAiGatewayHandler", () => { const handler = new VercelAiGatewayHandler(mockOptions) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] const stream = handler.createMessage(systemPrompt, messages) const chunks = [] @@ -204,7 +203,7 @@ describe("VercelAiGatewayHandler", () => { }) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] await handler.createMessage(systemPrompt, messages).next() @@ -221,7 +220,7 @@ describe("VercelAiGatewayHandler", () => { const handler = new VercelAiGatewayHandler(mockOptions) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] await handler.createMessage(systemPrompt, messages).next() @@ -238,7 +237,7 @@ describe("VercelAiGatewayHandler", () => { const handler = new VercelAiGatewayHandler(mockOptions) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] await handler.createMessage(systemPrompt, messages).next() @@ -265,7 +264,7 @@ describe("VercelAiGatewayHandler", () => { const handler = new VercelAiGatewayHandler(mockOptions) const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] const stream = handler.createMessage(systemPrompt, messages) const chunks = [] diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 7ae0e5fdff9..cc90c144b2f 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/vertex.spec.ts // Mock vscode first to avoid import errors @@ -141,7 +140,7 @@ describe("VertexHandler", () => { }) describe("createMessage", () => { - const mockMessages: RooMessage[] = [ + const mockMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ] diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 219a17aeeb8..305305d2289 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" import type { Mock } from "vitest" // Mocks must come first, before imports @@ -144,7 +143,7 @@ describe("VsCodeLmHandler", () => { it("should stream text responses", async () => { const systemPrompt = "You are a helpful assistant" - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user" as const, content: "Hello", @@ -183,7 +182,7 @@ describe("VsCodeLmHandler", () => { it("should emit tool_call chunks when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user" as const, content: "Calculate 2+2", @@ -248,7 +247,7 @@ describe("VsCodeLmHandler", () => { it("should handle native tool calls when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user" as const, content: "Calculate 2+2", @@ -313,7 +312,7 @@ describe("VsCodeLmHandler", () => { it("should pass tools to request options when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user" as const, content: "Calculate 2+2", @@ -381,7 +380,7 @@ describe("VsCodeLmHandler", () => { it("should handle errors", async () => { const systemPrompt = "You are a helpful assistant" - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user" as const, content: "Hello", diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 10c3181dfb3..27e0a25f5cc 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run api/providers/__tests__/xai.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -142,7 +141,7 @@ describe("XAIHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -539,7 +538,7 @@ describe("XAIHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 789915d6eff..af3154e7783 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../../core/task-persistence/rooMessage" // npx vitest run src/api/providers/__tests__/zai.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls @@ -263,7 +262,7 @@ describe("ZAiHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: RooMessage[] = [ + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 7760587f4f2..30c1c602f81 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -30,7 +30,6 @@ import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { @@ -86,7 +85,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() @@ -96,7 +95,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple this.lastRedactedThinkingBlocks = [] // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -140,7 +139,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } const userMsgIndices = messages.reduce( - (acc, msg, index) => ("role" in msg && msg.role === "user" ? [...acc, index] : acc), + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[], ) @@ -152,7 +151,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages as ModelMessage[], targetIndices, cacheProviderOption) + this.applyCacheControlToAiSdkMessages(messages, aiSdkMessages, targetIndices, cacheProviderOption) } // Build streamText request @@ -269,16 +268,57 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple * accounts for that split so cache control lands on the right message. */ private applyCacheControlToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, + targetOriginalIndices: Set, cacheProviderOption: Record>, ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cacheProviderOption, + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) + + if (hasToolResults && hasNonToolContent) { + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx += 2 + } else if (hasToolResults) { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ } + } else { + aiSdkIdx++ } } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 16d34247328..e04748cbc5a 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createAnthropic } from "@ai-sdk/anthropic" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -29,7 +29,6 @@ import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions @@ -73,7 +72,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() @@ -83,7 +82,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa this.lastRedactedThinkingBlocks = [] // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -116,7 +115,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } const userMsgIndices = messages.reduce( - (acc, msg, index) => ("role" in msg && msg.role === "user" ? [...acc, index] : acc), + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[], ) @@ -128,7 +127,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages as ModelMessage[], targetIndices, cacheProviderOption) + this.applyCacheControlToAiSdkMessages(messages, aiSdkMessages, targetIndices, cacheProviderOption) } // Build streamText request @@ -245,16 +244,57 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa * accounts for that split so cache control lands on the right message. */ private applyCacheControlToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, + targetOriginalIndices: Set, cacheProviderOption: Record>, ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cacheProviderOption, + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) + + if (hasToolResults && hasNonToolContent) { + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx += 2 + } else if (hasToolResults) { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ } + } else { + aiSdkIdx++ } } } diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index baad07558cb..dd527ad1654 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createAzure } from "@ai-sdk/azure" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/types" @@ -19,7 +19,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" const AZURE_DEFAULT_TEMPERATURE = 0 @@ -132,14 +131,14 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() const languageModel = this.getLanguageModel() // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 27aa58ba0f4..817af53a494 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -1,5 +1,4 @@ import { Anthropic } from "@anthropic-ai/sdk" -import type { RooMessage } from "../../core/task-persistence/rooMessage" import type { ModelInfo } from "@roo-code/types" @@ -14,7 +13,7 @@ import { isMcpTool } from "../../utils/mcp-name" export abstract class BaseProvider implements ApiHandler { abstract createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 4057b4714d2..cb450e658c0 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createBaseten } from "@ai-sdk/baseten" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/types" @@ -19,7 +19,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" const BASETEN_DEFAULT_TEMPERATURE = 0.5 @@ -95,13 +94,13 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() const languageModel = this.getLanguageModel() - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index ccfdb8613f1..0bb5936c2ba 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" @@ -38,7 +38,6 @@ import { DEFAULT_HEADERS } from "./constants" import { logger } from "../../utils/logging" import { Package } from "../../shared/package" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /************************************************************************************ * @@ -189,7 +188,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() @@ -201,7 +200,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // 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 filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { const meta = message as ReasoningMetaLike if (meta.type === "reasoning") { return false @@ -210,7 +209,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH }) // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) // Convert tools to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -279,7 +278,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // Find all user message indices in the original (pre-conversion) message array. const originalUserIndices = filteredMessages.reduce( - (acc, msg, idx) => ("role" in msg && msg.role === "user" ? [...acc, idx] : acc), + (acc, msg, idx) => (msg.role === "user" ? [...acc, idx] : acc), [], ) @@ -314,7 +313,12 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // A single original user message with tool_results becomes [tool-role msg, user-role msg] // in the AI SDK array, while a plain user message becomes [user-role msg]. if (targetOriginalIndices.size > 0) { - this.applyCachePointsToAiSdkMessages(aiSdkMessages, targetOriginalIndices, cachePointOption) + this.applyCachePointsToAiSdkMessages( + filteredMessages, + aiSdkMessages, + targetOriginalIndices, + cachePointOption, + ) } } @@ -743,16 +747,63 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH * accounts for that split so cache points land on the right message. */ private applyCachePointsToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, + targetOriginalIndices: Set, cachePointOption: Record>, ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cachePointOption, + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + // Simple string content → 1 AI SDK message + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + // User message with array content may split into tool + user messages. + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) + + if (hasToolResults && hasNonToolContent) { + // Split into tool msg + user msg — cache the user msg (the second one) + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx += 2 + } else if (hasToolResults) { + // Only tool results → 1 tool msg + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } else { + // Only text/image content → 1 user msg + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ } + } else { + // Assistant message → 1 AI SDK message + aiSdkIdx++ } } } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 6dc30163561..9181ad1ce3a 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createDeepSeek } from "@ai-sdk/deepseek" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" @@ -19,7 +19,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * DeepSeek provider using the dedicated @ai-sdk/deepseek package. @@ -110,14 +109,14 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() const languageModel = this.getLanguageModel() // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/fake-ai.ts b/src/api/providers/fake-ai.ts index 3b44b575847..b6bb9fd2c34 100644 --- a/src/api/providers/fake-ai.ts +++ b/src/api/providers/fake-ai.ts @@ -5,7 +5,6 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandler, SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" -import type { RooMessage } from "../../core/task-persistence/rooMessage" interface FakeAI { /** @@ -22,7 +21,11 @@ interface FakeAI { */ removeFromCache?: () => void - createMessage(systemPrompt: string, messages: RooMessage[], metadata?: ApiHandlerCreateMessageMetadata): ApiStream + createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream getModel(): { id: string; info: ModelInfo } countTokens(content: Array): Promise completePrompt(prompt: string): Promise @@ -58,7 +61,7 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler { async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { yield* this.ai.createMessage(systemPrompt, messages, metadata) diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index cfc459c1448..453bde8ad47 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createFireworks } from "@ai-sdk/fireworks" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-code/types" @@ -19,7 +19,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" const FIREWORKS_DEFAULT_TEMPERATURE = 0.5 @@ -110,14 +109,14 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() const languageModel = this.getLanguageModel() // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 728fdf4ad76..5a596eea68b 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, @@ -27,7 +27,6 @@ import { getModelParams } from "../transform/model-params" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export class GeminiHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -52,7 +51,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl async *createMessage( systemInstruction: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel() @@ -82,7 +81,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Anthropic.MessageParam values and will cause failures. type ReasoningMetaLike = { type?: string } - const filteredMessages = messages.filter((message) => { + const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { const meta = message as ReasoningMetaLike if (meta.type === "reasoning") { return false @@ -91,7 +90,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl }) // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) // Convert tools to OpenAI format first, then to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 8bca632f4c6..1566ea9ba07 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -18,7 +18,6 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { OpenAICompatibleHandler } from "./openai-compatible" import { getModels, getModelsFromCache } from "./fetchers/modelCache" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export class LiteLLMHandler extends OpenAICompatibleHandler implements SingleCompletionHandler { private models: ModelRecord = {} @@ -81,7 +80,7 @@ export class LiteLLMHandler extends OpenAICompatibleHandler implements SingleCom override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { await this.fetchModel() diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 751c4ccde4f..589164ff98c 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -1,13 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { - streamText, - generateText, - ToolSet, - wrapLanguageModel, - extractReasoningMiddleware, - LanguageModel, - ModelMessage, -} from "ai" +import { streamText, generateText, ToolSet, wrapLanguageModel, extractReasoningMiddleware, LanguageModel } from "ai" import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATURE } from "@roo-code/types" @@ -25,7 +17,6 @@ import { ApiStream } from "../transform/stream" import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCompletionHandler { constructor(options: ApiHandlerOptions) { @@ -58,13 +49,13 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const model = this.getModel() const languageModel = this.getLanguageModel() - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 8bacea7b4a2..d1fcc1c3648 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createAnthropic } from "@ai-sdk/anthropic" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" @@ -20,7 +20,6 @@ import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export class MiniMaxHandler extends BaseProvider implements SingleCompletionHandler { private client: ReturnType @@ -59,7 +58,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() @@ -76,8 +75,8 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand defaultTemperature: 1.0, }) - const mergedMessages = mergeEnvironmentDetailsForMiniMax(messages as any) - const aiSdkMessages = mergedMessages as ModelMessage[] + const mergedMessages = mergeEnvironmentDetailsForMiniMax(messages) + const aiSdkMessages = convertToAiSdkMessages(mergedMessages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined @@ -108,7 +107,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(aiSdkMessages, targetIndices, cacheProviderOption) + this.applyCacheControlToAiSdkMessages(mergedMessages, aiSdkMessages, targetIndices, cacheProviderOption) } const requestOptions = { @@ -129,7 +128,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand try { const result = streamText(requestOptions as Parameters[0]) - + let lastStreamError: string | undefined for await (const part of result.fullStream) { @@ -213,16 +212,57 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand } private applyCacheControlToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, + targetOriginalIndices: Set, cacheProviderOption: Record>, ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cacheProviderOption, + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) + + if (hasToolResults && hasNonToolContent) { + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx += 2 + } else if (hasToolResults) { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ } + } else { + aiSdkIdx++ } } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index e11ac0a79ee..73f272a30ad 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createMistral } from "@ai-sdk/mistral" -import { streamText, generateText, ToolSet, LanguageModel, ModelMessage } from "ai" +import { streamText, generateText, ToolSet, LanguageModel } from "ai" import { mistralModels, @@ -19,7 +19,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Mistral provider using the dedicated @ai-sdk/mistral package. @@ -138,13 +137,13 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const languageModel = this.getLanguageModel() // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index e73eee73daf..8a4a618ac2e 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createOllama } from "ollama-ai-provider-v2" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" @@ -18,7 +18,6 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import { getOllamaModels } from "./fetchers/ollama" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * NativeOllamaHandler using the ollama-ai-provider-v2 AI SDK community provider. @@ -84,7 +83,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { await this.fetchModel() @@ -94,7 +93,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const languageModel = this.getLanguageModel() - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 3e21de61d26..410d9fbdac8 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -2,7 +2,7 @@ import * as os from "os" import { v7 as uuidv7 } from "uuid" import { Anthropic } from "@anthropic-ai/sdk" import { createOpenAI } from "@ai-sdk/openai" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { Package } from "../../shared/package" import { @@ -28,7 +28,6 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth" -import type { RooMessage } from "../../core/task-persistence/rooMessage" import { stripPlainTextReasoningBlocks, collectEncryptedReasoningItems, @@ -144,7 +143,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const model = this.getModel() @@ -178,11 +177,11 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(cleanedMessages) // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts. if (encryptedReasoningItems.length > 0) { - injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[]) + injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages) } // Convert tools to OpenAI format first, then to AI SDK format diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 56ca670756f..d92c5fbbfc3 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -6,7 +6,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" -import { streamText, generateText, LanguageModel, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, LanguageModel, ToolSet } from "ai" import type { ModelInfo } from "@roo-code/types" @@ -24,7 +24,6 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Configuration options for creating an OpenAI-compatible provider. @@ -125,14 +124,14 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const model = this.getModel() const languageModel = this.getLanguageModel() // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index c966cc35375..d11ecdd6437 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -31,7 +31,6 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export type OpenAiNativeModel = ReturnType @@ -58,14 +57,16 @@ export interface EncryptedReasoningItem { * This function removes them BEFORE conversion. 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) => { - if (!("role" in msg) || msg.role !== "assistant" || typeof msg.content === "string") { +export function stripPlainTextReasoningBlocks( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + return messages.reduce((acc, msg) => { + if (msg.role !== "assistant" || typeof msg.content === "string") { acc.push(msg) return acc } - const filteredContent = (msg.content as any[]).filter((block: any) => { + const filteredContent = msg.content.filter((block) => { const b = block as unknown as Record // Remove blocks that are plain-text reasoning: // type === "reasoning" AND has "text" AND does NOT have "encrypted_content" @@ -77,7 +78,7 @@ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessag // Only include the message if it still has content if (filteredContent.length > 0) { - acc.push({ ...msg, content: filteredContent } as RooMessage) + acc.push({ ...msg, content: filteredContent }) } return acc @@ -91,10 +92,10 @@ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessag * injected by `buildCleanConversationHistory` for OpenAI Responses API * reasoning continuity. */ -export function collectEncryptedReasoningItems(messages: RooMessage[]): EncryptedReasoningItem[] { +export function collectEncryptedReasoningItems(messages: Anthropic.Messages.MessageParam[]): EncryptedReasoningItem[] { const items: EncryptedReasoningItem[] = [] messages.forEach((msg, index) => { - const m = msg as any + const m = msg as unknown as Record if (m.type === "reasoning" && m.encrypted_content) { items.push({ id: m.id as string, @@ -123,7 +124,7 @@ export function collectEncryptedReasoningItems(messages: RooMessage[]): Encrypte export function injectEncryptedReasoning( aiSdkMessages: ModelMessage[], encryptedItems: EncryptedReasoningItem[], - originalMessages: RooMessage[], + originalMessages: Anthropic.Messages.MessageParam[], ): void { if (encryptedItems.length === 0) return @@ -134,7 +135,7 @@ export function injectEncryptedReasoning( // Walk forward from the encrypted item to find its corresponding assistant message, // skipping over any other encrypted reasoning items. for (let i = item.originalIndex + 1; i < originalMessages.length; i++) { - const msg = originalMessages[i] as any + const msg = originalMessages[i] as unknown as Record if (msg.type === "reasoning" && msg.encrypted_content) continue if ((msg as { role?: string }).role === "assistant") { const existing = itemsByAssistantOrigIdx.get(i) || [] @@ -152,7 +153,7 @@ export function injectEncryptedReasoning( // encrypted reasoning items have been filtered out (order preserved). const standardAssistantOriginalIndices: number[] = [] for (let i = 0; i < originalMessages.length; i++) { - const msg = originalMessages[i] as any + const msg = originalMessages[i] as unknown as Record if (msg.type === "reasoning" && msg.encrypted_content) continue if ((msg as { role?: string }).role === "assistant") { standardAssistantOriginalIndices.push(i) @@ -397,7 +398,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const model = this.getModel() @@ -415,7 +416,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // 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, + (msg) => + (msg as unknown as Record).type !== "reasoning" || + !(msg as unknown as Record).encrypted_content, ) // Step 3: Strip plain-text reasoning blocks from assistant content arrays. @@ -424,12 +427,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(cleanedMessages) // Step 5: 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[]) + injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages) } const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 862ec292eaa..29ae5f0b32f 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { createAzure } from "@ai-sdk/azure" -import { streamText, generateText, ToolSet, LanguageModel, ModelMessage } from "ai" +import { streamText, generateText, ToolSet, LanguageModel } from "ai" import axios from "axios" import { @@ -29,7 +29,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" // TODO: Rename this to OpenAICompatibleHandler. Also, I think the // `OpenAINativeHandler` can subclass from this, since it's obviously @@ -94,7 +93,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { info: modelInfo, temperature, reasoning } = this.getModel() @@ -105,7 +104,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const languageModel = this.getLanguageModel() - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined @@ -171,7 +170,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl private async *handleStreaming( languageModel: LanguageModel, systemPrompt: string | undefined, - messages: ModelMessage[], + messages: ReturnType, temperature: number | undefined, tools: ToolSet | undefined, metadata: ApiHandlerCreateMessageMetadata | undefined, @@ -240,7 +239,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl private async *handleNonStreaming( languageModel: LanguageModel, systemPrompt: string | undefined, - messages: ModelMessage[], + messages: ReturnType, temperature: number | undefined, tools: ToolSet | undefined, metadata: ApiHandlerCreateMessageMetadata | undefined, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 7a8c8f2271c..d48fc4bb430 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { streamText, generateText, ModelMessage } from "ai" +import { streamText, generateText } from "ai" import { type ModelRecord, @@ -28,7 +28,6 @@ import { generateImageWithProvider, ImageGenerationResult } from "./utils/image- import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" import type { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream" -import type { RooMessage } from "../../core/task-persistence/rooMessage" export class OpenRouterHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -131,7 +130,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { this.currentReasoningDetails = [] @@ -150,7 +149,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH ? { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } : undefined - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openrouter = this.createOpenRouterProvider({ reasoning, headers }) diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index c90854d6303..23919506c1b 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -16,7 +16,6 @@ import { ApiStream } from "../transform/stream" import { DEFAULT_HEADERS } from "./constants" import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token` @@ -275,7 +274,7 @@ export class QwenCodeHandler extends OpenAICompatibleHandler implements SingleCo override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { await this.ensureAuthenticated() diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 29a43acb9bc..5f8e2cbc451 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createRequesty, type RequestyProviderMetadata } from "@requesty/ai-sdk" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types" @@ -23,7 +23,6 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { toRequestyServiceUrl } from "../../shared/utils/requesty" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Requesty provider using the dedicated @requesty/ai-sdk package. @@ -173,13 +172,13 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { info, temperature } = await this.fetchModel() const languageModel = this.getLanguageModel() - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 8a7b8dfeb38..3b57b9cd745 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" -import { streamText, generateText, type ModelMessage } from "ai" +import { streamText, generateText } from "ai" import { rooDefaultModelId, getApiProtocol, type ImageGenerationApiMethod } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" @@ -10,7 +10,13 @@ import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { convertToolsForAiSdk, processAiSdkStreamPart, handleAiSdkError, mapToolChoice } from "../transform/ai-sdk" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + handleAiSdkError, + mapToolChoice, +} from "../transform/ai-sdk" import { type ReasoningDetail } from "../transform/openai-format" import type { RooReasoningParams } from "../transform/reasoning" import { getRooReasoning } from "../transform/reasoning" @@ -20,7 +26,6 @@ import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache } from "./fetchers/modelCache" import { generateImageWithProvider, generateImageWithImagesApi, ImageGenerationResult } from "./utils/image-generation" import { t } from "../../i18n" -import type { RooMessage } from "../../core/task-persistence/rooMessage" function getSessionToken(): string { const token = CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined @@ -90,7 +95,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { // Reset reasoning_details accumulator for this request @@ -122,8 +127,8 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler // Create per-request provider with fresh session token const provider = this.createRooProvider({ reasoning, taskId: metadata?.taskId }) - // RooMessage[] is already AI SDK-compatible, cast directly - const aiSdkMessages = messages as ModelMessage[] + // Convert messages and tools to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) const tools = convertToolsForAiSdk(this.convertToolsForOpenAI(metadata?.tools)) let accumulatedReasoningText = "" diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 8f12d757ca0..71d2b66ab5d 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createSambaNova } from "sambanova-ai-provider" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-code/types" @@ -20,7 +20,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" const SAMBANOVA_DEFAULT_TEMPERATURE = 0.7 @@ -111,16 +110,18 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature, info } = this.getModel() const languageModel = this.getLanguageModel() + // Convert messages to AI SDK format // For models that don't support multi-part content (like DeepSeek), flatten messages to string content // SambaNova's DeepSeek models expect string content, not array content - const castMessages = messages as ModelMessage[] - const aiSdkMessages = info.supportsImages ? castMessages : flattenAiSdkMessagesToStringContent(castMessages) + const aiSdkMessages = convertToAiSdkMessages(messages, { + transform: info.supportsImages ? undefined : flattenAiSdkMessagesToStringContent, + }) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 2b7a315eec3..7184468343f 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { createGateway, streamText, generateText, ToolSet, ModelMessage } from "ai" +import { createGateway, streamText, generateText, ToolSet } from "ai" import { vercelAiGatewayDefaultModelId, @@ -24,7 +24,6 @@ import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache } from "./fetchers/modelCache" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Vercel AI Gateway provider using the built-in AI SDK gateway support. @@ -109,13 +108,13 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info } = await this.fetchModel() const languageModel = this.getLanguageModel(modelId) - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index f905bcbbf5f..390060ae1ef 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createVertex, type GoogleVertexProvider } from "@ai-sdk/google-vertex" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -27,7 +27,6 @@ import { getModelParams } from "../transform/model-params" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Vertex AI provider using the dedicated @ai-sdk/google-vertex package. @@ -66,7 +65,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl async *createMessage( systemInstruction: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel() @@ -96,7 +95,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl // Anthropic.MessageParam values and will cause failures. type ReasoningMetaLike = { type?: string } - const filteredMessages = messages.filter((message) => { + const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { const meta = message as ReasoningMetaLike if (meta.type === "reasoning") { return false @@ -105,7 +104,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl }) // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) // Convert tools to OpenAI format first, then to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 448da873d39..8fb564a9d59 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -13,7 +13,6 @@ import { convertToVsCodeLmMessages, extractTextCountFromMessage } from "../trans import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Converts OpenAI-format tools to VSCode Language Model tools. @@ -365,7 +364,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { // Ensure clean state before starting a new request @@ -375,13 +374,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Process messages const cleanedMessages = messages.map((msg) => ({ ...msg, - ...("content" in msg ? { content: this.cleanMessageContent((msg as any).content) } : {}), + content: this.cleanMessageContent(msg.content), })) - // Convert messages to VS Code LM messages + // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(systemPrompt), - ...convertToVsCodeLmMessages(cleanedMessages as any), + ...convertToVsCodeLmMessages(cleanedMessages), ] // Initialize cancellation token for the request diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 88b17a9044e..ace457dbfa9 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -19,7 +19,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" const XAI_DEFAULT_TEMPERATURE = 0 @@ -119,14 +118,14 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature, reasoning } = this.getModel() const languageModel = this.getLanguageModel() // Convert messages to AI SDK format - const aiSdkMessages = messages + const aiSdkMessages = convertToAiSdkMessages(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 6c7abb6c162..dd30ecd6d1c 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createZhipu } from "zhipu-ai-provider" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { internationalZAiModels, @@ -27,7 +27,6 @@ import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import type { RooMessage } from "../../core/task-persistence/rooMessage" /** * Z.ai provider using the dedicated zhipu-ai-provider package. @@ -92,13 +91,13 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler */ override async *createMessage( systemPrompt: string, - messages: RooMessage[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info, temperature } = this.getModel() const languageModel = this.getLanguageModel() - const aiSdkMessages = messages as ModelMessage[] + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined diff --git a/src/api/transform/__tests__/image-cleaning.spec.ts b/src/api/transform/__tests__/image-cleaning.spec.ts index f70ebfe3cc6..fc91e0da46e 100644 --- a/src/api/transform/__tests__/image-cleaning.spec.ts +++ b/src/api/transform/__tests__/image-cleaning.spec.ts @@ -3,6 +3,7 @@ import type { ModelInfo } from "@roo-code/types" import { ApiHandler } from "../../index" +import { ApiMessage } from "../../../core/task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../image-cleaning" describe("maybeRemoveImageBlocks", () => { @@ -23,7 +24,7 @@ describe("maybeRemoveImageBlocks", () => { it("should handle empty messages array", () => { const apiHandler = createMockApiHandler(true) - const messages: any[] = [] + const messages: ApiMessage[] = [] const result = maybeRemoveImageBlocks(messages, apiHandler) @@ -33,7 +34,7 @@ describe("maybeRemoveImageBlocks", () => { it("should not modify messages with no image blocks", () => { const apiHandler = createMockApiHandler(true) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello, world!", @@ -52,7 +53,7 @@ describe("maybeRemoveImageBlocks", () => { it("should not modify messages with array content but no image blocks", () => { const apiHandler = createMockApiHandler(true) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ @@ -76,7 +77,7 @@ describe("maybeRemoveImageBlocks", () => { it("should not modify image blocks when API handler supports images", () => { const apiHandler = createMockApiHandler(true) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ @@ -105,7 +106,7 @@ describe("maybeRemoveImageBlocks", () => { it("should convert image blocks to text descriptions when API handler doesn't support images", () => { const apiHandler = createMockApiHandler(false) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ @@ -148,7 +149,7 @@ describe("maybeRemoveImageBlocks", () => { it("should handle mixed content messages with multiple text and image blocks", () => { const apiHandler = createMockApiHandler(false) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ @@ -211,7 +212,7 @@ describe("maybeRemoveImageBlocks", () => { it("should handle multiple messages with image blocks", () => { const apiHandler = createMockApiHandler(false) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ @@ -292,7 +293,7 @@ describe("maybeRemoveImageBlocks", () => { it("should preserve additional message properties", () => { const apiHandler = createMockApiHandler(false) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index dd711d046b7..1a4c7f6518d 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -1,7 +1,7 @@ // npx vitest run api/transform/__tests__/openai-format.spec.ts +import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import type { RooMessage } from "../../../core/task-persistence/rooMessage" import { convertToOpenAiMessages, @@ -13,7 +13,7 @@ import { normalizeMistralToolCallId } from "../mistral-format" describe("convertToOpenAiMessages", () => { it("should convert simple text messages", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello", @@ -37,7 +37,7 @@ describe("convertToOpenAiMessages", () => { }) it("should handle messages with image content", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -77,7 +77,7 @@ describe("convertToOpenAiMessages", () => { }) it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ @@ -113,7 +113,7 @@ describe("convertToOpenAiMessages", () => { }) it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -136,7 +136,7 @@ describe("convertToOpenAiMessages", () => { }) it("should normalize tool call IDs when normalizeToolCallId function is provided", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ @@ -173,7 +173,7 @@ describe("convertToOpenAiMessages", () => { }) it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ @@ -208,7 +208,7 @@ describe("convertToOpenAiMessages", () => { }) it("should use custom normalization function when provided", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ @@ -235,7 +235,7 @@ describe("convertToOpenAiMessages", () => { // 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[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [ @@ -265,7 +265,7 @@ describe("convertToOpenAiMessages", () => { // 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[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -289,7 +289,7 @@ describe("convertToOpenAiMessages", () => { }) it('should use "(empty)" placeholder for tool result with undefined content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -297,7 +297,7 @@ describe("convertToOpenAiMessages", () => { type: "tool_result", tool_use_id: "tool-456", // content is undefined/not provided - }, + } as Anthropic.ToolResultBlockParam, ], }, ] @@ -311,7 +311,7 @@ describe("convertToOpenAiMessages", () => { }) it('should use "(empty)" placeholder for tool result with empty array content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -319,7 +319,7 @@ describe("convertToOpenAiMessages", () => { type: "tool_result", tool_use_id: "tool-789", content: [], // Empty array - }, + } as Anthropic.ToolResultBlockParam, ], }, ] @@ -337,7 +337,7 @@ describe("convertToOpenAiMessages", () => { // 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[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -365,7 +365,7 @@ describe("convertToOpenAiMessages", () => { 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[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -387,7 +387,7 @@ describe("convertToOpenAiMessages", () => { }) it("should preserve image blocks when filtering empty text blocks", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -426,7 +426,7 @@ describe("convertToOpenAiMessages", () => { describe("mergeToolResultText option", () => { it("should merge text content into last tool message when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -456,7 +456,7 @@ describe("convertToOpenAiMessages", () => { }) it("should merge text into last tool message when multiple tool results exist", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -489,7 +489,7 @@ describe("convertToOpenAiMessages", () => { }) it("should NOT merge text when images are present (fall back to user message)", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -519,7 +519,7 @@ describe("convertToOpenAiMessages", () => { }) it("should create separate user message when mergeToolResultText is false", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -548,7 +548,7 @@ describe("convertToOpenAiMessages", () => { }) it("should work with normalizeToolCallId when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -581,7 +581,7 @@ describe("convertToOpenAiMessages", () => { }) it("should handle user messages with only text content (no tool results)", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [ @@ -906,7 +906,7 @@ describe("convertToOpenAiMessages", () => { }) it("should handle messages without reasoning_details", () => { - const anthropicMessages: any[] = [ + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", content: [{ type: "text", text: "Simple response" }], diff --git a/src/api/transform/image-cleaning.ts b/src/api/transform/image-cleaning.ts index 80ef9d04e01..2cdf3abf886 100644 --- a/src/api/transform/image-cleaning.ts +++ b/src/api/transform/image-cleaning.ts @@ -1,32 +1,32 @@ -import { type RooMessage } from "../../core/task-persistence/rooMessage" +import { ApiMessage } from "../../core/task-persistence/apiMessages" import { ApiHandler } from "../index" /* Removes image blocks from messages if they are not supported by the Api Handler */ -export function maybeRemoveImageBlocks(messages: RooMessage[], apiHandler: ApiHandler): RooMessage[] { +export function maybeRemoveImageBlocks(messages: ApiMessage[], apiHandler: ApiHandler): ApiMessage[] { // Check model capability ONCE instead of for every message const supportsImages = apiHandler.getModel().info.supportsImages - if (supportsImages) { - return messages - } - return messages.map((message) => { - // Only process messages with a role and array content - if (!("role" in message) || !Array.isArray(message.content)) { - return message - } - - const content = message.content.map((block: any) => { - if (block.type === "image") { - return { - type: "text" as const, - text: "[Referenced image in conversation]", - } + // Handle array content (could contain image blocks). + let { content } = message + if (Array.isArray(content)) { + if (!supportsImages) { + // Convert image blocks to text descriptions. + content = content.map((block) => { + if (block.type === "image") { + // Convert image blocks to text descriptions. + // Note: We can't access the actual image content/url due to API limitations, + // but we can indicate that an image was present in the conversation. + return { + type: "text", + text: "[Referenced image in conversation]", + } + } + return block + }) } - return block - }) - - return { ...message, content } as typeof message + } + return { ...message, content } }) } diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 43fe6dba144..8974dd599ba 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -1,18 +1,5 @@ +import { Anthropic } from "@anthropic-ai/sdk" 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. @@ -158,12 +145,6 @@ export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]) 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. @@ -273,17 +254,17 @@ export function sanitizeGeminiMessages( } /** - * Options for converting messages to OpenAI format. + * Options for converting Anthropic 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. + * When provided, this function will be applied to all tool_use IDs and tool_result tool_use_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 + * 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 @@ -292,13 +273,8 @@ export interface ConvertToOpenAiMessagesOptions { 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[], + anthropicMessages: Anthropic.Messages.MessageParam[], options?: ConvertToOpenAiMessagesOptions, ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] @@ -324,222 +300,208 @@ export function convertToOpenAiMessages( // 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: { type: "image", image: base64, mediaType: mimeType } - if (part.image && part.mediaType) { - return `data:${part.mediaType};base64,${part.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 + for (const anthropicMessage of anthropicMessages) { + if (typeof anthropicMessage.content === "string") { + // Some upstream transforms (e.g. [`Task.buildCleanConversationHistory()`](src/core/task/Task.ts:4048)) + // will convert a single text block into a string for compactness. + // If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.), + // we must preserve it here as well. + const messageWithDetails = anthropicMessage as any const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { - role: message.role as "user" | "assistant", - content: message.content, + role: anthropicMessage.role, + content: anthropicMessage.content, } - if (message.role === "assistant") { + if (anthropicMessage.role === "assistant") { const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) if (mapped) { - baseMessage.reasoning_details = mapped + ;(baseMessage as any).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 { + // image_url.url is base64 encoded image data + // ensure it contains the content-type of the image: data:image/png;base64, + /* + { role: "user", content: "" | { type: "text", text: string } | { type: "image_url", image_url: { url: string } } }, + // content required unless tool_calls is present + { role: "assistant", content?: "" | null, tool_calls?: [{ id: "", function: { name: "", arguments: "" }, type: "function" }] }, + { role: "tool", tool_call_id: "", content: ""} + */ + 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: [] }, + ) + + // Process tool result messages FIRST since they must follow the tool use messages + let toolResultImages: Anthropic.Messages.ImageBlockParam[] = [] + toolMessages.forEach((toolMessage) => { + // The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility. + let content: string + + if (typeof toolMessage.content === "string") { + content = toolMessage.content + } else { + content = + toolMessage.content + ?.map((part) => { + if (part.type === "image") { + toolResultImages.push(part) + return "(see following user message for image)" + } + return part.text + }) + .join("\n") ?? "" } - } - } - } 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 }) - } - } + openAiMessages.push({ + role: "tool", + tool_call_id: normalizeId(toolMessage.tool_use_id), + // Use "(empty)" placeholder for empty content to satisfy providers like Gemini (via OpenRouter) + content: content || "(empty)", + }) + }) - // 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 tool results contain images, send as a separate user message + // I ran into an issue where if I gave feedback for one of many tool uses, the request would fail. + // "Messages following `tool_use` blocks must begin with a matching number of `tool_result` blocks." + // Therefore we need to send these images after the tool result messages + // NOTE: it's actually okay to have multiple user messages in a row, the model will treat them as a continuation of the same input (this way works better than combining them into one message, since the tool result specifically mentions (see following user message for image) + // UPDATE v2.0: we don't use tools anymore, but if we did it's important to note that the openrouter prompt caching mechanism requires one user message at a time, so we would need to add these images to the user content array instead. + // if (toolResultImages.length > 0) { + // openAiMessages.push({ + // role: "user", + // content: toolResultImages.map((part) => ({ + // type: "image_url", + // image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + // })), + // }) + // } + + // Process non-tool messages + // Filter out empty text blocks to prevent "must include at least one parts field" error + // from Gemini (via OpenRouter). Images always have content (base64 data). + const filteredNonToolMessages = nonToolMessages.filter( + (part) => part.type === "image" || (part.type === "text" && part.text), + ) + + if (filteredNonToolMessages.length > 0) { + // Check if we should merge text into the last tool message + // This is critical for reasoning/thinking models where a user message + // after tool results causes the model to drop all previous reasoning_content + const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text") + const hasToolMessages = toolMessages.length > 0 + const shouldMergeIntoToolMessage = + options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent + + if (shouldMergeIntoToolMessage) { + // Merge text content into the last tool message + const lastToolMessage = openAiMessages[ + openAiMessages.length - 1 + ] as OpenAI.Chat.ChatCompletionToolMessageParam + if (lastToolMessage?.role === "tool") { + const additionalText = filteredNonToolMessages + .map((part) => (part as Anthropic.TextBlockParam).text) + .join("\n") + lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` + } + } else { + // Standard behavior: add user message with text/image content + openAiMessages.push({ + role: "user", + content: filteredNonToolMessages.map((part) => { if (part.type === "image") { - return "(see following user message for image)" + return { + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + } } - 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}` + return { type: "text", text: part.text } + }), + }) } - } else { - openAiMessages.push({ - role: "user", - content: filteredNonToolMessages.map((part) => { + } + } 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: [] }, + ) + + // Process non-tool messages + let content: string | undefined + if (nonToolMessages.length > 0) { + content = nonToolMessages + .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 "" // impossible as the assistant cannot send images } - 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 }) + return part.text + }) + .join("\n") } - } - // 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 use messages + let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({ + id: normalizeId(toolMessage.id), + type: "function", + function: { + name: toolMessage.name, + // json string + arguments: JSON.stringify(toolMessage.input), + }, + })) + + // Check if the message has reasoning_details (used by Gemini 3, xAI, etc.) + const messageWithDetails = anthropicMessage as any + + // Build message with reasoning_details BEFORE tool_calls to preserve + // the order expected by providers like Roo. Property order matters + // when sending messages back to some APIs. + const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_details?: any[] + } = { + role: "assistant", + // Use empty string instead of undefined for providers like Gemini (via OpenRouter) + // that require every message to have content in the "parts" field + content: content ?? "", + } - // 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 ?? "", - } + // Pass through reasoning_details to preserve the original shape from the API. + // The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails). + const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) + if (mapped) { + baseMessage.reasoning_details = mapped + } - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - baseMessage.reasoning_details = mapped - } + // Add tool_calls after reasoning_details + // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty + if (tool_calls.length > 0) { + baseMessage.tool_calls = tool_calls + } - if (tool_calls.length > 0) { - baseMessage.tool_calls = tool_calls + openAiMessages.push(baseMessage) } - - openAiMessages.push(baseMessage) } } diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index a12ea7a12c3..7316884984f 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -38,7 +38,6 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = currentStreamingContentIndex: 0, assistantMessageContent: [], userMessageContent: [], - pendingToolResults: [], didCompleteReadingStream: false, didRejectTool: false, didAlreadyUseTool: false, @@ -67,13 +66,13 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { - const existingResult = mockTask.pendingToolResults.find( - (block: any) => block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, ) if (existingResult) { return false } - mockTask.pendingToolResults.push(toolResult) + mockTask.userMessageContent.push(toolResult) return true }) }) @@ -110,25 +109,25 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = // Execute presentAssistantMessage await presentAssistantMessage(mockTask) - // Verify that pendingToolResults was populated - expect(mockTask.pendingToolResults.length).toBeGreaterThan(0) + // Verify that userMessageContent was populated + expect(mockTask.userMessageContent.length).toBeGreaterThan(0) - // Find the tool-result block in pendingToolResults - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + // Find the tool_result block + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) expect(toolResult).toBeDefined() - expect(toolResult.toolCallId).toBe(toolCallId) + expect(toolResult.tool_use_id).toBe(toolCallId) - // For native tool calling, output should be a text value - expect(toolResult.output).toBeDefined() - expect(toolResult.output.value).toContain("I see a cat") + // For native tool calling, tool_result content should be a string (text only) + expect(typeof toolResult.content).toBe("string") + expect(toolResult.content).toContain("I see a cat") - // Images should be added as separate ImagePart blocks in userMessageContent + // Images should be added as separate blocks AFTER the tool_result const imageBlocks = mockTask.userMessageContent.filter((item: any) => item.type === "image") expect(imageBlocks.length).toBeGreaterThan(0) - expect(imageBlocks[0].image).toBe("base64ImageData") + expect(imageBlocks[0].source.data).toBe("base64ImageData") }) it("should convert to string when no images are present (native tool calling)", async () => { @@ -153,15 +152,14 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = await presentAssistantMessage(mockTask) - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) expect(toolResult).toBeDefined() - // When no images, output should be a text value - expect(toolResult.output.type).toBe("text") - expect(typeof toolResult.output.value).toBe("string") + // When no images, content should be a string + expect(typeof toolResult.content).toBe("string") }) it("should fail fast when tool_use is missing id (legacy/XML-style tool call)", async () => { @@ -211,13 +209,13 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = await presentAssistantMessage(mockTask) - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) expect(toolResult).toBeDefined() // Should have fallback text - expect(toolResult.output).toBeTruthy() + expect(toolResult.content).toBeTruthy() }) describe("Multiple tool calls handling", () => { @@ -248,20 +246,20 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = mockTask.currentStreamingContentIndex = 1 await presentAssistantMessage(mockTask) - // Find the tool-result for the second tool in pendingToolResults - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId2, + // Find the tool_result for the second tool + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId2, ) - // Verify that a tool-result block was created (not a text block) + // Verify that a tool_result block was created (not a text block) expect(toolResult).toBeDefined() - expect(toolResult.toolCallId).toBe(toolCallId2) - expect(toolResult.output.value).toContain("[ERROR]") - expect(toolResult.output.value).toContain("due to user rejecting a previous tool") + expect(toolResult.tool_use_id).toBe(toolCallId2) + expect(toolResult.is_error).toBe(true) + expect(toolResult.content).toContain("due to user rejecting a previous tool") // Ensure no text blocks were added for this rejection const textBlocks = mockTask.userMessageContent.filter( - (item: any) => item.type === "text" && item.text?.includes("due to user rejecting"), + (item: any) => item.type === "text" && item.text.includes("due to user rejecting"), ) expect(textBlocks.length).toBe(0) }) @@ -312,15 +310,15 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = await presentAssistantMessage(mockTask) - // Find the tool-result in pendingToolResults - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + // Find the tool_result + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) - // Verify tool-result was created for partial block + // Verify tool_result was created for partial block expect(toolResult).toBeDefined() - expect(toolResult.output.value).toContain("[ERROR]") - expect(toolResult.output.value).toContain("was interrupted and not executed") + expect(toolResult.is_error).toBe(true) + expect(toolResult.content).toContain("was interrupted and not executed") }) }) }) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 29b4133f819..15a1e2d8672 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -32,7 +32,6 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { currentStreamingContentIndex: 0, assistantMessageContent: [], userMessageContent: [], - pendingToolResults: [], didCompleteReadingStream: false, didRejectTool: false, didAlreadyUseTool: false, @@ -63,13 +62,13 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { - const existingResult = mockTask.pendingToolResults.find( - (block: any) => block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, ) if (existingResult) { return false } - mockTask.pendingToolResults.push(toolResult) + mockTask.userMessageContent.push(toolResult) return true }) }) @@ -90,17 +89,17 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Execute presentAssistantMessage await presentAssistantMessage(mockTask) - // Verify that a tool-result with error was pushed to pendingToolResults - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + // Verify that a tool_result with error was pushed + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) expect(toolResult).toBeDefined() - expect(toolResult.toolCallId).toBe(toolCallId) - // The error is wrapped in output.value by formatResponse.toolError - expect(toolResult.output.value).toContain("nonexistent_tool") - expect(toolResult.output.value).toContain("does not exist") - expect(toolResult.output.value).toContain("error") + expect(toolResult.tool_use_id).toBe(toolCallId) + // The error is wrapped in JSON by formatResponse.toolError + expect(toolResult.content).toContain("nonexistent_tool") + expect(toolResult.content).toContain("does not exist") + expect(toolResult.content).toContain("error") // Verify consecutiveMistakeCount was incremented expect(mockTask.consecutiveMistakeCount).toBe(1) @@ -170,9 +169,9 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { const completed = await Promise.race([resultPromise, timeoutPromise]) expect(completed).toBe(true) - // Verify a tool-result was pushed (critical for API not to freeze) - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + // Verify a tool_result was pushed (critical for API not to freeze) + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) expect(toolResult).toBeDefined() }) @@ -234,13 +233,13 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { await presentAssistantMessage(mockTask) - // When didRejectTool is true, should send error tool-result - const toolResult = mockTask.pendingToolResults.find( - (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, + // When didRejectTool is true, should send error tool_result + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, ) expect(toolResult).toBeDefined() - expect(toolResult.output.value).toContain("[ERROR]") - expect(toolResult.output.value).toContain("due to user rejecting a previous tool") + expect(toolResult.is_error).toBe(true) + expect(toolResult.content).toContain("due to user rejecting a previous tool") }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 56f6288b238..ccb29aaa2ed 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -1,7 +1,6 @@ import { serializeError } from "serialize-error" import { Anthropic } from "@anthropic-ai/sdk" -import type { ImagePart, ToolResultPart } from "../task-persistence" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { ConsecutiveMistakeError, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -119,10 +118,10 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: mcpBlock.name, - output: { type: "text", value: `[ERROR] ${errorMessage}` }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: errorMessage, + is_error: true, }) } break @@ -144,13 +143,13 @@ export async function presentAssistantMessage(cline: Task) { } let resultContent: string - let imageBlocks: ImagePart[] = [] + let imageBlocks: Anthropic.ImageBlockParam[] = [] if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { const textBlocks = content.filter((item) => item.type === "text") - imageBlocks = content.filter((item) => item.type === "image") as ImagePart[] + imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] resultContent = textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || "(tool did not return anything)" @@ -170,10 +169,9 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: mcpBlock.name, - output: { type: "text", value: resultContent }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: resultContent, }) if (imageBlocks.length > 0) { @@ -401,10 +399,10 @@ export async function presentAssistantMessage(cline: Task) { : `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.` cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: block.name, - output: { type: "text", value: `[ERROR] ${errorMessage}` }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: errorMessage, + is_error: true, }) break @@ -438,10 +436,10 @@ export async function presentAssistantMessage(cline: Task) { // Push tool_result directly without setting didAlreadyUseTool so streaming can // continue gracefully. cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: block.name, - output: { type: "text", value: `[ERROR] ${formatResponse.toolError(errorMessage)}` }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: formatResponse.toolError(errorMessage), + is_error: true, }) break @@ -461,13 +459,13 @@ export async function presentAssistantMessage(cline: Task) { } let resultContent: string - let imageBlocks: ImagePart[] = [] + let imageBlocks: Anthropic.ImageBlockParam[] = [] if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { const textBlocks = content.filter((item) => item.type === "text") - imageBlocks = content.filter((item) => item.type === "image") as ImagePart[] + imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] resultContent = textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || "(tool did not return anything)" @@ -484,10 +482,9 @@ export async function presentAssistantMessage(cline: Task) { } cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: block.name, - output: { type: "text", value: resultContent }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: resultContent, }) if (imageBlocks.length > 0) { @@ -647,13 +644,10 @@ export async function presentAssistantMessage(cline: Task) { const errorContent = formatResponse.toolError(error.message) // Push tool_result directly without setting didAlreadyUseTool cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: block.name, - output: { - type: "text", - value: `[ERROR] ${typeof errorContent === "string" ? errorContent : "(validation error)"}`, - }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: typeof errorContent === "string" ? errorContent : "(validation error)", + is_error: true, }) break @@ -954,10 +948,10 @@ export async function presentAssistantMessage(cline: Task) { // Push tool_result directly WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" cline.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName: block.name, - output: { type: "text", value: `[ERROR] ${formatResponse.toolError(errorMessage)}` }, + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: formatResponse.toolError(errorMessage), + is_error: true, }) break } diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index e441637cf20..c209fa97243 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -1,9 +1,11 @@ // npx vitest src/core/condense/__tests__/condense.spec.ts +import { Anthropic } from "@anthropic-ai/sdk" import type { ModelInfo } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { BaseProvider } from "../../../api/providers/base-provider" +import { ApiMessage } from "../../task-persistence/apiMessages" import { summarizeConversation, getMessagesSinceLastSummary, @@ -39,7 +41,7 @@ class MockApiHandler extends BaseProvider { } } - override async countTokens(content: Array): Promise { + override async countTokens(content: Array): Promise { // Simple token counting for testing let tokens = 0 for (const block of content) { @@ -63,7 +65,7 @@ describe("Condense", () => { describe("extractCommandBlocks", () => { it("should extract command blocks from string content", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: 'Some text /prr #123 more text', } @@ -73,7 +75,7 @@ describe("Condense", () => { }) it("should extract multiple command blocks", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: '/prr #123 text /mode code', } @@ -83,7 +85,7 @@ describe("Condense", () => { }) it("should extract command blocks from array content", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: [ { type: "text", text: "Some user text" }, @@ -96,7 +98,7 @@ describe("Condense", () => { }) it("should return empty string when no command blocks found", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: "Just regular text without commands", } @@ -106,7 +108,7 @@ describe("Condense", () => { }) it("should handle multiline command blocks", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: ` Line 1 @@ -122,7 +124,7 @@ Line 2 describe("summarizeConversation", () => { it("should create a summary message with role user (fresh start model)", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message with /prr command content" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -145,22 +147,22 @@ Line 2 // Verify we have a summary message with role "user" (fresh start model) const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - expect((summaryMessage as any).role).toBe("user") - expect(Array.isArray((summaryMessage as any).content)).toBe(true) - const contentArray = (summaryMessage as any).content as any[] + expect(summaryMessage!.role).toBe("user") + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const contentArray = summaryMessage!.content as any[] expect(contentArray.some((b) => b.type === "text")).toBe(true) // Should NOT have reasoning blocks (no longer needed for user messages) expect(contentArray.some((b) => b.type === "reasoning")).toBe(false) // Fresh start model: effective history should only contain the summary - const effectiveHistory = getEffectiveApiHistory(result.messages as any) + const effectiveHistory = getEffectiveApiHistory(result.messages) expect(effectiveHistory.length).toBe(1) expect(effectiveHistory[0].isSummary).toBe(true) - expect((effectiveHistory[0] as any).role).toBe("user") + expect(effectiveHistory[0].role).toBe("user") }) it("should tag ALL messages with condenseParent", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message with /prr command content" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -185,7 +187,7 @@ Line 2 }) it("should preserve blocks in the summary", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: [ @@ -214,7 +216,7 @@ Line 2 const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - const contentArray = (summaryMessage as any).content as any[] + const contentArray = summaryMessage!.content as any[] // Summary content is split into separate text blocks: // - First block: "## Conversation Summary\n..." // - Second block: "..." with command blocks @@ -226,12 +228,12 @@ Line 2 }) it("should handle complex first message content", async () => { - const complexContent: any[] = [ + const complexContent: Anthropic.Messages.ContentBlockParam[] = [ { type: "text", text: "/mode code" }, { type: "text", text: "Additional context from the user" }, ] - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: complexContent }, { role: "assistant", content: "Switching to code mode" }, { role: "user", content: "Write a function" }, @@ -252,14 +254,14 @@ Line 2 }) // Effective history should contain only the summary (fresh start) - const effectiveHistory = getEffectiveApiHistory(result.messages as any) + const effectiveHistory = getEffectiveApiHistory(result.messages) expect(effectiveHistory).toHaveLength(1) expect(effectiveHistory[0].isSummary).toBe(true) - expect((effectiveHistory[0] as any).role).toBe("user") + expect(effectiveHistory[0].role).toBe("user") }) it("should return error when not enough messages to summarize", async () => { - const messages: any[] = [{ role: "user", content: "Only one message" }] + const messages: ApiMessage[] = [{ role: "user", content: "Only one message" }] const result = await summarizeConversation({ messages, @@ -276,7 +278,7 @@ Line 2 }) it("should not summarize messages that already contain a recent summary with no new messages", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message with /command" }, { role: "user", content: "Previous summary", isSummary: true }, ] @@ -310,7 +312,7 @@ Line 2 } const emptyHandler = new EmptyMockApiHandler() - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second" }, { role: "user", content: "Third" }, @@ -337,7 +339,7 @@ Line 2 describe("getEffectiveApiHistory", () => { it("should return only summary when summary exists (fresh start)", () => { const condenseId = "test-condense-id" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: condenseId }, { role: "assistant", content: "Second", condenseParent: condenseId }, { role: "user", content: "Third", condenseParent: condenseId }, @@ -357,7 +359,7 @@ Line 2 it("should include messages after summary in fresh start model", () => { const condenseId = "test-condense-id" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: condenseId }, { role: "assistant", content: "Second", condenseParent: condenseId }, { @@ -374,12 +376,12 @@ Line 2 expect(result).toHaveLength(3) expect(result[0].isSummary).toBe(true) - expect((result[1] as any).content).toBe("New response after summary") - expect((result[2] as any).content).toBe("New user message") + expect(result[1].content).toBe("New response after summary") + expect(result[2].content).toBe("New user message") }) it("should return all messages when no summary exists", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First" }, { role: "assistant", content: "Second" }, { role: "user", content: "Third" }, @@ -395,7 +397,7 @@ Line 2 // The cleanupAfterTruncation function would normally clear these, // but even without cleanup, getEffectiveApiHistory should handle orphaned tags const orphanedCondenseId = "deleted-summary-id" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: orphanedCondenseId }, { role: "assistant", content: "Second", condenseParent: orphanedCondenseId }, { role: "user", content: "Third", condenseParent: orphanedCondenseId }, @@ -411,7 +413,7 @@ Line 2 describe("getMessagesSinceLastSummary", () => { it("should return all messages when no summary exists", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -422,7 +424,7 @@ Line 2 }) it("should return messages since last summary including the summary", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Summary content", isSummary: true }, @@ -438,7 +440,7 @@ Line 2 }) it("should handle multiple summaries and return from the last one", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "user", content: "First summary", isSummary: true }, { role: "assistant", content: "Middle message" }, diff --git a/src/core/condense/__tests__/foldedFileContext.spec.ts b/src/core/condense/__tests__/foldedFileContext.spec.ts index f4a3e09c1d9..3bd9b390f5a 100644 --- a/src/core/condense/__tests__/foldedFileContext.spec.ts +++ b/src/core/condense/__tests__/foldedFileContext.spec.ts @@ -323,7 +323,7 @@ describe("foldedFileContext", () => { expect(summaryMessage).toBeDefined() // Each file should have its own content block - const contentArray = (summaryMessage as any).content as any[] + const contentArray = summaryMessage!.content as any[] // Find the content blocks containing file contexts const userFileBlock = contentArray.find( @@ -381,7 +381,7 @@ describe("foldedFileContext", () => { expect(summaryMessage).toBeDefined() // The summary content should NOT contain any file context blocks - const contentArray = (summaryMessage as any).content as any[] + const contentArray = summaryMessage!.content as any[] const fileContextBlock = contentArray.find( (block: any) => block.type === "text" && block.text?.includes("## File Context"), ) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index ea2411adaf9..10092f71dc7 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -2,10 +2,11 @@ import type { Mock } from "vitest" +import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" import { ApiHandler } from "../../../api" -import { RooMessage } from "../../task-persistence/rooMessage" +import { ApiMessage } from "../../task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../../../api/transform/image-cleaning" import { summarizeConversation, @@ -21,7 +22,7 @@ import { } from "../index" vi.mock("../../../api/transform/image-cleaning", () => ({ - maybeRemoveImageBlocks: vi.fn((messages: RooMessage[], _apiHandler: ApiHandler) => [...messages]), + maybeRemoveImageBlocks: vi.fn((messages: ApiMessage[], _apiHandler: ApiHandler) => [...messages]), })) vi.mock("@roo-code/telemetry", () => ({ @@ -36,7 +37,7 @@ const taskId = "test-task-id" describe("extractCommandBlocks", () => { it("should extract command blocks from string content", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: 'Some text /prr #123 more text', } @@ -46,7 +47,7 @@ describe("extractCommandBlocks", () => { }) it("should extract multiple command blocks", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: '/prr #123 text /mode code', } @@ -56,7 +57,7 @@ describe("extractCommandBlocks", () => { }) it("should extract command blocks from array content", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: [ { type: "text", text: "Some user text" }, @@ -69,7 +70,7 @@ describe("extractCommandBlocks", () => { }) it("should return empty string when no command blocks found", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: "Just regular text without commands", } @@ -79,7 +80,7 @@ describe("extractCommandBlocks", () => { }) it("should handle multiline command blocks", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: ` Line 1 @@ -93,7 +94,7 @@ Line 2 }) it("should handle command blocks with attributes", () => { - const message: any = { + const message: ApiMessage = { role: "user", content: 'content', } @@ -106,7 +107,7 @@ Line 2 describe("injectSyntheticToolResults", () => { it("should return messages unchanged when no orphan tool_calls exist", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", @@ -125,7 +126,7 @@ describe("injectSyntheticToolResults", () => { }) it("should inject synthetic tool_result for orphan tool_call", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", @@ -140,17 +141,17 @@ describe("injectSyntheticToolResults", () => { const result = injectSyntheticToolResults(messages) expect(result.length).toBe(3) - expect((result[2] as any).role).toBe("tool") + expect(result[2].role).toBe("user") - const content = (result[2] as any).content as any[] + const content = result[2].content as any[] expect(content.length).toBe(1) - expect(content[0].type).toBe("tool-result") - expect(content[0].toolCallId).toBe("tool-orphan") - expect(content[0].output.value).toBe("Context condensation triggered. Tool execution deferred.") + expect(content[0].type).toBe("tool_result") + expect(content[0].tool_use_id).toBe("tool-orphan") + expect(content[0].content).toBe("Context condensation triggered. Tool execution deferred.") }) it("should inject synthetic tool_results for multiple orphan tool_calls", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", @@ -166,14 +167,14 @@ describe("injectSyntheticToolResults", () => { const result = injectSyntheticToolResults(messages) expect(result.length).toBe(3) - const content = (result[2] as any).content as any[] + const content = result[2].content as any[] expect(content.length).toBe(2) - expect(content[0].toolCallId).toBe("tool-1") - expect(content[1].toolCallId).toBe("tool-2") + expect(content[0].tool_use_id).toBe("tool-1") + expect(content[1].tool_use_id).toBe("tool-2") }) it("should only inject for orphan tool_calls, not matched ones", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", @@ -194,13 +195,13 @@ describe("injectSyntheticToolResults", () => { const result = injectSyntheticToolResults(messages) expect(result.length).toBe(4) - const syntheticContent = (result[3] as any).content as any[] + const syntheticContent = result[3].content as any[] expect(syntheticContent.length).toBe(1) - expect(syntheticContent[0].toolCallId).toBe("orphan-tool") + expect(syntheticContent[0].tool_use_id).toBe("orphan-tool") }) it("should handle messages with string content (no tool_use/tool_result)", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there!", ts: 2 }, ] @@ -215,7 +216,7 @@ describe("injectSyntheticToolResults", () => { }) it("should handle tool_results spread across multiple user messages", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", @@ -245,7 +246,7 @@ describe("injectSyntheticToolResults", () => { describe("getMessagesSinceLastSummary", () => { it("should return all messages when there is no summary", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -256,7 +257,7 @@ describe("getMessagesSinceLastSummary", () => { }) it("should return messages since the last summary", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "Summary of conversation", ts: 3, isSummary: true }, @@ -273,7 +274,7 @@ describe("getMessagesSinceLastSummary", () => { }) it("should handle multiple summary messages and return since the last one", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "user", content: "First summary", ts: 2, isSummary: true }, { role: "assistant", content: "How are you?", ts: 3 }, @@ -294,7 +295,7 @@ describe("getMessagesSinceLastSummary", () => { }) it("should return messages from user summary (fresh start model)", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1, condenseParent: "cond-1" }, { role: "assistant", content: "Hi there", ts: 2, condenseParent: "cond-1" }, { role: "user", content: "Summary content", ts: 3, isSummary: true, condenseId: "cond-1" }, @@ -303,14 +304,14 @@ describe("getMessagesSinceLastSummary", () => { const result = getMessagesSinceLastSummary(messages) expect(result[0].isSummary).toBe(true) - expect((result[0] as any).role).toBe("user") + expect(result[0].role).toBe("user") }) }) describe("getEffectiveApiHistory", () => { it("should return only summary when summary exists (fresh start model)", () => { const condenseId = "test-condense-id" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: condenseId }, { role: "assistant", content: "Second", condenseParent: condenseId }, { role: "user", content: "Third", condenseParent: condenseId }, @@ -330,7 +331,7 @@ describe("getEffectiveApiHistory", () => { it("should include messages after summary in fresh start model", () => { const condenseId = "test-condense-id" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: condenseId }, { role: "assistant", content: "Second", condenseParent: condenseId }, { @@ -347,12 +348,12 @@ describe("getEffectiveApiHistory", () => { expect(result).toHaveLength(3) expect(result[0].isSummary).toBe(true) - expect((result[1] as any).content).toBe("New response after summary") - expect((result[2] as any).content).toBe("New user message") + expect(result[1].content).toBe("New response after summary") + expect(result[2].content).toBe("New user message") }) it("should return all messages when no summary exists", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First" }, { role: "assistant", content: "Second" }, { role: "user", content: "Third" }, @@ -365,7 +366,7 @@ describe("getEffectiveApiHistory", () => { it("should restore messages when summary is deleted (rewind - orphaned condenseParent)", () => { const orphanedCondenseId = "deleted-summary-id" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: orphanedCondenseId }, { role: "assistant", content: "Second", condenseParent: orphanedCondenseId }, { role: "user", content: "Third", condenseParent: orphanedCondenseId }, @@ -381,7 +382,7 @@ describe("getEffectiveApiHistory", () => { it("should filter out truncated messages within summary range", () => { const condenseId = "cond-1" const truncationId = "trunc-1" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: condenseId }, { role: "user", @@ -405,12 +406,12 @@ describe("getEffectiveApiHistory", () => { expect(result).toHaveLength(3) expect(result[0].isSummary).toBe(true) expect(result[1].isTruncationMarker).toBe(true) - expect((result[2] as any).content).toBe("After truncation") + expect(result[2].content).toBe("After truncation") }) it("should filter out orphan tool_result blocks after fresh start condensation", () => { const condenseId = "cond-1" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", condenseParent: condenseId }, { role: "assistant", @@ -442,7 +443,7 @@ describe("getEffectiveApiHistory", () => { it("should keep tool_result blocks that have matching tool_use in fresh start", () => { const condenseId = "cond-1" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", condenseParent: condenseId }, { role: "user", @@ -450,14 +451,12 @@ describe("getEffectiveApiHistory", () => { isSummary: true, condenseId, }, - // This tool-call is AFTER the summary, so it's not condensed away + // This tool_use is AFTER the summary, so it's not condensed away { role: "assistant", - content: [ - { type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: { path: "test.ts" } }, - ], + content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }], }, - // This tool_result has a matching tool-call, so it should be kept (legacy user message format) + // This tool_result has a matching tool_use, so it should be kept { role: "user", content: [{ type: "tool_result", tool_use_id: "tool-valid", content: "file contents" }], @@ -469,23 +468,18 @@ describe("getEffectiveApiHistory", () => { // All messages after summary should be included expect(result).toHaveLength(3) expect(result[0].isSummary).toBe(true) - expect(((result[1] as any).content as any[])[0].toolCallId).toBe("tool-valid") - expect(((result[2] as any).content as any[])[0].tool_use_id).toBe("tool-valid") + expect((result[1].content as any[])[0].id).toBe("tool-valid") + expect((result[2].content as any[])[0].tool_use_id).toBe("tool-valid") }) it("should filter orphan tool_results but keep other content in mixed user message", () => { const condenseId = "cond-1" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", condenseParent: condenseId }, { role: "assistant", content: [ - { - type: "tool-call", - toolCallId: "tool-orphan", - toolName: "attempt_completion", - input: { result: "Done" }, - }, + { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, ], condenseParent: condenseId, }, @@ -495,14 +489,12 @@ describe("getEffectiveApiHistory", () => { isSummary: true, condenseId, }, - // This tool-call is AFTER the summary + // This tool_use is AFTER the summary { role: "assistant", - content: [ - { type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: { path: "test.ts" } }, - ], + content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }], }, - // Mixed content: one orphan tool_result and one valid tool_result (legacy user message format) + // Mixed content: one orphan tool_result and one valid tool_result { role: "user", content: [ @@ -514,18 +506,18 @@ describe("getEffectiveApiHistory", () => { const result = getEffectiveApiHistory(messages) - // Summary + assistant with tool-call + filtered user message + // Summary + assistant with tool_use + filtered user message expect(result).toHaveLength(3) expect(result[0].isSummary).toBe(true) // The user message should only contain the valid tool_result - const userContent = (result[2] as any).content as any[] + const userContent = result[2].content as any[] expect(userContent).toHaveLength(1) expect(userContent[0].tool_use_id).toBe("tool-valid") }) it("should handle multiple orphan tool_results in a single message", () => { const condenseId = "cond-1" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "assistant", content: [ @@ -559,7 +551,7 @@ describe("getEffectiveApiHistory", () => { it("should preserve non-tool_result content in user messages", () => { const condenseId = "cond-1" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "assistant", content: [ @@ -588,7 +580,7 @@ describe("getEffectiveApiHistory", () => { // Summary + user message with only text (orphan tool_result filtered) expect(result).toHaveLength(2) expect(result[0].isSummary).toBe(true) - const userContent = (result[1] as any).content as any[] + const userContent = result[1].content as any[] expect(userContent).toHaveLength(1) expect(userContent[0].type).toBe("text") expect(userContent[0].text).toBe("User added some text") @@ -598,7 +590,7 @@ describe("getEffectiveApiHistory", () => { describe("cleanupAfterTruncation", () => { it("should clear orphaned condenseParent references", () => { const orphanedCondenseId = "deleted-summary" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: orphanedCondenseId }, { role: "assistant", content: "Second", condenseParent: orphanedCondenseId }, { role: "user", content: "Third" }, @@ -613,7 +605,7 @@ describe("cleanupAfterTruncation", () => { it("should keep condenseParent when summary still exists", () => { const condenseId = "existing-summary" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: condenseId }, { role: "assistant", content: "Second", condenseParent: condenseId }, { @@ -632,7 +624,7 @@ describe("cleanupAfterTruncation", () => { it("should clear orphaned truncationParent references", () => { const orphanedTruncationId = "deleted-truncation" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", truncationParent: orphanedTruncationId }, { role: "assistant", content: "Second" }, ] @@ -644,7 +636,7 @@ describe("cleanupAfterTruncation", () => { it("should keep truncationParent when marker still exists", () => { const truncationId = "existing-truncation" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", truncationParent: truncationId }, { role: "assistant", @@ -662,7 +654,7 @@ describe("cleanupAfterTruncation", () => { it("should handle mixed orphaned and valid references", () => { const validCondenseId = "valid-cond" const orphanedCondenseId = "orphaned-cond" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First", condenseParent: orphanedCondenseId }, { role: "assistant", content: "Second", condenseParent: validCondenseId }, { @@ -720,7 +712,7 @@ describe("summarizeConversation", () => { const defaultSystemPrompt = "You are a helpful assistant." it("should not summarize when there are not enough messages", async () => { - const messages: any[] = [{ role: "user", content: "Hello", ts: 1 }] + const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }] const result = await summarizeConversation({ messages, @@ -737,7 +729,7 @@ describe("summarizeConversation", () => { }) it("should create summary with user role (fresh start model)", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -771,19 +763,19 @@ describe("summarizeConversation", () => { } // Summary message is a user message with just text (fresh start model) - expect((summaryMessage! as any).role).toBe("user") - expect(Array.isArray((summaryMessage as any).content)).toBe(true) - const content = (summaryMessage as any).content as any[] + expect(summaryMessage!.role).toBe("user") + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const content = summaryMessage!.content as any[] expect(content).toHaveLength(1) expect(content[0].type).toBe("text") expect(content[0].text).toContain("## Conversation Summary") expect(content[0].text).toContain("This is a summary") // Fresh start: effective API history should contain only the summary - const effectiveHistory = getEffectiveApiHistory(result.messages as any) + const effectiveHistory = getEffectiveApiHistory(result.messages) expect(effectiveHistory).toHaveLength(1) expect(effectiveHistory[0].isSummary).toBe(true) - expect((effectiveHistory[0] as any).role).toBe("user") + expect(effectiveHistory[0].role).toBe("user") // Check the cost and token counts expect(result.cost).toBe(0.05) @@ -794,7 +786,7 @@ describe("summarizeConversation", () => { }) it("should preserve command blocks from first message in summary", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: 'Hello /prr #123', @@ -816,7 +808,7 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - const content = (summaryMessage as any).content as any[] + const content = summaryMessage!.content as any[] // Summary content is now split into separate text blocks expect(content).toHaveLength(2) expect(content[0].text).toContain("## Conversation Summary") @@ -826,7 +818,7 @@ describe("summarizeConversation", () => { }) it("should not include command blocks wrapper when no commands in first message", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -844,14 +836,14 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() - const content = (summaryMessage as any).content as any[] + const content = summaryMessage!.content as any[] expect(content[0].text).not.toContain("") expect(content[0].text).not.toContain("Active Workflows") }) it("should handle empty summary response and return error", async () => { // We need enough messages to trigger summarization - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -892,7 +884,7 @@ describe("summarizeConversation", () => { }) it("should correctly format the request to the API", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -929,7 +921,7 @@ describe("summarizeConversation", () => { }) it("should include the original first user message in summarization input", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Initial ask", ts: 1 }, { role: "assistant", content: "Ack", ts: 2 }, { role: "user", content: "Follow-up", ts: 3 }, @@ -961,7 +953,7 @@ describe("summarizeConversation", () => { }) it("should calculate newContextTokens correctly with systemPrompt", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -1000,7 +992,7 @@ describe("summarizeConversation", () => { }) it("should successfully summarize conversation", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -1033,7 +1025,7 @@ describe("summarizeConversation", () => { expect(result.messages.length).toBe(messages.length + 1) // Fresh start: effective history should contain only the summary - const effectiveHistory = getEffectiveApiHistory(result.messages as any) + const effectiveHistory = getEffectiveApiHistory(result.messages) expect(effectiveHistory.length).toBe(1) expect(effectiveHistory[0].isSummary).toBe(true) @@ -1045,7 +1037,7 @@ describe("summarizeConversation", () => { }) it("should return error when API handler is invalid", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -1089,7 +1081,7 @@ describe("summarizeConversation", () => { }) it("should tag all messages with condenseParent (fresh start model)", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -1115,7 +1107,7 @@ describe("summarizeConversation", () => { }) it("should place summary message at end of messages array", async () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -1133,7 +1125,7 @@ describe("summarizeConversation", () => { // Summary should be the last message const lastMessage = result.messages[result.messages.length - 1] expect(lastMessage.isSummary).toBe(true) - expect((lastMessage as any).role).toBe("user") + expect(lastMessage.role).toBe("user") }) }) @@ -1144,7 +1136,7 @@ describe("summarizeConversation with custom settings", () => { const localTaskId = "test-task" // Sample messages for testing - const sampleMessages: any[] = [ + const sampleMessages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi there", ts: 2 }, { role: "user", content: "How are you?", ts: 3 }, @@ -1297,7 +1289,7 @@ describe("summarizeConversation with custom settings", () => { describe("toolUseToText", () => { it("should convert tool_use block with object input to text", () => { - const block: any = { + const block: Anthropic.Messages.ToolUseBlockParam = { type: "tool_use", id: "tool-123", name: "read_file", @@ -1310,7 +1302,7 @@ describe("toolUseToText", () => { }) it("should convert tool_use block with nested object input to text", () => { - const block: any = { + const block: Anthropic.Messages.ToolUseBlockParam = { type: "tool_use", id: "tool-456", name: "write_file", @@ -1330,7 +1322,7 @@ describe("toolUseToText", () => { }) it("should convert tool_use block with string input to text", () => { - const block: any = { + const block: Anthropic.Messages.ToolUseBlockParam = { type: "tool_use", id: "tool-789", name: "execute_command", @@ -1343,7 +1335,7 @@ describe("toolUseToText", () => { }) it("should handle empty object input", () => { - const block: any = { + const block: Anthropic.Messages.ToolUseBlockParam = { type: "tool_use", id: "tool-empty", name: "some_tool", @@ -1358,7 +1350,7 @@ describe("toolUseToText", () => { describe("toolResultToText", () => { it("should convert tool_result with string content to text", () => { - const block: any = { + const block: Anthropic.Messages.ToolResultBlockParam = { type: "tool_result", tool_use_id: "tool-123", content: "File contents here", @@ -1370,7 +1362,7 @@ describe("toolResultToText", () => { }) it("should convert tool_result with error flag to text", () => { - const block: any = { + const block: Anthropic.Messages.ToolResultBlockParam = { type: "tool_result", tool_use_id: "tool-456", content: "File not found", @@ -1383,7 +1375,7 @@ describe("toolResultToText", () => { }) it("should convert tool_result with array content to text", () => { - const block: any = { + const block: Anthropic.Messages.ToolResultBlockParam = { type: "tool_result", tool_use_id: "tool-789", content: [ @@ -1398,7 +1390,7 @@ describe("toolResultToText", () => { }) it("should handle tool_result with image in array content", () => { - const block: any = { + const block: Anthropic.Messages.ToolResultBlockParam = { type: "tool_result", tool_use_id: "tool-img", content: [ @@ -1413,7 +1405,7 @@ describe("toolResultToText", () => { }) it("should handle tool_result with no content", () => { - const block: any = { + const block: Anthropic.Messages.ToolResultBlockParam = { type: "tool_result", tool_use_id: "tool-empty", } @@ -1434,7 +1426,7 @@ describe("convertToolBlocksToText", () => { }) it("should convert tool_use blocks to text blocks", () => { - const content: any[] = [ + const content: Anthropic.Messages.ContentBlockParam[] = [ { type: "tool_use", id: "tool-123", @@ -1446,12 +1438,12 @@ describe("convertToolBlocksToText", () => { const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - expect((result as any[])[0].type).toBe("text") - expect((result as any[])[0].text).toContain("[Tool Use: read_file]") + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Use: read_file]") }) it("should convert tool_result blocks to text blocks", () => { - const content: any[] = [ + const content: Anthropic.Messages.ContentBlockParam[] = [ { type: "tool_result", tool_use_id: "tool-123", @@ -1462,12 +1454,12 @@ describe("convertToolBlocksToText", () => { const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - expect((result as any[])[0].type).toBe("text") - expect((result as any[])[0].text).toContain("[Tool Result]") + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Result]") }) it("should preserve non-tool blocks unchanged", () => { - const content: any[] = [ + const content: Anthropic.Messages.ContentBlockParam[] = [ { type: "text", text: "Hello" }, { type: "tool_use", @@ -1481,16 +1473,16 @@ describe("convertToolBlocksToText", () => { const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - const resultArray = result as any[] + const resultArray = result as Anthropic.Messages.ContentBlockParam[] expect(resultArray).toHaveLength(3) expect(resultArray[0]).toEqual({ type: "text", text: "Hello" }) expect(resultArray[1].type).toBe("text") - expect((resultArray[1] as any).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") expect(resultArray[2]).toEqual({ type: "text", text: "World" }) }) it("should handle mixed content with multiple tool blocks", () => { - const content: any[] = [ + const content: Anthropic.Messages.ContentBlockParam[] = [ { type: "tool_use", id: "tool-1", @@ -1507,11 +1499,11 @@ describe("convertToolBlocksToText", () => { const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - const resultArray = result as any[] + const resultArray = result as Anthropic.Messages.ContentBlockParam[] expect(resultArray).toHaveLength(2) - expect((resultArray[0] as any).text).toContain("[Tool Use: read_file]") - expect((resultArray[1] as any).text).toContain("[Tool Result]") - expect((resultArray[1] as any).text).toContain("contents of a.ts") + expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts") }) }) diff --git a/src/core/condense/__tests__/nested-condense.spec.ts b/src/core/condense/__tests__/nested-condense.spec.ts index fbccc15eadd..3868a22262b 100644 --- a/src/core/condense/__tests__/nested-condense.spec.ts +++ b/src/core/condense/__tests__/nested-condense.spec.ts @@ -9,7 +9,7 @@ describe("nested condensing scenarios", () => { const condenseId2 = "condense-2" // Simulate history after two nested condenses with user-role summaries - const history: any[] = [ + const history: ApiMessage[] = [ // Original task - condensed in first condense { role: "user", content: "Build an app", ts: 100, condenseParent: condenseId1 }, // Messages from first condense @@ -47,8 +47,8 @@ describe("nested condensing scenarios", () => { expect(effectiveHistory.length).toBe(3) expect(effectiveHistory[0].isSummary).toBe(true) expect(effectiveHistory[0].condenseId).toBe(condenseId2) // Latest summary - expect((effectiveHistory[1] as any).content).toBe("Database added") - expect((effectiveHistory[2] as any).content).toBe("Now test it") + expect(effectiveHistory[1].content).toBe("Database added") + expect(effectiveHistory[2].content).toBe("Now test it") // Verify NO condensed messages are included const hasCondensedMessages = effectiveHistory.some( @@ -68,7 +68,7 @@ describe("nested condensing scenarios", () => { const hasSummary1 = messagesSinceLastSummary.some((m) => m.condenseId === condenseId1) expect(hasSummary1).toBe(false) - const hasOriginalTask = messagesSinceLastSummary.some((m) => (m as any).content === "Build an app") + const hasOriginalTask = messagesSinceLastSummary.some((m) => m.content === "Build an app") expect(hasOriginalTask).toBe(false) }) @@ -77,7 +77,7 @@ describe("nested condensing scenarios", () => { const condenseId2 = "condense-2" const condenseId3 = "condense-3" - const history: any[] = [ + const history: ApiMessage[] = [ // First condense content { role: "user", content: "Task", ts: 100, condenseParent: condenseId1 }, { @@ -116,7 +116,7 @@ describe("nested condensing scenarios", () => { // Should only contain Summary3 and current work expect(effectiveHistory.length).toBe(2) expect(effectiveHistory[0].condenseId).toBe(condenseId3) - expect((effectiveHistory[1] as any).content).toBe("Current work") + expect(effectiveHistory[1].content).toBe("Current work") const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory) expect(messagesSinceLastSummary.length).toBe(2) @@ -133,7 +133,7 @@ describe("nested condensing scenarios", () => { it("should return consistent results when called with full history vs effective history", () => { const condenseId = "condense-1" - const fullHistory: any[] = [ + const fullHistory: ApiMessage[] = [ { role: "user", content: "Original task", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "Response", ts: 200, condenseParent: condenseId }, { @@ -166,7 +166,7 @@ describe("nested condensing scenarios", () => { const condenseId2 = "condense-2" // Scenario: Two nested condenses with user-role summaries - const fullHistory: any[] = [ + const fullHistory: ApiMessage[] = [ { role: "user", content: "Original task - should NOT appear", ts: 100, condenseParent: condenseId1 }, { role: "assistant", content: "Old response", ts: 200, condenseParent: condenseId1 }, // First summary (user role, fresh-start model), then condensed again @@ -197,9 +197,9 @@ describe("nested condensing scenarios", () => { // The original task should NOT be included const hasOriginalTask = messagesSinceLastSummary.some((m) => - typeof (m as any).content === "string" - ? (m as any).content.includes("Original task") - : JSON.stringify((m as any).content).includes("Original task"), + typeof m.content === "string" + ? m.content.includes("Original task") + : JSON.stringify(m.content).includes("Original task"), ) expect(hasOriginalTask).toBe(false) diff --git a/src/core/condense/__tests__/rewind-after-condense.spec.ts b/src/core/condense/__tests__/rewind-after-condense.spec.ts index b5e8c4c06be..068f49a8570 100644 --- a/src/core/condense/__tests__/rewind-after-condense.spec.ts +++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts @@ -24,7 +24,7 @@ describe("Rewind After Condense - Issue #8295", () => { describe("getEffectiveApiHistory", () => { it("should return summary and messages after summary (fresh start model)", () => { const condenseId = "summary-123" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message", ts: 1, condenseParent: condenseId }, { role: "assistant", content: "First response", ts: 2, condenseParent: condenseId }, { role: "user", content: "Second message", ts: 3, condenseParent: condenseId }, @@ -39,12 +39,12 @@ describe("Rewind After Condense - Issue #8295", () => { // Fresh start model: summary + all messages after it expect(effective.length).toBe(3) expect(effective[0].isSummary).toBe(true) - expect((effective[1] as any).content).toBe("Third message") - expect((effective[2] as any).content).toBe("Third response") + expect(effective[1].content).toBe("Third message") + expect(effective[2].content).toBe("Third response") }) it("should include messages without condenseParent", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi", ts: 2 }, ] @@ -64,7 +64,7 @@ describe("Rewind After Condense - Issue #8295", () => { describe("cleanupAfterTruncation", () => { it("should clear condenseParent when summary message is deleted", () => { const condenseId = "summary-123" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message", ts: 1 }, { role: "assistant", content: "First response", ts: 2, condenseParent: condenseId }, { role: "user", content: "Second message", ts: 3, condenseParent: condenseId }, @@ -80,7 +80,7 @@ describe("Rewind After Condense - Issue #8295", () => { it("should preserve condenseParent when summary message still exists", () => { const condenseId = "summary-123" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message", ts: 1 }, { role: "assistant", content: "First response", ts: 2, condenseParent: condenseId }, { role: "user", content: "Summary", ts: 3, isSummary: true, condenseId }, @@ -95,7 +95,7 @@ describe("Rewind After Condense - Issue #8295", () => { it("should handle multiple condense operations with different IDs", () => { const condenseId1 = "summary-1" const condenseId2 = "summary-2" - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Message 1", ts: 1, condenseParent: condenseId1 }, { role: "user", content: "Summary 1", ts: 2, isSummary: true, condenseId: condenseId1 }, { role: "user", content: "Message 2", ts: 3, condenseParent: condenseId2 }, @@ -111,7 +111,7 @@ describe("Rewind After Condense - Issue #8295", () => { }) it("should not modify messages without condenseParent", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Hello", ts: 1 }, { role: "assistant", content: "Hi", ts: 2 }, ] @@ -132,7 +132,7 @@ describe("Rewind After Condense - Issue #8295", () => { const condenseId = "summary-abc" // Simulate a conversation after condensing (all prior messages tagged) - const fullHistory: any[] = [ + const fullHistory: ApiMessage[] = [ { role: "user", content: "Initial task", ts: 1, condenseParent: condenseId }, { role: "assistant", content: "Working on it", ts: 2, condenseParent: condenseId }, { role: "user", content: "Continue", ts: 3, condenseParent: condenseId }, @@ -152,11 +152,11 @@ describe("Rewind After Condense - Issue #8295", () => { } // Verify effective history after cleanup: all messages should be visible now - const effectiveAfterCleanup = getEffectiveApiHistory(cleanedAfterDeletingSummary as any) + const effectiveAfterCleanup = getEffectiveApiHistory(cleanedAfterDeletingSummary) expect(effectiveAfterCleanup.length).toBe(3) - expect((effectiveAfterCleanup[0] as any).content).toBe("Initial task") - expect((effectiveAfterCleanup[1] as any).content).toBe("Working on it") - expect((effectiveAfterCleanup[2] as any).content).toBe("Continue") + expect(effectiveAfterCleanup[0].content).toBe("Initial task") + expect(effectiveAfterCleanup[1].content).toBe("Working on it") + expect(effectiveAfterCleanup[2].content).toBe("Continue") }) it("should properly restore context after rewind when summary was deleted", () => { @@ -165,7 +165,7 @@ describe("Rewind After Condense - Issue #8295", () => { // Scenario: Most of the conversation was condensed, but the summary was deleted. // getEffectiveApiHistory already correctly handles orphaned messages (includes them // when their summary doesn't exist). cleanupAfterTruncation cleans up the tags. - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Start", ts: 1 }, { role: "assistant", content: "Response 1", ts: 2, condenseParent: condenseId }, { role: "user", content: "More", ts: 3, condenseParent: condenseId }, @@ -177,8 +177,8 @@ describe("Rewind After Condense - Issue #8295", () => { // getEffectiveApiHistory already includes orphaned messages (summary doesn't exist) const effectiveBefore = getEffectiveApiHistory(messages) expect(effectiveBefore.length).toBe(5) // All messages visible since summary was deleted - expect((effectiveBefore[0] as any).content).toBe("Start") - expect((effectiveBefore[1] as any).content).toBe("Response 1") + expect(effectiveBefore[0].content).toBe("Start") + expect(effectiveBefore[1].content).toBe("Response 1") // cleanupAfterTruncation clears the orphaned condenseParent tags for data hygiene const cleaned = cleanupAfterTruncation(messages) @@ -190,7 +190,7 @@ describe("Rewind After Condense - Issue #8295", () => { expect(cleaned[4].condenseParent).toBeUndefined() // After cleanup, effective history is the same (all visible) - const effectiveAfter = getEffectiveApiHistory(cleaned as any) + const effectiveAfter = getEffectiveApiHistory(cleaned) expect(effectiveAfter.length).toBe(5) // All messages visible }) @@ -199,7 +199,7 @@ describe("Rewind After Condense - Issue #8295", () => { // Scenario: Messages were condensed and summary exists - fresh start model returns // only the summary and messages after it, NOT messages before the summary - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Start", ts: 1 }, { role: "assistant", content: "Response 1", ts: 2, condenseParent: condenseId }, { role: "user", content: "More", ts: 3, condenseParent: condenseId }, @@ -211,9 +211,9 @@ describe("Rewind After Condense - Issue #8295", () => { // "Start" is NOT included because it's before the summary const effective = getEffectiveApiHistory(messages) expect(effective.length).toBe(2) // Summary, After summary (NOT Start) - expect((effective[0] as any).content).toBe("Summary") + expect(effective[0].content).toBe("Summary") expect(effective[0].isSummary).toBe(true) - expect((effective[1] as any).content).toBe("After summary") + expect(effective[1].content).toBe("After summary") // cleanupAfterTruncation should NOT clear condenseParent since summary exists const cleaned = cleanupAfterTruncation(messages) @@ -241,7 +241,7 @@ describe("Rewind After Condense - Issue #8295", () => { // Simulate post-condense state where summary has unique timestamp (firstKeptTs - 1) // In real usage, condensed messages have timestamps like 100, 200, 300... // and firstKeptTs is much larger, so firstKeptTs - 1 = 999 is unique - const messagesAfterCondense: any[] = [ + const messagesAfterCondense: ApiMessage[] = [ { role: "user", content: "Initial task", ts: 100 }, { role: "assistant", content: "Response 1", ts: 200, condenseParent: condenseId }, { role: "user", content: "Continue", ts: 300, condenseParent: condenseId }, @@ -281,7 +281,7 @@ describe("Rewind After Condense - Issue #8295", () => { const condenseId = "summary-lookup-test" const firstKeptTs = 8 - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1 }, { role: "user", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId }, { role: "assistant", content: "First kept message", ts: firstKeptTs }, @@ -320,7 +320,7 @@ describe("Rewind After Condense - Issue #8295", () => { // - msg2-msg7 tagged with condenseParent // - summary inserted with ts = msg8.ts - 1 // - msg8, msg9, msg10 kept - const storageAfterCondense: any[] = [ + const storageAfterCondense: ApiMessage[] = [ { role: "user", content: "Task: Build a feature", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "I'll help with that", ts: 200, condenseParent: condenseId }, { role: "user", content: "Start with the API", ts: 300, condenseParent: condenseId }, @@ -350,23 +350,23 @@ describe("Rewind After Condense - Issue #8295", () => { expect(effective.length).toBe(4) // Verify exact order and content - expect((effective[0] as any).role).toBe("user") + expect(effective[0].role).toBe("user") expect(effective[0].isSummary).toBe(true) - expect((effective[0] as any).content).toBe("Summary: Built API with validation, working on tests") + expect(effective[0].content).toBe("Summary: Built API with validation, working on tests") - expect((effective[1] as any).role).toBe("assistant") - expect((effective[1] as any).content).toBe("Writing unit tests now") + expect(effective[1].role).toBe("assistant") + expect(effective[1].content).toBe("Writing unit tests now") - expect((effective[2] as any).role).toBe("user") - expect((effective[2] as any).content).toBe("Include edge cases") + expect(effective[2].role).toBe("user") + expect(effective[2].content).toBe("Include edge cases") - expect((effective[3] as any).role).toBe("assistant") - expect((effective[3] as any).content).toBe("Added edge case tests") + expect(effective[3].role).toBe("assistant") + expect(effective[3].content).toBe("Added edge case tests") // Verify condensed messages are NOT in effective history const condensedContents = ["I'll help with that", "Start with the API", "Creating API endpoints"] for (const content of condensedContents) { - expect(effective.find((m) => (m as any).content === content)).toBeUndefined() + expect(effective.find((m) => m.content === content)).toBeUndefined() } }) @@ -380,7 +380,7 @@ describe("Rewind After Condense - Issue #8295", () => { // Second condense: summary1 + msg8-msg17 condensed, summary2 created // // Storage after double condense: - const storageAfterDoubleCondense: any[] = [ + const storageAfterDoubleCondense: ApiMessage[] = [ // First message - condensed during the first condense { role: "user", content: "Initial task: Build a full app", ts: 100, condenseParent: condenseId1 }, @@ -437,22 +437,22 @@ describe("Rewind After Condense - Issue #8295", () => { expect(effective.length).toBe(4) // Verify exact order and content - expect((effective[0] as any).role).toBe("user") + expect(effective[0].role).toBe("user") expect(effective[0].isSummary).toBe(true) expect(effective[0].condenseId).toBe(condenseId2) // Must be the SECOND summary - expect((effective[0] as any).content).toContain("Summary2") + expect(effective[0].content).toContain("Summary2") - expect((effective[1] as any).role).toBe("assistant") - expect((effective[1] as any).content).toBe("Writing integration tests") + expect(effective[1].role).toBe("assistant") + expect(effective[1].content).toBe("Writing integration tests") - expect((effective[2] as any).role).toBe("user") - expect((effective[2] as any).content).toBe("Test the auth flow") + expect(effective[2].role).toBe("user") + expect(effective[2].content).toBe("Test the auth flow") - expect((effective[3] as any).role).toBe("assistant") - expect((effective[3] as any).content).toBe("Auth tests passing") + expect(effective[3].role).toBe("assistant") + expect(effective[3].content).toBe("Auth tests passing") // Verify Summary1 is NOT in effective history (it's tagged with condenseParent) - const summary1 = effective.find((m) => (m as any).content?.toString().includes("Summary1")) + const summary1 = effective.find((m) => m.content?.toString().includes("Summary1")) expect(summary1).toBeUndefined() // Verify all condensed messages are NOT in effective history @@ -464,7 +464,7 @@ describe("Rewind After Condense - Issue #8295", () => { "Implemented error handlers", ] for (const content of condensedContents) { - expect(effective.find((m) => (m as any).content === content)).toBeUndefined() + expect(effective.find((m) => m.content === content)).toBeUndefined() } }) @@ -473,7 +473,7 @@ describe("Rewind After Condense - Issue #8295", () => { // Verify that after condense, the effective history maintains proper // user/assistant message alternation (important for API compatibility) - const storage: any[] = [ + const storage: ApiMessage[] = [ { role: "user", content: "Start task", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "Response 1", ts: 200, condenseParent: condenseId }, { role: "user", content: "Continue", ts: 300, condenseParent: condenseId }, @@ -488,17 +488,17 @@ describe("Rewind After Condense - Issue #8295", () => { // Verify the sequence: user(summary), assistant, user, assistant // This is the fresh-start model with user-role summaries - expect((effective[0] as any).role).toBe("user") + expect(effective[0].role).toBe("user") expect(effective[0].isSummary).toBe(true) - expect((effective[1] as any).role).toBe("assistant") - expect((effective[2] as any).role).toBe("user") - expect((effective[3] as any).role).toBe("assistant") + expect(effective[1].role).toBe("assistant") + expect(effective[2].role).toBe("user") + expect(effective[3].role).toBe("assistant") }) it("should preserve timestamps in chronological order in effective history", () => { const condenseId = "summary-timestamps" - const storage: any[] = [ + const storage: ApiMessage[] = [ { role: "user", content: "First", ts: 100, condenseParent: condenseId }, { role: "assistant", content: "Condensed", ts: 200, condenseParent: condenseId }, { role: "user", content: "Summary", ts: 299, isSummary: true, condenseId }, diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index ea6b472d971..0438bf6bcb1 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -1,32 +1,11 @@ +import Anthropic from "@anthropic-ai/sdk" import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { t } from "../../i18n" import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" -import { - type RooMessage, - type RooUserMessage, - type RooToolMessage, - type RooRoleMessage, - isRooAssistantMessage, - isRooToolMessage, - isRooUserMessage, - isRooRoleMessage, - type ToolCallPart, - type ToolResultPart, - type TextPart, - type AnyToolCallBlock, - type AnyToolResultBlock, - isAnyToolCallBlock, - isAnyToolResultBlock, - getToolCallId, - getToolCallName, - getToolCallInput, - getToolResultCallId, - getToolResultContent, - getToolResultIsError, -} from "../task-persistence/rooMessage" +import { ApiMessage } from "../task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" @@ -36,15 +15,13 @@ import { generateFoldedFileContext } from "./foldedFileContext" export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext" /** - * Converts a tool-call / tool_use block to a text representation. - * Accepts both AI SDK ToolCallPart (toolName, input) and legacy Anthropic format (name, input). + * Converts a tool_use block to a text representation. + * This allows the conversation to be summarized without requiring the tools parameter. */ -export function toolUseToText(block: AnyToolCallBlock): string { - const name = getToolCallName(block) - const rawInput = getToolCallInput(block) +export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string { let input: string - if (typeof rawInput === "object" && rawInput !== null) { - input = Object.entries(rawInput) + if (typeof block.input === "object" && block.input !== null) { + input = Object.entries(block.input) .map(([key, value]) => { const formattedValue = typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) @@ -52,38 +29,33 @@ export function toolUseToText(block: AnyToolCallBlock): string { }) .join("\n") } else { - input = String(rawInput) + input = String(block.input) } - return `[Tool Use: ${name}]\n${input}` + return `[Tool Use: ${block.name}]\n${input}` } /** - * Converts a tool-result / tool_result block to a text representation. - * Accepts both AI SDK ToolResultPart and legacy Anthropic format. + * Converts a tool_result block to a text representation. + * This allows the conversation to be summarized without requiring the tools parameter. */ -export function toolResultToText(block: AnyToolResultBlock): string { - const isError = getToolResultIsError(block) - const errorSuffix = isError ? " (Error)" : "" - // AI SDK uses `output`, legacy uses `content` - const rawContent = getToolResultContent(block) - if (typeof rawContent === "string") { - return `[Tool Result${errorSuffix}]\n${rawContent}` - } else if (Array.isArray(rawContent)) { - const contentText = rawContent - .map((contentBlock: { type: string; text?: string }) => { +export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string { + const errorSuffix = block.is_error ? " (Error)" : "" + if (typeof block.content === "string") { + return `[Tool Result${errorSuffix}]\n${block.content}` + } else if (Array.isArray(block.content)) { + const contentText = block.content + .map((contentBlock) => { if (contentBlock.type === "text") { return contentBlock.text } if (contentBlock.type === "image") { return "[Image]" } - return `[${contentBlock.type}]` + // Handle any other content block types + return `[${(contentBlock as { type: string }).type}]` }) .join("\n") return `[Tool Result${errorSuffix}]\n${contentText}` - } else if (rawContent && typeof rawContent === "object" && "value" in rawContent) { - // AI SDK ToolResultPart.output has shape { type: "text", value: string } - return `[Tool Result${errorSuffix}]\n${String((rawContent as { value: unknown }).value)}` } return `[Tool Result${errorSuffix}]` } @@ -96,21 +68,21 @@ export function toolResultToText(block: AnyToolResultBlock): string { * @param content - The message content (string or array of content blocks) * @returns The transformed content with tool blocks converted to text blocks */ -export function convertToolBlocksToText(content: string | Array<{ type: string }>): string | Array<{ type: string }> { +export function convertToolBlocksToText( + content: string | Anthropic.Messages.ContentBlockParam[], +): string | Anthropic.Messages.ContentBlockParam[] { if (typeof content === "string") { return content } return content.map((block) => { - // Check both AI SDK (`tool-call`) and legacy (`tool_use`) discriminators - if (isAnyToolCallBlock(block)) { + if (block.type === "tool_use") { return { type: "text" as const, text: toolUseToText(block), } } - // Check both AI SDK (`tool-result`) and legacy (`tool_result`) discriminators - if (isAnyToolResultBlock(block)) { + if (block.type === "tool_result") { return { type: "text" as const, text: toolResultToText(block), @@ -127,9 +99,9 @@ export function convertToolBlocksToText(content: string | Array<{ type: string } * @param messages - The messages to transform * @returns The transformed messages with tool blocks converted to text */ -export function transformMessagesForCondensing }>( - messages: T[], -): T[] { +export function transformMessagesForCondensing< + T extends { role: string; content: string | Anthropic.Messages.ContentBlockParam[] }, +>(messages: T[]): T[] { return messages.map((msg) => ({ ...msg, content: convertToolBlocksToText(msg.content), @@ -159,33 +131,24 @@ The goal is for work to continue seamlessly after condensation - as if it never * @param messages - The conversation messages to process * @returns The messages with synthetic tool_results appended if needed */ -export function injectSyntheticToolResults(messages: RooMessage[]): RooMessage[] { - // Find all tool-call IDs in assistant messages +export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] { + // Find all tool_call IDs in assistant messages const toolCallIds = new Set() - // Find all tool-result IDs in user/tool messages + // Find all tool_result IDs in user messages const toolResultIds = new Set() for (const msg of messages) { - if (isRooAssistantMessage(msg) && Array.isArray(msg.content)) { + if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (isAnyToolCallBlock(block as { type: string })) { - toolCallIds.add(getToolCallId(block as AnyToolCallBlock)) + if (block.type === "tool_use") { + toolCallIds.add(block.id) } } } - if (isRooToolMessage(msg) && Array.isArray(msg.content)) { + if (msg.role === "user" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (isAnyToolResultBlock(block as { type: string })) { - toolResultIds.add(getToolResultCallId(block as AnyToolResultBlock)) - } - } - } - // Also check legacy user messages with tool_result blocks - if (isRooUserMessage(msg) && Array.isArray(msg.content)) { - for (const block of msg.content) { - const typedBlock = block as unknown as { type: string } - if (isAnyToolResultBlock(typedBlock)) { - toolResultIds.add(getToolResultCallId(typedBlock)) + if (block.type === "tool_result") { + toolResultIds.add(block.tool_use_id) } } } @@ -198,16 +161,15 @@ export function injectSyntheticToolResults(messages: RooMessage[]): RooMessage[] return messages } - // Inject synthetic tool_results as a new RooToolMessage - const syntheticResults: ToolResultPart[] = orphanIds.map((id) => ({ - type: "tool-result" as const, - toolCallId: id, - toolName: "unknown", - output: { type: "text" as const, value: "Context condensation triggered. Tool execution deferred." }, + // Inject synthetic tool_results as a new user message + const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = orphanIds.map((id) => ({ + type: "tool_result" as const, + tool_use_id: id, + content: "Context condensation triggered. Tool execution deferred.", })) - const syntheticMessage: RooToolMessage = { - role: "tool", + const syntheticMessage: ApiMessage = { + role: "user", content: syntheticResults, ts: Date.now(), } @@ -222,10 +184,7 @@ export function injectSyntheticToolResults(messages: RooMessage[]): RooMessage[] * @param message - The message to extract command blocks from * @returns A string containing all command blocks found, or empty string if none */ -export function extractCommandBlocks(message: RooMessage): string { - if (!isRooRoleMessage(message)) { - return "" - } +export function extractCommandBlocks(message: ApiMessage): string { const content = message.content let text: string @@ -234,7 +193,7 @@ export function extractCommandBlocks(message: RooMessage): string { } else if (Array.isArray(content)) { // Concatenate all text blocks text = content - .filter((block): block is TextPart => (block as { type: string }).type === "text") + .filter((block): block is Anthropic.Messages.TextBlockParam => block.type === "text") .map((block) => block.text) .join("\n") } else { @@ -253,7 +212,7 @@ export function extractCommandBlocks(message: RooMessage): string { } export type SummarizeResponse = { - messages: RooMessage[] // The messages after summarization + messages: ApiMessage[] // The messages after summarization summary: string // The summary text; empty string for no summary cost: number // The cost of the summarization operation newContextTokens?: number // The number of tokens in the context for the next API request @@ -263,7 +222,7 @@ export type SummarizeResponse = { } export type SummarizeConversationOptions = { - messages: RooMessage[] + messages: ApiMessage[] apiHandler: ApiHandler systemPrompt: string taskId: string @@ -328,7 +287,7 @@ export async function summarizeConversation(options: SummarizeConversationOption } // Check if there's a recent summary in the messages (edge case) - const recentSummaryExists = messagesToSummarize.some((message) => message.isSummary) + const recentSummaryExists = messagesToSummarize.some((message: ApiMessage) => message.isSummary) if (recentSummaryExists && messagesToSummarize.length <= 2) { const error = t("common:errors.condensed_recently") @@ -339,7 +298,7 @@ export async function summarizeConversation(options: SummarizeConversationOption // This respects user's custom condensing prompt setting const condenseInstructions = customCondensingPrompt?.trim() || supportPrompt.default.CONDENSE - const finalRequestMessage: RooUserMessage = { + const finalRequestMessage: Anthropic.MessageParam = { role: "user", content: condenseInstructions, } @@ -352,15 +311,8 @@ export async function summarizeConversation(options: SummarizeConversationOption // This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter // when tool blocks are present. By converting them to text, we can send the conversation for // summarization without needing to pass the tools parameter. - // Filter out reasoning messages (no role/content) before transforming for the API - const messagesForApi = [...messagesWithToolResults, finalRequestMessage].filter( - (msg): msg is Exclude => "role" in msg, - ) const messagesWithTextToolBlocks = transformMessagesForCondensing( - maybeRemoveImageBlocks(messagesForApi, apiHandler) as Array<{ - role: string - content: string | Array<{ type: string }> - }>, + maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler), ) const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content })) @@ -380,7 +332,7 @@ export async function summarizeConversation(options: SummarizeConversationOption let outputTokens = 0 try { - const stream = apiHandler.createMessage(promptToUse, requestMessages as RooMessage[], metadata) + const stream = apiHandler.createMessage(promptToUse, requestMessages, metadata) for await (const chunk of stream) { if (chunk.type === "text") { @@ -446,7 +398,9 @@ export async function summarizeConversation(options: SummarizeConversationOption const commandBlocks = firstMessage ? extractCommandBlocks(firstMessage) : "" // Build the summary content as separate text blocks - const summaryContent: TextPart[] = [{ type: "text", text: `## Conversation Summary\n${summary}` }] + const summaryContent: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: `## Conversation Summary\n${summary}` }, + ] // Add command blocks (active workflows) in their own system-reminder block if present if (commandBlocks) { @@ -501,7 +455,7 @@ ${commandBlocks} // The summary goes at the end of all messages. const lastMsgTs = messages[messages.length - 1]?.ts ?? Date.now() - const summaryMessage: RooUserMessage = { + const summaryMessage: ApiMessage = { role: "user", // Fresh start model: summary is a user message content: summaryContent, ts: lastMsgTs + 1, // Unique timestamp after last message @@ -534,7 +488,7 @@ ${commandBlocks} // Count the tokens in the context for the next API request // After condense, the context will contain: system prompt + summary + tool definitions - const systemPromptMessage: RooUserMessage = { role: "user", content: systemPrompt } + const systemPromptMessage: ApiMessage = { role: "user", content: systemPrompt } // Count actual summaryMessage content directly instead of using outputTokens as a proxy // This ensures we account for wrapper text (## Conversation Summary, , ) @@ -542,7 +496,7 @@ ${commandBlocks} typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content, ) - const messageTokens = await apiHandler.countTokens(contextBlocks as Parameters[0]) + const messageTokens = await apiHandler.countTokens(contextBlocks) // Count tool definition tokens if tools are provided let toolTokens = 0 @@ -562,7 +516,7 @@ ${commandBlocks} * Note: Summary messages are always created with role: "user" (fresh-start model), * so the first message since the last summary is guaranteed to be a user message. */ -export function getMessagesSinceLastSummary(messages: RooMessage[]): RooMessage[] { +export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[] { const lastSummaryIndexReverse = [...messages].reverse().findIndex((message) => message.isSummary) if (lastSummaryIndexReverse === -1) { @@ -589,7 +543,7 @@ export function getMessagesSinceLastSummary(messages: RooMessage[]): RooMessage[ * @param messages - The full API conversation history including tagged messages * @returns The filtered history that should be sent to the API */ -export function getEffectiveApiHistory(messages: RooMessage[]): RooMessage[] { +export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { // Find the most recent summary message const lastSummary = findLast(messages, (msg) => msg.isSummary === true) @@ -598,56 +552,42 @@ export function getEffectiveApiHistory(messages: RooMessage[]): RooMessage[] { const summaryIndex = messages.indexOf(lastSummary) let messagesFromSummary = messages.slice(summaryIndex) - // Collect all tool call IDs from assistant messages in the result. - // This is needed to filter out orphan tool results that reference - // tool call IDs from messages that were condensed away. - const toolCallIds = new Set() + // Collect all tool_use IDs from assistant messages in the result + // This is needed to filter out orphan tool_result blocks that reference + // tool_use IDs from messages that were condensed away + const toolUseIds = new Set() for (const msg of messagesFromSummary) { - if (isRooAssistantMessage(msg) && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "tool-call") { - toolCallIds.add((part as ToolCallPart).toolCallId) + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) { + toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id) } } } } - // Filter out orphan tool results from tool messages + // Filter out orphan tool_result blocks from user messages messagesFromSummary = messagesFromSummary .map((msg) => { - if (isRooToolMessage(msg) && Array.isArray(msg.content)) { - const filteredContent = msg.content.filter((part) => { - if (part.type === "tool-result") { - return toolCallIds.has((part as ToolResultPart).toolCallId) - } - return true - }) - if (filteredContent.length === 0) { - return null - } - if (filteredContent.length !== msg.content.length) { - return { ...msg, content: filteredContent } - } - } - // Also handle legacy user messages that may contain tool_result blocks - if (isRooUserMessage(msg) && Array.isArray(msg.content)) { + if (msg.role === "user" && Array.isArray(msg.content)) { const filteredContent = msg.content.filter((block) => { - const typedBlock = block as unknown as { type: string } - if (isAnyToolResultBlock(typedBlock)) { - return toolCallIds.has(getToolResultCallId(typedBlock)) + if (block.type === "tool_result") { + return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id) } return true }) + // If all content was filtered out, mark for removal if (filteredContent.length === 0) { return null } + // If some content was filtered, return updated message if (filteredContent.length !== msg.content.length) { - return { ...msg, content: filteredContent as typeof msg.content } + return { ...msg, content: filteredContent } } } return msg }) - .filter((msg): msg is RooMessage => msg !== null) + .filter((msg): msg is ApiMessage => msg !== null) // Still need to filter out any truncated messages within this range const existingTruncationIds = new Set() @@ -658,6 +598,7 @@ export function getEffectiveApiHistory(messages: RooMessage[]): RooMessage[] { } return messagesFromSummary.filter((msg) => { + // Filter out truncated messages if their truncation marker exists if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) { return false } @@ -668,7 +609,9 @@ export function getEffectiveApiHistory(messages: RooMessage[]): RooMessage[] { // No summary - filter based on condenseParent and truncationParent as before // This handles the case of orphaned condenseParent tags (summary was deleted via rewind) + // Collect all condenseIds of summaries that exist in the current history const existingSummaryIds = new Set() + // Collect all truncationIds of truncation markers that exist in the current history const existingTruncationIds = new Set() for (const msg of messages) { @@ -680,10 +623,15 @@ export function getEffectiveApiHistory(messages: RooMessage[]): RooMessage[] { } } + // Filter out messages whose condenseParent points to an existing summary + // or whose truncationParent points to an existing truncation marker. + // Messages with orphaned parents (summary/marker was deleted) are included. return messages.filter((msg) => { + // Filter out condensed messages if their summary exists if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) { return false } + // Filter out truncated messages if their truncation marker exists if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) { return false } @@ -702,7 +650,7 @@ export function getEffectiveApiHistory(messages: RooMessage[]): RooMessage[] { * @param messages - The API conversation history after truncation * @returns The cleaned history with orphaned condenseParent and truncationParent fields cleared */ -export function cleanupAfterTruncation(messages: RooMessage[]): RooMessage[] { +export function cleanupAfterTruncation(messages: ApiMessage[]): ApiMessage[] { // Collect all condenseIds of summaries that still exist const existingSummaryIds = new Set() // Collect all truncationIds of truncation markers that still exist @@ -734,7 +682,7 @@ export function cleanupAfterTruncation(messages: RooMessage[]): RooMessage[] { if (needsUpdate) { // Create a new object without orphaned parent references const { condenseParent, truncationParent, ...rest } = msg - const result = rest as RooMessage + const result: ApiMessage = rest as ApiMessage // Keep condenseParent if its summary still exists if (condenseParent && existingSummaryIds.has(condenseParent)) { diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 8e98b99b605..9950ec536b3 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -6,7 +6,7 @@ import type { ModelInfo } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { BaseProvider } from "../../../api/providers/base-provider" - +import { ApiMessage } from "../../task-persistence/apiMessages" import * as condenseModule from "../../condense" import { @@ -61,7 +61,7 @@ describe("Context Management", () => { */ describe("truncateConversation", () => { it("should retain the first message", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -80,7 +80,7 @@ describe("Context Management", () => { }) it("should remove the specified fraction of messages (rounded to even number)", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -103,7 +103,7 @@ describe("Context Management", () => { // Marker should be at index 3 (at the boundary, after truncated messages) expect(result.messages[3].isTruncationMarker).toBe(true) - expect((result.messages[3] as any).role).toBe("user") + expect(result.messages[3].role).toBe("user") // Messages at indices 3 and 4 from original should NOT be tagged (now at indices 4 and 5) expect(result.messages[4].truncationParent).toBeUndefined() @@ -111,7 +111,7 @@ describe("Context Management", () => { }) it("should round to an even number of messages to remove", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -131,7 +131,7 @@ describe("Context Management", () => { }) it("should handle edge case with fracToRemove = 0", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -145,7 +145,7 @@ describe("Context Management", () => { }) it("should handle edge case with fracToRemove = 1", () => { - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -167,7 +167,7 @@ describe("Context Management", () => { // Marker should be at index 3 (at the boundary) expect(result.messages[3].isTruncationMarker).toBe(true) - expect((result.messages[3] as any).role).toBe("user") + expect(result.messages[3].role).toBe("user") // Last message should NOT be tagged (now at index 4) expect(result.messages[4].truncationParent).toBeUndefined() @@ -273,7 +273,7 @@ describe("Context Management", () => { maxTokens, }) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -446,7 +446,7 @@ describe("Context Management", () => { // Test case 1: Small content that won't push us over the threshold const smallContent = [{ type: "text" as const, text: "Small content" }] const smallContentTokens = await estimateTokenCount(smallContent, mockApiHandler) - const messagesWithSmallContent: any[] = [ + const messagesWithSmallContent: ApiMessage[] = [ ...messages.slice(0, -1), { role: messages[messages.length - 1].role, content: smallContent }, ] @@ -482,7 +482,7 @@ describe("Context Management", () => { }, ] const largeContentTokens = await estimateTokenCount(largeContent, mockApiHandler) - const messagesWithLargeContent: any[] = [ + const messagesWithLargeContent: ApiMessage[] = [ ...messages.slice(0, -1), { role: messages[messages.length - 1].role, content: largeContent }, ] @@ -510,7 +510,7 @@ describe("Context Management", () => { // Test case 3: Very large content that will definitely exceed threshold const veryLargeContent = [{ type: "text" as const, text: "X".repeat(1000) }] const veryLargeContentTokens = await estimateTokenCount(veryLargeContent, mockApiHandler) - const messagesWithVeryLargeContent: any[] = [ + const messagesWithVeryLargeContent: ApiMessage[] = [ ...messages.slice(0, -1), { role: messages[messages.length - 1].role, content: veryLargeContent }, ] @@ -858,7 +858,7 @@ describe("Context Management", () => { maxTokens, }) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -1067,7 +1067,7 @@ describe("Context Management", () => { maxTokens, }) - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -1275,7 +1275,7 @@ describe("Context Management", () => { }) // Reuse across tests for consistency - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -1623,7 +1623,7 @@ describe("Context Management", () => { const modelInfo = createModelInfo(100000, 30000) const totalTokens = 70001 // Above threshold to trigger truncation - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, @@ -1665,7 +1665,7 @@ describe("Context Management", () => { const modelInfo = createModelInfo(100000, 30000) const totalTokens = 70001 // Above threshold to trigger truncation - const messages: any[] = [ + const messages: ApiMessage[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "Second message" }, { role: "user", content: "Third message" }, diff --git a/src/core/context-management/__tests__/truncation.spec.ts b/src/core/context-management/__tests__/truncation.spec.ts index 81e5e224684..2e6cbed5b6e 100644 --- a/src/core/context-management/__tests__/truncation.spec.ts +++ b/src/core/context-management/__tests__/truncation.spec.ts @@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from "vitest" import { TelemetryService } from "@roo-code/telemetry" import { truncateConversation } from "../index" import { getEffectiveApiHistory, cleanupAfterTruncation } from "../../condense" +import { ApiMessage } from "../../task-persistence/apiMessages" describe("Non-Destructive Sliding Window Truncation", () => { - let messages: any[] + let messages: ApiMessage[] beforeEach(() => { // Initialize TelemetryService for tests @@ -65,8 +66,8 @@ describe("Non-Destructive Sliding Window Truncation", () => { expect(marker!.isTruncationMarker).toBe(true) expect(marker!.truncationId).toBeDefined() expect(marker!.truncationId).toBe(result.truncationId) - expect((marker as any).role).toBe("user") - expect((marker as any).content).toContain("Sliding window truncation") + expect(marker!.role).toBe("user") + expect(marker!.content).toContain("Sliding window truncation") }) it("should return truncationId and messagesRemoved", () => { @@ -79,7 +80,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { it("should round messagesToRemove to an even number", () => { // Test with 12 messages (1 initial + 11 conversation) - const manyMessages: any[] = [ + const manyMessages: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1000 }, ...Array.from({ length: 11 }, (_, i) => ({ role: (i % 2 === 0 ? "assistant" : "user") as "assistant" | "user", @@ -98,7 +99,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { describe("getEffectiveApiHistory()", () => { it("should filter out truncated messages when truncation marker exists", () => { const truncationResult = truncateConversation(messages, 0.5, "test-task-id") - const effective = getEffectiveApiHistory(truncationResult.messages as any) + const effective = getEffectiveApiHistory(truncationResult.messages) // Should exclude 4 truncated messages but keep the first message and truncation marker // Original: 11 messages @@ -107,7 +108,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { expect(effective.length).toBe(8) // First message should be present - expect((effective[0] as any).content).toBe("Initial task") + expect(effective[0].content).toBe("Initial task") // Truncation marker should be present expect(effective[1].isTruncationMarker).toBe(true) @@ -126,19 +127,19 @@ describe("Non-Destructive Sliding Window Truncation", () => { // Remove the truncation marker (simulate rewind past truncation) const messagesWithoutMarker = truncationResult.messages.filter((msg) => !msg.isTruncationMarker) - const effective = getEffectiveApiHistory(messagesWithoutMarker as any) + const effective = getEffectiveApiHistory(messagesWithoutMarker) // All messages should be visible now expect(effective.length).toBe(messages.length) // Verify first and last messages are present - expect((effective[0] as any).content).toBe("Initial task") - expect((effective[effective.length - 1] as any).content).toBe("Message 6") + expect(effective[0].content).toBe("Initial task") + expect(effective[effective.length - 1].content).toBe("Message 6") }) it("should handle both condenseParent and truncationParent filtering", () => { // Create a scenario with both condensing and truncation - const messagesWithCondense: any[] = [ + const messagesWithCondense: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1000 }, { role: "assistant", content: "Msg 1", ts: 1100, condenseParent: "condense-1" }, { role: "user", content: "Msg 2", ts: 1200, condenseParent: "condense-1" }, @@ -154,7 +155,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { ] const truncationResult = truncateConversation(messagesWithCondense, 0.5, "test-task-id") - const effective = getEffectiveApiHistory(truncationResult.messages as any) + const effective = getEffectiveApiHistory(truncationResult.messages) // Should filter both condensed messages and truncated messages // Messages with condenseParent="condense-1" should be filtered (summary exists) @@ -198,7 +199,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { }) it("should handle both condenseParent and truncationParent cleanup", () => { - const messagesWithBoth: any[] = [ + const messagesWithBoth: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1000 }, { role: "assistant", content: "Msg 1", ts: 1100, condenseParent: "orphan-condense" }, { role: "user", content: "Msg 2", ts: 1200, truncationParent: "orphan-truncation" }, @@ -213,7 +214,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { }) it("should preserve valid parent references", () => { - const messagesWithValidParents: any[] = [ + const messagesWithValidParents: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1000 }, { role: "assistant", content: "Msg 1", ts: 1100, condenseParent: "valid-condense" }, { @@ -247,7 +248,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { const truncationResult = truncateConversation(messages, 0.5, "test-task-id") // Step 2: Verify messages are hidden initially - const effectiveBeforeRewind = getEffectiveApiHistory(truncationResult.messages as any) + const effectiveBeforeRewind = getEffectiveApiHistory(truncationResult.messages) expect(effectiveBeforeRewind.length).toBeLessThan(messages.length) // Step 3: Simulate rewind by removing truncation marker and subsequent messages @@ -259,7 +260,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { const cleanedAfterRewind = cleanupAfterTruncation(messagesAfterRewind) // Step 5: Get effective history after cleanup - const effectiveAfterRewind = getEffectiveApiHistory(cleanedAfterRewind as any) + const effectiveAfterRewind = getEffectiveApiHistory(cleanedAfterRewind) // All original messages before the marker should be restored expect(effectiveAfterRewind.length).toBe(markerIndex) @@ -275,8 +276,8 @@ describe("Non-Destructive Sliding Window Truncation", () => { const firstTruncation = truncateConversation(messages, 0.5, "task-1") // Step 2: Get effective history and simulate more messages being added - const effectiveAfterFirst = getEffectiveApiHistory(firstTruncation.messages as any) - const moreMessages: any[] = [ + const effectiveAfterFirst = getEffectiveApiHistory(firstTruncation.messages) + const moreMessages: ApiMessage[] = [ ...firstTruncation.messages, { role: "user", content: "New message 1", ts: 3000 }, { role: "assistant", content: "New response 1", ts: 3100 }, @@ -288,7 +289,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { const secondTruncation = truncateConversation(moreMessages, 0.5, "task-1") // Step 4: Get effective history after second truncation - const effectiveAfterSecond = getEffectiveApiHistory(secondTruncation.messages as any) + const effectiveAfterSecond = getEffectiveApiHistory(secondTruncation.messages) // Should have messages hidden by both truncations filtered out const firstMarker = secondTruncation.messages.find( @@ -318,8 +319,8 @@ describe("Non-Destructive Sliding Window Truncation", () => { // Step 2: Add more messages AFTER getting effective history // This simulates real usage where we only send effective messages to API - const effectiveAfterFirst = getEffectiveApiHistory(firstTruncation.messages as any) - const moreMessages: any[] = [ + const effectiveAfterFirst = getEffectiveApiHistory(firstTruncation.messages) + const moreMessages: ApiMessage[] = [ ...firstTruncation.messages, // Keep full history with tagged messages { role: "user", content: "New message 1", ts: 3000 }, { role: "assistant", content: "New response 1", ts: 3100 }, @@ -340,7 +341,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { const cleaned = cleanupAfterTruncation(afterSecondRewind) // Step 6: Get effective history - const effective = getEffectiveApiHistory(cleaned as any) + const effective = getEffectiveApiHistory(cleaned) // The second truncation marker should be removed const hasSecondTruncationMarker = effective.some( @@ -375,7 +376,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { }) it("should handle truncateConversation with very few messages", () => { - const fewMessages: any[] = [ + const fewMessages: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1000 }, { role: "assistant", content: "Response", ts: 1100 }, ] @@ -391,7 +392,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { it("should handle truncating all visible messages except first", () => { // This tests the edge case where visibleIndices[messagesToRemove + 1] would be undefined // 3 messages total: first is preserved, 2 others can be truncated - const threeMessages: any[] = [ + const threeMessages: ApiMessage[] = [ { role: "user", content: "Initial", ts: 1000 }, { role: "assistant", content: "Response 1", ts: 1100 }, { role: "user", content: "Message 2", ts: 1200 }, @@ -410,7 +411,7 @@ describe("Non-Destructive Sliding Window Truncation", () => { // First message should be untouched expect(result.messages[0].truncationParent).toBeUndefined() - expect((result.messages[0] as any).content).toBe("Initial") + expect(result.messages[0].content).toBe("Initial") // Messages at indices 1 and 2 should be tagged expect(result.messages[1].truncationParent).toBe(result.truncationId) @@ -418,11 +419,11 @@ describe("Non-Destructive Sliding Window Truncation", () => { // Marker should be at the end (index 3) expect(result.messages[3].isTruncationMarker).toBe(true) - expect((result.messages[3] as any).role).toBe("user") + expect(result.messages[3].role).toBe("user") }) it("should handle empty condenseParent and truncationParent gracefully", () => { - const messagesWithoutTags: any[] = [ + const messagesWithoutTags: ApiMessage[] = [ { role: "user", content: "Message 1", ts: 1000 }, { role: "assistant", content: "Response 1", ts: 1100 }, ] diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 7f177687aa2..243d7bd797f 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -1,11 +1,11 @@ +import { Anthropic } from "@anthropic-ai/sdk" import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense" -import type { RooMessage, ContentBlockParam } from "../task-persistence/rooMessage" -import { isRooRoleMessage } from "../task-persistence/rooMessage" +import { ApiMessage } from "../task-persistence/apiMessages" import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -28,22 +28,23 @@ export const TOKEN_BUFFER_PERCENTAGE = 0.1 /** * Counts tokens for user content using the provider's token counting implementation. * - * @param content - The content to count tokens for - * @param apiHandler - The API handler to use for token counting - * @returns A promise resolving to the token count + * @param {Array} content - The content to count tokens for + * @param {ApiHandler} apiHandler - The API handler to use for token counting + * @returns {Promise} A promise resolving to the token count */ -export async function estimateTokenCount(content: ContentBlockParam[], apiHandler: ApiHandler): Promise { +export async function estimateTokenCount( + content: Array, + apiHandler: ApiHandler, +): Promise { if (!content || content.length === 0) return 0 - // countTokens accepts Anthropic.Messages.ContentBlockParam[] — our { type, text? } - // blocks are structurally compatible with TextBlockParam. - return apiHandler.countTokens(content as Parameters[0]) + return apiHandler.countTokens(content) } /** * Result of truncation operation, includes the truncation ID for UI events. */ export type TruncationResult = { - messages: RooMessage[] + messages: ApiMessage[] truncationId: string messagesRemoved: number } @@ -58,12 +59,12 @@ export type TruncationResult = { * This implements non-destructive sliding window truncation, allowing messages to be * restored if the user rewinds past the truncation point. * - * @param {RooMessage[]} messages - The conversation messages. + * @param {ApiMessage[]} messages - The conversation messages. * @param {number} fracToRemove - The fraction (between 0 and 1) of messages (excluding the first) to hide. * @param {string} taskId - The task ID for the conversation, used for telemetry * @returns {TruncationResult} Object containing the tagged messages, truncation ID, and count of messages removed. */ -export function truncateConversation(messages: RooMessage[], fracToRemove: number, taskId: string): TruncationResult { +export function truncateConversation(messages: ApiMessage[], fracToRemove: number, taskId: string): TruncationResult { TelemetryService.instance.captureSlidingWindowTruncation(taskId) const truncationId = crypto.randomUUID() @@ -109,7 +110,7 @@ export function truncateConversation(messages: RooMessage[], fracToRemove: numbe // Insert truncation marker at the actual boundary (between last truncated and first kept) const firstKeptTs = messages[firstKeptVisibleIndex]?.ts ?? Date.now() - const truncationMarker: RooMessage = { + const truncationMarker: ApiMessage = { role: "user", content: `[Sliding window truncation: ${messagesToRemove} messages hidden to reduce context]`, ts: firstKeptTs - 1, @@ -202,11 +203,11 @@ export function willManageContext({ * Falls back to sliding window truncation if condensation is unavailable or fails. * * @param {ContextManagementOptions} options - The options for truncation/condensation - * @returns {Promise} The original, condensed, or truncated conversation messages. + * @returns {Promise} The original, condensed, or truncated conversation messages. */ export type ContextManagementOptions = { - messages: RooMessage[] + messages: ApiMessage[] totalTokens: number contextWindow: number maxTokens?: number | null @@ -241,7 +242,7 @@ export type ContextManagementResult = SummarizeResponse & { * Conditionally manages conversation context (condense and fallback truncation). * * @param {ContextManagementOptions} options - The options for truncation/condensation - * @returns {Promise} The original, condensed, or truncated conversation messages. + * @returns {Promise} The original, condensed, or truncated conversation messages. */ export async function manageContext({ messages, @@ -270,9 +271,9 @@ export async function manageContext({ // Estimate tokens for the last message (which is always a user message) const lastMessage = messages[messages.length - 1] - const lastMessageContent = isRooRoleMessage(lastMessage) ? lastMessage.content : "" + const lastMessageContent = lastMessage.content const lastMessageTokens = Array.isArray(lastMessageContent) - ? await estimateTokenCount(lastMessageContent as ContentBlockParam[], apiHandler) + ? await estimateTokenCount(lastMessageContent, apiHandler) : await estimateTokenCount([{ type: "text", text: lastMessageContent as string }], apiHandler) // Calculate total effective tokens (totalTokens never includes the last message) @@ -347,9 +348,9 @@ export async function manageContext({ ) for (const msg of effectiveMessages) { - const content = isRooRoleMessage(msg) ? msg.content : undefined + const content = msg.content if (Array.isArray(content)) { - newContextTokensAfterTruncation += await estimateTokenCount(content as ContentBlockParam[], apiHandler) + newContextTokensAfterTruncation += await estimateTokenCount(content, apiHandler) } else if (typeof content === "string") { newContextTokensAfterTruncation += await estimateTokenCount( [{ type: "text", text: content }], diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 018a4f2a7e8..7732cf279b4 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -74,7 +74,81 @@ describe("processUserContentMentions", () => { expect(result.mode).toBeUndefined() }) - it("should handle mixed content types (text + image)", async () => { + it("should process tool_result blocks with string content", async () => { + const userContent = [ + { + type: "tool_result" as const, + tool_use_id: "123", + content: "Tool feedback", + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(parseMentions).toHaveBeenCalled() + // String content is now converted to array format to support content blocks + expect(result.content[0]).toEqual({ + type: "tool_result", + tool_use_id: "123", + content: [ + { + type: "text", + text: "parsed: Tool feedback", + }, + ], + }) + expect(result.mode).toBeUndefined() + }) + + it("should process tool_result blocks with array content", async () => { + const userContent = [ + { + type: "tool_result" as const, + tool_use_id: "123", + content: [ + { + type: "text" as const, + text: "Array task", + }, + { + type: "text" as const, + text: "Regular text", + }, + ], + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(parseMentions).toHaveBeenCalledTimes(1) + expect(result.content[0]).toEqual({ + type: "tool_result", + tool_use_id: "123", + content: [ + { + type: "text", + text: "parsed: Array task", + }, + { + type: "text", + text: "Regular text", + }, + ], + }) + expect(result.mode).toBeUndefined() + }) + + it("should handle mixed content types", async () => { const userContent = [ { type: "text" as const, @@ -82,25 +156,44 @@ describe("processUserContentMentions", () => { }, { type: "image" as const, - image: "base64data", - mediaType: "image/png", + source: { + type: "base64" as const, + media_type: "image/png" as const, + data: "base64data", + }, + }, + { + type: "tool_result" as const, + tool_use_id: "456", + content: "Feedback", }, ] const result = await processUserContentMentions({ - userContent: userContent as any, + userContent, cwd: "/test", urlContentFetcher: mockUrlContentFetcher, fileContextTracker: mockFileContextTracker, }) - expect(parseMentions).toHaveBeenCalledTimes(1) - expect(result.content).toHaveLength(2) + expect(parseMentions).toHaveBeenCalledTimes(2) + expect(result.content).toHaveLength(3) expect(result.content[0]).toEqual({ type: "text", text: "parsed: First task", }) expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged + // String content is now converted to array format to support content blocks + expect(result.content[2]).toEqual({ + type: "tool_result", + tool_use_id: "456", + content: [ + { + type: "text", + text: "parsed: Feedback", + }, + ], + }) expect(result.mode).toBeUndefined() }) }) @@ -195,5 +288,90 @@ describe("processUserContentMentions", () => { text: "command help", }) }) + + it("should include slash command content in tool_result string content", async () => { + vi.mocked(parseMentions).mockResolvedValueOnce({ + text: "parsed tool output", + slashCommandHelp: "command help", + mode: undefined, + contentBlocks: [], + }) + + const userContent = [ + { + type: "tool_result" as const, + tool_use_id: "123", + content: "Tool output", + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ + type: "tool_result", + tool_use_id: "123", + content: [ + { + type: "text", + text: "parsed tool output", + }, + { + type: "text", + text: "command help", + }, + ], + }) + }) + + it("should include slash command content in tool_result array content", async () => { + vi.mocked(parseMentions).mockResolvedValueOnce({ + text: "parsed array item", + slashCommandHelp: "command help", + mode: undefined, + contentBlocks: [], + }) + + const userContent = [ + { + type: "tool_result" as const, + tool_use_id: "123", + content: [ + { + type: "text" as const, + text: "Array item", + }, + ], + }, + ] + + const result = await processUserContentMentions({ + userContent, + cwd: "/test", + urlContentFetcher: mockUrlContentFetcher, + fileContextTracker: mockFileContextTracker, + }) + + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ + type: "tool_result", + tool_use_id: "123", + content: [ + { + type: "text", + text: "parsed array item", + }, + { + type: "text", + text: "command help", + }, + ], + }) + }) }) }) diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 962eb37c30d..d27f2cae66a 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -1,19 +1,19 @@ -import type { TextPart, ImagePart } from "../task-persistence/rooMessage" +import { Anthropic } from "@anthropic-ai/sdk" import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" import { FileContextTracker } from "../context-tracking/FileContextTracker" export interface ProcessUserContentMentionsResult { - content: Array + content: Anthropic.Messages.ContentBlockParam[] mode?: string // Mode from the first slash command that has one } /** - * Converts MentionContentBlocks to TextPart blocks. + * Converts MentionContentBlocks to Anthropic text blocks. * Each file/folder mention becomes a separate text block formatted * to look like a read_file tool result. */ -function contentBlocksToTextParts(contentBlocks: MentionContentBlock[]): TextPart[] { +function contentBlocksToAnthropicBlocks(contentBlocks: MentionContentBlock[]): Anthropic.Messages.TextBlockParam[] { return contentBlocks.map((block) => ({ type: "text" as const, text: block.content, @@ -37,7 +37,7 @@ export async function processUserContentMentions({ includeDiagnosticMessages = true, maxDiagnosticMessages = 50, }: { - userContent: Array + userContent: Anthropic.Messages.ContentBlockParam[] cwd: string urlContentFetcher: UrlContentFetcher fileContextTracker: FileContextTracker @@ -49,8 +49,13 @@ export async function processUserContentMentions({ // Track the first mode found from slash commands let commandMode: string | undefined - // Process userContent array, which contains text and image parts. - // We need to apply parseMentions() to TextPart's text that contains "". + // Process userContent array, which contains various block types: + // TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam. + // We need to apply parseMentions() to: + // 1. All TextBlockParam's text (first user message) + // 2. ToolResultBlockParam's content/context text arrays if it contains + // "" - we place all user generated content in this tag + // so it can effectively be used as a marker for when we should parse mentions. const content = ( await Promise.all( userContent.map(async (block) => { @@ -77,7 +82,7 @@ export async function processUserContentMentions({ // 1. User's text (with @ mentions replaced by clean paths) // 2. File/folder content blocks (formatted like read_file results) // 3. Slash command help (if any) - const blocks: Array = [ + const blocks: Anthropic.Messages.ContentBlockParam[] = [ { ...block, text: result.text, @@ -86,7 +91,7 @@ export async function processUserContentMentions({ // Add file/folder content as separate blocks if (result.contentBlocks.length > 0) { - blocks.push(...contentBlocksToTextParts(result.contentBlocks)) + blocks.push(...contentBlocksToAnthropicBlocks(result.contentBlocks)) } if (result.slashCommandHelp) { @@ -99,11 +104,107 @@ export async function processUserContentMentions({ } return block - } + } else if (block.type === "tool_result") { + if (typeof block.content === "string") { + if (shouldProcessMentions(block.content)) { + const result = await parseMentions( + block.content, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } + + // Build content array with file blocks included + const contentParts: Array<{ type: "text"; text: string }> = [ + { + type: "text" as const, + text: result.text, + }, + ] + + // Add file/folder content blocks + for (const contentBlock of result.contentBlocks) { + contentParts.push({ + type: "text" as const, + text: contentBlock.content, + }) + } + + if (result.slashCommandHelp) { + contentParts.push({ + type: "text" as const, + text: result.slashCommandHelp, + }) + } + + return { + ...block, + content: contentParts, + } + } + + return block + } else if (Array.isArray(block.content)) { + const parsedContent = ( + await Promise.all( + block.content.map(async (contentBlock) => { + if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { + const result = await parseMentions( + contentBlock.text, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } + + // Build blocks array with file content + const blocks: Array<{ type: "text"; text: string }> = [ + { + ...contentBlock, + text: result.text, + }, + ] + + // Add file/folder content blocks + for (const cb of result.contentBlocks) { + blocks.push({ + type: "text" as const, + text: cb.content, + }) + } + + if (result.slashCommandHelp) { + blocks.push({ + type: "text" as const, + text: result.slashCommandHelp, + }) + } + return blocks + } + + return contentBlock + }), + ) + ).flat() + + return { ...block, content: parsedContent } + } - // Legacy backward compat: filter out any tool_result / tool-result blocks - // that may still exist in persisted data from older formats. - if ((block as any).type === "tool_result" || (block as any).type === "tool-result") { return block } @@ -112,5 +213,5 @@ export async function processUserContentMentions({ ) ).flat() - return { content: content as Array, mode: commandMode } + return { content, mode: commandMode } } diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index 71a5f3ae2de..4b68be0825c 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -168,7 +168,7 @@ export class MessageManager { // at or after the cutoff to use as the actual boundary. // This ensures assistant messages that preceded the user's response are preserved. const firstUserMsgIndexToRemove = apiHistory.findIndex( - (m) => m.ts !== undefined && m.ts >= cutoffTs && "role" in m && m.role === "user", + (m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user", ) if (firstUserMsgIndexToRemove !== -1) { diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 96950f8c261..60b5b4123ac 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -1,6 +1,6 @@ +import { Anthropic } from "@anthropic-ai/sdk" import * as path from "path" import * as diff from "diff" -import type { TextPart, ImagePart } from "../task-persistence/rooMessage" import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" @@ -96,10 +96,13 @@ Otherwise, if you have not completed the task and do not need additional informa available_servers: availableServers.length > 0 ? availableServers : [], }), - toolResult: (text: string, images?: string[]): string | Array => { + toolResult: ( + text: string, + images?: string[], + ): string | Array => { if (images && images.length > 0) { - const textBlock: TextPart = { type: "text", text } - const imageBlocks: ImagePart[] = formatImagesIntoBlocks(images) + const textBlock: Anthropic.TextBlockParam = { type: "text", text } + const imageBlocks: Anthropic.ImageBlockParam[] = formatImagesIntoBlocks(images) // Placing images after text leads to better results return [textBlock, ...imageBlocks] } else { @@ -107,7 +110,7 @@ Otherwise, if you have not completed the task and do not need additional informa } }, - imageBlocks: (images?: string[]): ImagePart[] => { + imageBlocks: (images?: string[]): Anthropic.ImageBlockParam[] => { return formatImagesIntoBlocks(images) }, @@ -199,17 +202,16 @@ Otherwise, if you have not completed the task and do not need additional informa } // to avoid circular dependency -const formatImagesIntoBlocks = (images?: string[]): ImagePart[] => { +const formatImagesIntoBlocks = (images?: string[]): Anthropic.ImageBlockParam[] => { return images ? images.map((dataUrl) => { // data:image/png;base64,base64string const [rest, base64] = dataUrl.split(",") const mimeType = rest.split(":")[1].split(";")[0] return { - type: "image" as const, - image: base64, - mediaType: mimeType, - } + type: "image", + source: { type: "base64", media_type: mimeType, data: base64 }, + } as Anthropic.ImageBlockParam }) : [] } diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 65e0cc1dc86..fca89d965de 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -4,35 +4,7 @@ export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage" export type { RooUserMessage, RooAssistantMessage, RooToolMessage, RooReasoningMessage } from "./rooMessage" -export type { RooRoleMessage } from "./rooMessage" -export { - isRooUserMessage, - isRooAssistantMessage, - isRooToolMessage, - isRooReasoningMessage, - isRooRoleMessage, -} from "./rooMessage" -export type { - TextPart, - ImagePart, - FilePart, - ToolCallPart, - ToolResultPart, - ReasoningPart, - UserContentPart, - ContentBlockParam, -} from "./rooMessage" -export type { LegacyToolUseBlock, LegacyToolResultBlock, AnyToolCallBlock, AnyToolResultBlock } from "./rooMessage" -export { - isAnyToolCallBlock, - isAnyToolResultBlock, - getToolCallId, - getToolCallName, - getToolCallInput, - getToolResultCallId, - getToolResultContent, - getToolResultIsError, - setToolResultCallId, -} from "./rooMessage" +export { isRooUserMessage, isRooAssistantMessage, isRooToolMessage, isRooReasoningMessage } from "./rooMessage" +export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart, ReasoningPart } from "./rooMessage" export { convertAnthropicToRooMessages } from "./converters/anthropicToRoo" export { flattenModelMessagesToStringContent } from "./messageUtils" diff --git a/src/core/task-persistence/rooMessage.ts b/src/core/task-persistence/rooMessage.ts index 81c75f8974a..a94cc32ebe6 100644 --- a/src/core/task-persistence/rooMessage.ts +++ b/src/core/task-persistence/rooMessage.ts @@ -13,20 +13,6 @@ import type { UserModelMessage, AssistantModelMessage, ToolModelMessage, Assista // Re-export AI SDK content part types for convenience export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart } from "ai" -import type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart } from "ai" - -/** - * Union of content parts that can appear in a user message's content array. - */ -export type UserContentPart = TextPart | ImagePart | FilePart - -/** - * A minimal content block with a type discriminator and optional text. - * Structurally compatible with Anthropic's `TextBlockParam` (which `countTokens` accepts) - * without importing provider-specific types. - */ -export type ContentBlockParam = { type: string; text?: string } - /** * `ReasoningPart` is used by the AI SDK in `AssistantContent` but is not directly * exported from `"ai"`. We extract it from the `AssistantContent` union to get the @@ -115,12 +101,6 @@ export interface RooReasoningMessage extends RooMessageMetadata { */ export type RooMessage = RooUserMessage | RooAssistantMessage | RooToolMessage | RooReasoningMessage -/** - * Union of RooMessage types that have a `role` property (i.e. everything except - * {@link RooReasoningMessage}). Useful for narrowing before accessing `.role` or `.content`. - */ -export type RooRoleMessage = RooUserMessage | RooAssistantMessage | RooToolMessage - // ──────────────────────────────────────────────────────────────────────────── // Storage Wrapper // ──────────────────────────────────────────────────────────────────────────── @@ -170,123 +150,3 @@ export function isRooToolMessage(msg: RooMessage): msg is RooToolMessage { export function isRooReasoningMessage(msg: RooMessage): msg is RooReasoningMessage { return "type" in msg && (msg as RooReasoningMessage).type === "reasoning" && !("role" in msg) } - -/** - * Type guard that checks whether a message is a {@link RooRoleMessage} - * (i.e. any message with a `role` property — user, assistant, or tool). - */ -export function isRooRoleMessage(msg: RooMessage): msg is RooRoleMessage { - return "role" in msg -} - -// ──────────────────────────────────────────────────────────────────────────── -// Content Part Type Guards -// ──────────────────────────────────────────────────────────────────────────── - -/** Type guard for AI SDK `TextPart` content blocks. */ -export function isTextPart(part: { type: string }): part is TextPart { - return part.type === "text" -} - -/** Type guard for AI SDK `ToolCallPart` content blocks. */ -export function isToolCallPart(part: { type: string }): part is ToolCallPart { - return part.type === "tool-call" -} - -/** Type guard for AI SDK `ToolResultPart` content blocks. */ -export function isToolResultPart(part: { type: string }): part is ToolResultPart { - return part.type === "tool-result" -} - -/** Type guard for AI SDK `ImagePart` content blocks. */ -export function isImagePart(part: { type: string }): part is ImagePart { - return part.type === "image" -} - -// ──────────────────────────────────────────────────────────────────────────── -// Legacy (Anthropic) Block Types — for dual-format backward compatibility -// ──────────────────────────────────────────────────────────────────────────── - -/** Legacy Anthropic `tool_use` content block shape (persisted data from older versions). */ -export interface LegacyToolUseBlock { - type: "tool_use" - id: string - name: string - input: unknown -} - -/** Legacy Anthropic `tool_result` content block shape (persisted data from older versions). */ -export interface LegacyToolResultBlock { - type: "tool_result" - tool_use_id: string - content?: string | ContentBlockParam[] - is_error?: boolean -} - -/** Union of AI SDK `ToolCallPart` and legacy Anthropic `tool_use` block. */ -export type AnyToolCallBlock = ToolCallPart | LegacyToolUseBlock - -/** Union of AI SDK `ToolResultPart` and legacy Anthropic `tool_result` block. */ -export type AnyToolResultBlock = ToolResultPart | LegacyToolResultBlock - -// ──────────────────────────────────────────────────────────────────────────── -// Dual-Format Type Guards -// ──────────────────────────────────────────────────────────────────────────── - -/** Type guard matching both AI SDK `tool-call` and legacy Anthropic `tool_use` blocks. */ -export function isAnyToolCallBlock(block: { type: string }): block is AnyToolCallBlock { - return block.type === "tool-call" || block.type === "tool_use" -} - -/** Type guard matching both AI SDK `tool-result` and legacy Anthropic `tool_result` blocks. */ -export function isAnyToolResultBlock(block: { type: string }): block is AnyToolResultBlock { - return block.type === "tool-result" || block.type === "tool_result" -} - -// ──────────────────────────────────────────────────────────────────────────── -// Dual-Format Accessor Helpers -// ──────────────────────────────────────────────────────────────────────────── - -/** Get the tool call ID from either format. */ -export function getToolCallId(block: AnyToolCallBlock): string { - return block.type === "tool-call" ? block.toolCallId : block.id -} - -/** Get the tool name from either format. */ -export function getToolCallName(block: AnyToolCallBlock): string { - return block.type === "tool-call" ? block.toolName : block.name -} - -/** Get the tool call arguments/input from either format. */ -export function getToolCallInput(block: AnyToolCallBlock): unknown { - return block.input -} - -/** Get the referenced tool call ID from a tool result in either format. */ -export function getToolResultCallId(block: AnyToolResultBlock): string { - return block.type === "tool-result" ? block.toolCallId : block.tool_use_id -} - -/** Get the tool result content/output from either format. */ -export function getToolResultContent(block: AnyToolResultBlock): unknown { - if (block.type === "tool-result") { - return block.output - } - return block.content -} - -/** Get the error flag from a tool result in either format. */ -export function getToolResultIsError(block: AnyToolResultBlock): boolean | undefined { - if (block.type === "tool-result") { - return undefined // AI SDK ToolResultPart has no isError field - } - return block.is_error -} - -/** Set the tool result's reference to a tool call ID, returning a new block. */ -export function setToolResultCallId(block: AnyToolResultBlock, id: string): AnyToolResultBlock { - if (block.type === "tool-result") { - return { ...block, toolCallId: id } - } - return { ...block, tool_use_id: id } -} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e8b2b1302b2..9d27c4b90b0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -7,7 +7,6 @@ import EventEmitter from "events" import { AskIgnoredError } from "./AskIgnoredError" -// Note: Anthropic SDK import retained for types used by the API handler interface import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import debounce from "lodash.debounce" @@ -117,29 +116,6 @@ import { readTaskMessages, saveTaskMessages, taskMetadata, - type RooMessage, - type RooUserMessage, - type RooAssistantMessage, - type RooToolMessage, - type RooReasoningMessage, - type TextPart, - type ImagePart, - type ToolCallPart, - type ToolResultPart, - type UserContentPart, - type AnyToolCallBlock, - type AnyToolResultBlock, - isRooUserMessage, - isRooAssistantMessage, - isRooToolMessage, - isRooReasoningMessage, - isRooRoleMessage, - isAnyToolResultBlock, - getToolCallId, - getToolCallName, - getToolResultContent, - readRooMessages, - saveRooMessages, } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" @@ -337,7 +313,7 @@ export class Task extends EventEmitter implements TaskLike { didEditFile: boolean = false // LLM Messages & Chat Messages - apiConversationHistory: RooMessage[] = [] + apiConversationHistory: ApiMessage[] = [] clineMessages: ClineMessage[] = [] // Ask @@ -377,9 +353,8 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageContent: AssistantMessageContent[] = [] presentAssistantMessageLocked = false presentAssistantMessageHasPendingUpdates = false - userMessageContent: Array = [] + userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false - pendingToolResults: Array = [] /** * Flag indicating whether the assistant message for the current streaming session @@ -396,24 +371,24 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageSavedToHistory = false /** - * Push a tool result to pendingToolResults, preventing duplicates. - * Duplicate toolCallIds cause API errors. + * Push a tool_result block to userMessageContent, preventing duplicates. + * Duplicate tool_use_ids cause API errors. * - * @param toolResult - The ToolResultPart to add + * @param toolResult - The tool_result block to add * @returns true if added, false if duplicate was skipped */ - public pushToolResultToUserContent(toolResult: ToolResultPart): boolean { - const existingResult = this.pendingToolResults.find( - (block): block is ToolResultPart => - block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, + public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean { + const existingResult = this.userMessageContent.find( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, ) if (existingResult) { console.warn( - `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for toolCallId: ${toolResult.toolCallId}`, + `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`, ) return false } - this.pendingToolResults.push(toolResult) + this.userMessageContent.push(toolResult) return true } @@ -1036,18 +1011,11 @@ export class Task extends EventEmitter implements TaskLike { // API Messages - private async getSavedApiConversationHistory(): Promise { - return readRooMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) + private async getSavedApiConversationHistory(): Promise { + return readApiMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } - private async addToApiConversationHistory(message: RooMessage, reasoning?: string) { - // Handle RooReasoningMessage (has `type` instead of `role`) - if (!("role" in message)) { - this.apiConversationHistory.push({ ...message, ts: message.ts ?? Date.now() }) - await this.saveApiConversationHistory() - return - } - + private async addToApiConversationHistory(message: Anthropic.MessageParam, reasoning?: string) { // Capture the encrypted_content / thought signatures from the provider (e.g., OpenAI Responses API, Google GenAI) if present. // We only persist data reported by the current response body. const handler = this.api as ApiHandler & { @@ -1077,10 +1045,8 @@ export class Task extends EventEmitter implements TaskLike { ) const isAnthropicProtocol = apiProtocol === "anthropic" - // Start from the original assistant message. - // Use `any` for the content type because we store custom block types - // (thinking, redacted_thinking, thoughtSignature) that aren't part of the AI SDK's AssistantContent. - const messageWithTs: RooAssistantMessage & { reasoning_details?: any[]; content: any } = { + // Start from the original assistant message + const messageWithTs: any = { ...message, ...(responseId ? { id: responseId } : {}), ts: Date.now(), @@ -1104,7 +1070,10 @@ export class Task extends EventEmitter implements TaskLike { } if (typeof messageWithTs.content === "string") { - messageWithTs.content = [thinkingBlock, { type: "text", text: messageWithTs.content } as TextPart] + messageWithTs.content = [ + thinkingBlock, + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + ] } else if (Array.isArray(messageWithTs.content)) { messageWithTs.content = [thinkingBlock, ...messageWithTs.content] } else if (!messageWithTs.content) { @@ -1128,7 +1097,10 @@ export class Task extends EventEmitter implements TaskLike { } if (typeof messageWithTs.content === "string") { - messageWithTs.content = [reasoningBlock, { type: "text", text: messageWithTs.content } as TextPart] + messageWithTs.content = [ + reasoningBlock, + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + ] } else if (Array.isArray(messageWithTs.content)) { messageWithTs.content = [reasoningBlock, ...messageWithTs.content] } else if (!messageWithTs.content) { @@ -1144,7 +1116,10 @@ export class Task extends EventEmitter implements TaskLike { } if (typeof messageWithTs.content === "string") { - messageWithTs.content = [reasoningBlock, { type: "text", text: messageWithTs.content } as TextPart] + messageWithTs.content = [ + reasoningBlock, + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + ] } else if (Array.isArray(messageWithTs.content)) { messageWithTs.content = [reasoningBlock, ...messageWithTs.content] } else if (!messageWithTs.content) { @@ -1163,7 +1138,7 @@ export class Task extends EventEmitter implements TaskLike { if (typeof messageWithTs.content === "string") { messageWithTs.content = [ - { type: "text", text: messageWithTs.content } as TextPart, + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, thoughtSignatureBlock, ] } else if (Array.isArray(messageWithTs.content)) { @@ -1175,43 +1150,37 @@ export class Task extends EventEmitter implements TaskLike { this.apiConversationHistory.push(messageWithTs) } else { - // For user/tool messages, validate tool_result IDs ONLY when the immediately previous - // *effective* message is an assistant message. + // For user messages, validate tool_result IDs ONLY when the immediately previous *effective* message + // is an assistant message. + // + // If the previous effective message is also a user message (e.g., summary + a new user message), + // validating against any earlier assistant message can incorrectly inject placeholder tool_results. const effectiveHistoryForValidation = getEffectiveApiHistory(this.apiConversationHistory) const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1] - const lastIsAssistant = lastEffective ? isRooAssistantMessage(lastEffective) : false - const historyForValidation = lastIsAssistant ? effectiveHistoryForValidation : [] + const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : [] // If the previous effective message is NOT an assistant, convert tool_result blocks to text blocks. - let messageToAdd: RooMessage = message - if (!lastIsAssistant && isRooRoleMessage(message) && Array.isArray(message.content)) { + // This prevents orphaned tool_results from being filtered out by getEffectiveApiHistory. + // This can happen when condensing occurs after the assistant sends tool_uses but before + // the user responds - the tool_use blocks get condensed away, leaving orphaned tool_results. + let messageToAdd = message + if (lastEffective?.role !== "assistant" && Array.isArray(message.content)) { messageToAdd = { ...message, - content: (message.content as Array<{ type: string }>).map((block) => - isAnyToolResultBlock(block) + content: message.content.map((block) => + block.type === "tool_result" ? { type: "text" as const, - text: `Tool result:\n${(() => { - const raw = getToolResultContent(block) - if (typeof raw === "string") return raw - if ( - raw && - typeof raw === "object" && - "value" in raw && - typeof (raw as { value: unknown }).value === "string" - ) - return (raw as { value: string }).value - return JSON.stringify(raw) - })()}`, + text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`, } : block, ), - } as RooMessage + } } const validatedMessage = validateAndFixToolResultIds(messageToAdd, historyForValidation) const messageWithTs = { ...validatedMessage, ts: Date.now() } - this.apiConversationHistory.push(messageWithTs as RooMessage) + this.apiConversationHistory.push(messageWithTs) } await this.saveApiConversationHistory() @@ -1221,7 +1190,7 @@ export class Task extends EventEmitter implements TaskLike { // For API requests, consecutive same-role messages are merged via mergeConsecutiveApiMessages() // so rewind/edit behavior can still reference original message boundaries. - async overwriteApiConversationHistory(newHistory: RooMessage[]) { + async overwriteApiConversationHistory(newHistory: ApiMessage[]) { this.apiConversationHistory = newHistory await this.saveApiConversationHistory() } @@ -1243,7 +1212,7 @@ export class Task extends EventEmitter implements TaskLike { */ public async flushPendingToolResultsToHistory(): Promise { // Only flush if there's actually pending content to save - if (this.userMessageContent.length === 0 && this.pendingToolResults.length === 0) { + if (this.userMessageContent.length === 0) { return true } @@ -1277,31 +1246,25 @@ export class Task extends EventEmitter implements TaskLike { return false } - // Save pending tool results as a RooToolMessage - if (this.pendingToolResults.length > 0) { - const toolMessage: RooToolMessage = { - role: "tool", - content: [...this.pendingToolResults], - ts: Date.now(), - } - this.apiConversationHistory.push(toolMessage) + // Save the user message with tool_result blocks + const userMessage: Anthropic.MessageParam = { + role: "user", + content: this.userMessageContent, } - // Save any text/image user content as a RooUserMessage - if (this.userMessageContent.length > 0) { - const userMessage: RooUserMessage = { - role: "user", - content: [...this.userMessageContent], - ts: Date.now(), - } - this.apiConversationHistory.push(userMessage) - } + // Validate and fix tool_result IDs when the previous *effective* message is an assistant message. + const effectiveHistoryForValidation = getEffectiveApiHistory(this.apiConversationHistory) + const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1] + const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : [] + const validatedMessage = validateAndFixToolResultIds(userMessage, historyForValidation) + const userMessageWithTs = { ...validatedMessage, ts: Date.now() } + this.apiConversationHistory.push(userMessageWithTs as ApiMessage) const saved = await this.saveApiConversationHistory() if (saved) { + // Clear the pending content since it's now saved this.userMessageContent = [] - this.pendingToolResults = [] } else { console.warn( `[Task#${this.taskId}] flushPendingToolResultsToHistory: save failed, retaining pending tool results in memory`, @@ -1313,7 +1276,7 @@ export class Task extends EventEmitter implements TaskLike { private async saveApiConversationHistory(): Promise { try { - await saveRooMessages({ + await saveApiMessages({ messages: structuredClone(this.apiConversationHistory), taskId: this.taskId, globalStoragePath: this.globalStoragePath, @@ -1923,7 +1886,7 @@ export class Task extends EventEmitter implements TaskLike { ) return } - await this.overwriteApiConversationHistory(messages as RooMessage[]) + await this.overwriteApiConversationHistory(messages) const contextCondense: ContextCondense = { summary, @@ -2181,7 +2144,7 @@ export class Task extends EventEmitter implements TaskLike { } this.isInitialized = true - const imageBlocks: ImagePart[] = formatResponse.imageBlocks(images) + const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) // Task starting await this.initiateTaskLoop([ @@ -2297,136 +2260,91 @@ export class Task extends EventEmitter implements TaskLike { // Make sure that the api conversation history can be resumed by the API, // even if it goes out of sync with cline messages. - const existingApiConversationHistory: RooMessage[] = await this.getSavedApiConversationHistory() - - // If the last message is an assistant message with tool calls, every tool call - // needs a corresponding tool result. Create a RooToolMessage with "interrupted" - // results for any missing ones. - // If the last message is a user message, check the preceding assistant for - // unmatched tool calls and fill in missing tool results. - // In RooMessage format, tool results live in RooToolMessage (not in user messages). - - let modifiedOldUserContent: UserContentPart[] - let modifiedApiConversationHistory: RooMessage[] + let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory() - if (existingApiConversationHistory.length > 0) { - // Find the last message that has a role (skip RooReasoningMessage items) - let lastMsgIndex = existingApiConversationHistory.length - 1 - while (lastMsgIndex >= 0 && isRooReasoningMessage(existingApiConversationHistory[lastMsgIndex])) { - lastMsgIndex-- - } - - if (lastMsgIndex < 0) { - throw new Error("Unexpected: No user or assistant messages in API conversation history") - } + // Tool blocks are always preserved; native tool calling only. - const lastMessage = existingApiConversationHistory[lastMsgIndex] + // if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response + // if there's no tool use and only a text block, then we can just add a user message + // (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks) - if (isRooAssistantMessage(lastMessage)) { - const content = Array.isArray(lastMessage.content) ? lastMessage.content : [] - const toolCallParts = content.filter((part): part is ToolCallPart => part.type === "tool-call") + // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted' - if (toolCallParts.length > 0) { - const toolResults: ToolResultPart[] = toolCallParts.map((tc) => ({ - type: "tool-result" as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - output: { - type: "text" as const, - value: "Task was interrupted before this tool call could be completed.", - }, + let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message + let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message + if (existingApiConversationHistory.length > 0) { + const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1] + + if (lastMessage.role === "assistant") { + const content = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text", text: lastMessage.content }] + const hasToolUse = content.some((block) => block.type === "tool_use") + + if (hasToolUse) { + const toolUseBlocks = content.filter( + (block) => block.type === "tool_use", + ) as Anthropic.Messages.ToolUseBlock[] + const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({ + type: "tool_result", + tool_use_id: block.id, + content: "Task was interrupted before this tool call could be completed.", })) - const toolMessage: RooToolMessage = { role: "tool", content: toolResults } - modifiedApiConversationHistory = [...existingApiConversationHistory, toolMessage] - modifiedOldUserContent = [] + modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes + modifiedOldUserContent = [...toolResponses] } else { modifiedApiConversationHistory = [...existingApiConversationHistory] modifiedOldUserContent = [] } - } else if (isRooUserMessage(lastMessage)) { - // Find the preceding assistant message (skip tool/reasoning messages) - let prevAssistantIndex = lastMsgIndex - 1 - while ( - prevAssistantIndex >= 0 && - !isRooAssistantMessage(existingApiConversationHistory[prevAssistantIndex]) - ) { - prevAssistantIndex-- - } - const previousAssistantMessage = - prevAssistantIndex >= 0 ? existingApiConversationHistory[prevAssistantIndex] : undefined - - // Extract existing user content for initiateTaskLoop - const existingUserContent: UserContentPart[] = Array.isArray(lastMessage.content) - ? (lastMessage.content as UserContentPart[]) - : [{ type: "text" as const, text: String(lastMessage.content) }] - - if (previousAssistantMessage && isRooAssistantMessage(previousAssistantMessage)) { + } else if (lastMessage.role === "user") { + const previousAssistantMessage: ApiMessage | undefined = + existingApiConversationHistory[existingApiConversationHistory.length - 2] + + const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text", text: lastMessage.content }] + if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { const assistantContent = Array.isArray(previousAssistantMessage.content) ? previousAssistantMessage.content - : [] - const toolCallParts = assistantContent.filter( - (part): part is ToolCallPart => part.type === "tool-call", - ) - - if (toolCallParts.length > 0) { - // Collect tool call IDs that already have results (in tool messages between assistant and user) - const answeredToolCallIds = new Set() - for (let i = prevAssistantIndex + 1; i < lastMsgIndex; i++) { - const msg = existingApiConversationHistory[i] - if (isRooToolMessage(msg) && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "tool-result") { - answeredToolCallIds.add((part as ToolResultPart).toolCallId) - } - } - } - } + : [{ type: "text", text: previousAssistantMessage.content }] - const missingToolCalls = toolCallParts.filter((tc) => !answeredToolCallIds.has(tc.toolCallId)) + const toolUseBlocks = assistantContent.filter( + (block) => block.type === "tool_use", + ) as Anthropic.Messages.ToolUseBlock[] - // Remove last user message; add missing tool results as a RooToolMessage - const historyWithoutLastUser = existingApiConversationHistory.slice(0, lastMsgIndex) + if (toolUseBlocks.length > 0) { + const existingToolResults = existingUserContent.filter( + (block) => block.type === "tool_result", + ) as Anthropic.ToolResultBlockParam[] - if (missingToolCalls.length > 0) { - const missingResults: ToolResultPart[] = missingToolCalls.map((tc) => ({ - type: "tool-result" as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - output: { - type: "text" as const, - value: "Task was interrupted before this tool call could be completed.", - }, + const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks + .filter( + (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id), + ) + .map((toolUse) => ({ + type: "tool_result", + tool_use_id: toolUse.id, + content: "Task was interrupted before this tool call could be completed.", })) - const toolMessage: RooToolMessage = { role: "tool", content: missingResults } - modifiedApiConversationHistory = [...historyWithoutLastUser, toolMessage] - } else { - modifiedApiConversationHistory = historyWithoutLastUser - } - // Strip any legacy tool_result / tool-result blocks from old user content - modifiedOldUserContent = existingUserContent.filter( - (block) => !isAnyToolResultBlock(block as { type: string }), - ) + modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message + modifiedOldUserContent = [...existingUserContent, ...missingToolResponses] } else { - modifiedApiConversationHistory = existingApiConversationHistory.slice(0, lastMsgIndex) + modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) modifiedOldUserContent = [...existingUserContent] } } else { - modifiedApiConversationHistory = existingApiConversationHistory.slice(0, lastMsgIndex) + modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) modifiedOldUserContent = [...existingUserContent] } - } else if (isRooToolMessage(lastMessage)) { - // Last message is a tool result — no user message was added yet - modifiedApiConversationHistory = [...existingApiConversationHistory] - modifiedOldUserContent = [] } else { - throw new Error("Unexpected: Last message is not a user, assistant, or tool message") + throw new Error("Unexpected: Last message is not a user or assistant message") } } else { throw new Error("Unexpected: No existing API conversation history") } - let newUserContent: UserContentPart[] = [...modifiedOldUserContent] + let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent] const agoText = ((): string => { const timestamp = lastClineMessage?.ts ?? Date.now() @@ -2709,25 +2627,26 @@ export class Task extends EventEmitter implements TaskLike { const environmentDetails = await getEnvironmentDetails(this, true) let lastUserMsgIndex = -1 for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { - const msg = this.apiConversationHistory[i] - if ("role" in msg && msg.role === "user") { + if (this.apiConversationHistory[i].role === "user") { lastUserMsgIndex = i break } } if (lastUserMsgIndex >= 0) { - const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] as any + const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] if (Array.isArray(lastUserMsg.content)) { // Remove any existing environment_details blocks before adding fresh ones - const contentWithoutEnvDetails = lastUserMsg.content.filter((block: any) => { - if (block.type === "text" && typeof block.text === "string") { - const isEnvironmentDetailsBlock = - block.text.trim().startsWith("") && - block.text.trim().endsWith("") - return !isEnvironmentDetailsBlock - } - return true - }) + const contentWithoutEnvDetails = lastUserMsg.content.filter( + (block: Anthropic.Messages.ContentBlockParam) => { + if (block.type === "text" && typeof block.text === "string") { + const isEnvironmentDetailsBlock = + block.text.trim().startsWith("") && + block.text.trim().endsWith("") + return !isEnvironmentDetailsBlock + } + return true + }, + ) // Add fresh environment details lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] } @@ -2743,7 +2662,7 @@ export class Task extends EventEmitter implements TaskLike { // Task Loop - private async initiateTaskLoop(userContent: UserContentPart[]): Promise { + private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { // Kicks off the checkpoints initialization process in the background. getCheckpointService(this) @@ -2778,11 +2697,11 @@ export class Task extends EventEmitter implements TaskLike { } public async recursivelyMakeClineRequests( - userContent: UserContentPart[], + userContent: Anthropic.Messages.ContentBlockParam[], includeFileDetails: boolean = false, ): Promise { interface StackItem { - userContent: UserContentPart[] + userContent: Anthropic.Messages.ContentBlockParam[] includeFileDetails: boolean retryAttempt?: number userMessageWasRemoved?: boolean // Track if user message was removed due to empty response @@ -2873,7 +2792,7 @@ export class Task extends EventEmitter implements TaskLike { } = (await this.providerRef.deref()?.getState()) ?? {} const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ - userContent: currentUserContent as Array, + userContent: currentUserContent, cwd: this.cwd, urlContentFetcher: this.urlContentFetcher, fileContextTracker: this.fileContextTracker, @@ -2927,7 +2846,7 @@ export class Task extends EventEmitter implements TaskLike { const shouldAddUserMessage = ((currentItem.retryAttempt ?? 0) === 0 && !isEmptyUserContent) || currentItem.userMessageWasRemoved if (shouldAddUserMessage) { - await this.addToApiConversationHistory({ role: "user", content: finalUserContent } as RooMessage) + await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) TelemetryService.instance.captureConversationMessage(this.taskId, "user") } @@ -3032,7 +2951,6 @@ export class Task extends EventEmitter implements TaskLike { this.assistantMessageContent = [] this.didCompleteReadingStream = false this.userMessageContent = [] - this.pendingToolResults = [] this.userMessageContentReady = false this.didRejectTool = false this.didAlreadyUseTool = false @@ -3610,7 +3528,7 @@ export class Task extends EventEmitter implements TaskLike { } // Build the assistant message content array - const assistantContent: Array = [] + const assistantContent: Array = [] // Add text content if present if (assistantMessage) { @@ -3645,9 +3563,9 @@ export class Task extends EventEmitter implements TaskLike { } seenToolUseIds.add(sanitizedId) assistantContent.push({ - type: "tool-call" as const, - toolCallId: sanitizedId, - toolName: mcpBlock.name, // Original dynamic name + type: "tool_use" as const, + id: sanitizedId, + name: mcpBlock.name, // Original dynamic name input: mcpBlock.arguments, // Direct tool arguments }) } @@ -3675,9 +3593,9 @@ export class Task extends EventEmitter implements TaskLike { const toolNameForHistory = toolUse.originalName ?? toolUse.name assistantContent.push({ - type: "tool-call" as const, - toolCallId: sanitizedId, - toolName: toolNameForHistory, + type: "tool_use" as const, + id: sanitizedId, + name: toolNameForHistory, input, }) } @@ -3688,7 +3606,7 @@ export class Task extends EventEmitter implements TaskLike { // truncate any tools that come after it and inject error tool_results. // This prevents orphaned tools when delegation disposes the parent task. const newTaskIndex = assistantContent.findIndex( - (block) => block.type === "tool-call" && (block as ToolCallPart).toolName === "new_task", + (block) => block.type === "tool_use" && block.name === "new_task", ) if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) { @@ -3709,18 +3627,13 @@ export class Task extends EventEmitter implements TaskLike { // Pre-inject error tool_results for truncated tools for (const tool of truncatedTools) { - if (tool.type !== "tool-call") continue - const toolCallId = getToolCallId(tool as AnyToolCallBlock) - const toolName = getToolCallName(tool as AnyToolCallBlock) - if (toolCallId) { + if (tool.type === "tool_use" && (tool as Anthropic.ToolUseBlockParam).id) { this.pushToolResultToUserContent({ - type: "tool-result", - toolCallId: sanitizeToolUseId(toolCallId), - toolName, - output: { - type: "text", - value: "[ERROR] This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", - }, + type: "tool_result", + tool_use_id: (tool as Anthropic.ToolUseBlockParam).id, + content: + "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", + is_error: true, }) } } @@ -3731,7 +3644,7 @@ export class Task extends EventEmitter implements TaskLike { // will save the user message with tool_results. The assistant message must already be in history // so that tool_result blocks appear AFTER their corresponding tool_use blocks. await this.addToApiConversationHistory( - { role: "assistant", content: assistantContent } as RooMessage, + { role: "assistant", content: assistantContent }, reasoningMessage || undefined, ) this.assistantMessageSavedToHistory = true @@ -3802,7 +3715,7 @@ export class Task extends EventEmitter implements TaskLike { // When paused, we push an empty item so the loop continues to the pause check. if (this.userMessageContent.length > 0 || this.isPaused) { stack.push({ - userContent: [...this.userMessageContent] as UserContentPart[], // Create a copy to avoid mutation issues + userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues includeFileDetails: false, // Subsequent iterations don't need file details }) @@ -3832,7 +3745,7 @@ export class Task extends EventEmitter implements TaskLike { let state = await this.providerRef.deref()?.getState() if (this.apiConversationHistory.length > 0) { const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] - if ("role" in lastMessage && lastMessage.role === "user") { + if (lastMessage.role === "user") { // Remove the last user message that we added earlier this.apiConversationHistory.pop() } @@ -3893,7 +3806,7 @@ export class Task extends EventEmitter implements TaskLike { await this.addToApiConversationHistory({ role: "user", content: currentUserContent, - } as RooMessage) + }) await this.say( "error", @@ -4103,7 +4016,7 @@ export class Task extends EventEmitter implements TaskLike { }) if (truncateResult.messages !== this.apiConversationHistory) { - await this.overwriteApiConversationHistory(truncateResult.messages as RooMessage[]) + await this.overwriteApiConversationHistory(truncateResult.messages) } if (truncateResult.summary) { @@ -4234,11 +4147,11 @@ export class Task extends EventEmitter implements TaskLike { // This allows us to show an in-progress indicator to the user // We use the centralized willManageContext helper to avoid duplicating threshold logic const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] - const lastMessageContent = isRooRoleMessage(lastMessage) ? lastMessage.content : undefined + const lastMessageContent = lastMessage?.content let lastMessageTokens = 0 if (lastMessageContent) { lastMessageTokens = Array.isArray(lastMessageContent) - ? await this.api.countTokens(lastMessageContent as Parameters[0]) + ? await this.api.countTokens(lastMessageContent) : await this.api.countTokens([{ type: "text", text: lastMessageContent as string }]) } @@ -4331,7 +4244,7 @@ export class Task extends EventEmitter implements TaskLike { rooIgnoreController: this.rooIgnoreController, }) if (truncateResult.messages !== this.apiConversationHistory) { - await this.overwriteApiConversationHistory(truncateResult.messages as RooMessage[]) + await this.overwriteApiConversationHistory(truncateResult.messages) } if (truncateResult.error) { await this.say("condense_context_error", truncateResult.error) @@ -4396,7 +4309,7 @@ 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) + const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[]) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( @@ -4474,7 +4387,12 @@ export class Task extends EventEmitter implements TaskLike { // Reset the flag after using it this.skipPrevResponseIdOnce = false - const stream = this.api.createMessage(systemPrompt, cleanConversationHistory, metadata) + // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. + const stream = this.api.createMessage( + systemPrompt, + cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[], + metadata, + ) const iterator = stream[Symbol.asyncIterator]() // Set up abort handling - when the signal is aborted, clean up the controller reference @@ -4644,50 +4562,147 @@ export class Task extends EventEmitter implements TaskLike { return checkpointSave(this, force, suppressMessage) } - /** - * Prepares conversation history for the API request. - * - * With the AI SDK architecture, RooMessage already stores content in - * ModelMessage format. Condense/truncation filtering is handled upstream - * by getEffectiveApiHistory. This method: - * - * - Passes through RooReasoningMessage items (encrypted reasoning for OpenAI Native) - * - Passes through RooAssistantMessage items with providerOptions (reasoning_details) - * - Strips plain-text reasoning parts from assistant messages when the provider - * does not support them (defensive; currently all providers are AI SDK) - * - Passes through all other messages unchanged - */ - private buildCleanConversationHistory(messages: RooMessage[]): RooMessage[] { - const preserveReasoning = this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider() - - // Fast path: when reasoning is preserved, no transformation needed - if (preserveReasoning) { - return messages + private buildCleanConversationHistory( + messages: ApiMessage[], + ): Array< + Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } + > { + type ReasoningItemForRequest = { + type: "reasoning" + encrypted_content: string + id?: string + summary?: any[] } - // Defensive path for non-AI SDK providers: strip reasoning - return messages - .filter((msg) => { - // Remove standalone encrypted reasoning items - if (isRooReasoningMessage(msg)) { - return false + const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] + + for (const msg of messages) { + // Standalone reasoning: send encrypted, skip plain text + if (msg.type === "reasoning") { + if (msg.encrypted_content) { + cleanConversationHistory.push({ + type: "reasoning", + summary: msg.summary, + encrypted_content: msg.encrypted_content!, + ...(msg.id ? { id: msg.id } : {}), + }) } - return true - }) - .map((msg) => { - // Strip reasoning parts from assistant message content - if (isRooAssistantMessage(msg) && Array.isArray(msg.content)) { - const hasReasoning = msg.content.some((part) => part.type === "reasoning") - if (hasReasoning) { - const filtered = msg.content.filter((part) => part.type !== "reasoning") - return { - ...msg, - content: filtered.length > 0 ? filtered : [{ type: "text" as const, text: "" }], + continue + } + + // Preferred path: assistant message with embedded reasoning as first content block + if (msg.role === "assistant") { + const rawContent = msg.content + + const contentArray: Anthropic.Messages.ContentBlockParam[] = Array.isArray(rawContent) + ? (rawContent as Anthropic.Messages.ContentBlockParam[]) + : rawContent !== undefined + ? ([ + { type: "text", text: rawContent } satisfies Anthropic.Messages.TextBlockParam, + ] as Anthropic.Messages.ContentBlockParam[]) + : [] + + const [first, ...rest] = contentArray + + // Check if this message has reasoning_details (OpenRouter format for Gemini 3, etc.) + const msgWithDetails = msg + if (msgWithDetails.reasoning_details && Array.isArray(msgWithDetails.reasoning_details)) { + // Build the assistant message with reasoning_details + let assistantContent: Anthropic.Messages.MessageParam["content"] + + if (contentArray.length === 0) { + assistantContent = "" + } else if (contentArray.length === 1 && contentArray[0].type === "text") { + assistantContent = (contentArray[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = contentArray + } + + // Create message with reasoning_details property + cleanConversationHistory.push({ + role: "assistant", + content: assistantContent, + reasoning_details: msgWithDetails.reasoning_details, + } as any) + + continue + } + + // Embedded reasoning: encrypted (send) or plain text (skip) + const hasEncryptedReasoning = + first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" + const hasPlainTextReasoning = + first && (first as any).type === "reasoning" && typeof (first as any).text === "string" + + if (hasEncryptedReasoning) { + const reasoningBlock = first as any + + // Send as separate reasoning item (OpenAI Native) + cleanConversationHistory.push({ + type: "reasoning", + summary: reasoningBlock.summary ?? [], + encrypted_content: reasoningBlock.encrypted_content, + ...(reasoningBlock.id ? { id: reasoningBlock.id } : {}), + }) + + // Send assistant message without reasoning + let assistantContent: Anthropic.Messages.MessageParam["content"] + + if (rest.length === 0) { + assistantContent = "" + } else if (rest.length === 1 && rest[0].type === "text") { + assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = rest + } + + cleanConversationHistory.push({ + role: "assistant", + content: assistantContent, + } satisfies Anthropic.Messages.MessageParam) + + continue + } else if (hasPlainTextReasoning) { + // Preserve plain-text reasoning blocks for: + // - models explicitly opting in via preserveReasoning + // - AI SDK providers (provider packages decide what to include in the native request) + const shouldPreserveForApi = + this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider() + + let assistantContent: Anthropic.Messages.MessageParam["content"] + + if (shouldPreserveForApi) { + assistantContent = contentArray + } else { + // Strip reasoning out - stored for history only, not sent back to API + if (rest.length === 0) { + assistantContent = "" + } else if (rest.length === 1 && rest[0].type === "text") { + assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = rest } } + + cleanConversationHistory.push({ + role: "assistant", + content: assistantContent, + } satisfies Anthropic.Messages.MessageParam) + + continue } - return msg - }) + } + + // Default path for regular messages (no embedded reasoning) + if (msg.role) { + cleanConversationHistory.push({ + role: msg.role, + content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, + }) + } + } + + return cleanConversationHistory } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 20db490b871..1e4acc9713b 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -15,7 +15,6 @@ import { ContextProxy } from "../../config/ContextProxy" const { mockSaveApiMessages, - mockSaveRooMessages, mockSaveTaskMessages, mockReadApiMessages, mockReadTaskMessages, @@ -23,7 +22,6 @@ const { mockPWaitFor, } = vi.hoisted(() => ({ mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), - mockSaveRooMessages: vi.fn().mockResolvedValue(undefined), mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), mockReadApiMessages: vi.fn().mockResolvedValue([]), mockReadTaskMessages: vi.fn().mockResolvedValue([]), @@ -77,7 +75,6 @@ vi.mock("p-wait-for", () => ({ vi.mock("../../task-persistence", () => ({ saveApiMessages: mockSaveApiMessages, - saveRooMessages: mockSaveRooMessages, saveTaskMessages: mockSaveTaskMessages, readApiMessages: mockReadApiMessages, readTaskMessages: mockReadTaskMessages, @@ -254,7 +251,7 @@ describe("Task persistence", () => { describe("saveApiConversationHistory", () => { it("returns true on success", async () => { - mockSaveRooMessages.mockResolvedValueOnce(undefined) + mockSaveApiMessages.mockResolvedValueOnce(undefined) const task = new Task({ provider: mockProvider, @@ -276,7 +273,7 @@ describe("Task persistence", () => { vi.useFakeTimers() // All 3 retry attempts must fail for retrySaveApiConversationHistory to return false - mockSaveRooMessages + mockSaveApiMessages .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")) .mockRejectedValueOnce(new Error("fail 3")) @@ -293,7 +290,7 @@ describe("Task persistence", () => { const result = await promise expect(result).toBe(false) - expect(mockSaveRooMessages).toHaveBeenCalledTimes(3) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(3) vi.useRealTimers() }) @@ -301,7 +298,7 @@ describe("Task persistence", () => { it("succeeds on 2nd retry attempt", async () => { vi.useFakeTimers() - mockSaveRooMessages.mockRejectedValueOnce(new Error("fail 1")).mockResolvedValueOnce(undefined) // succeeds on 2nd try + mockSaveApiMessages.mockRejectedValueOnce(new Error("fail 1")).mockResolvedValueOnce(undefined) // succeeds on 2nd try const task = new Task({ provider: mockProvider, @@ -315,13 +312,13 @@ describe("Task persistence", () => { const result = await promise expect(result).toBe(true) - expect(mockSaveRooMessages).toHaveBeenCalledTimes(2) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(2) vi.useRealTimers() }) it("snapshots the array before passing to saveApiMessages", async () => { - mockSaveRooMessages.mockResolvedValueOnce(undefined) + mockSaveApiMessages.mockResolvedValueOnce(undefined) const task = new Task({ provider: mockProvider, @@ -338,9 +335,9 @@ describe("Task persistence", () => { await task.retrySaveApiConversationHistory() - expect(mockSaveRooMessages).toHaveBeenCalledTimes(1) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) - const callArgs = mockSaveRooMessages.mock.calls[0][0] + const callArgs = mockSaveApiMessages.mock.calls[0][0] // The messages passed should be a COPY, not the live reference expect(callArgs.messages).not.toBe(task.apiConversationHistory) // But the content should be the same @@ -412,7 +409,7 @@ describe("Task persistence", () => { describe("flushPendingToolResultsToHistory persistence", () => { it("retains userMessageContent on save failure", async () => { - mockSaveRooMessages.mockRejectedValueOnce(new Error("disk full")) + mockSaveApiMessages.mockRejectedValueOnce(new Error("disk full")) const task = new Task({ provider: mockProvider, @@ -424,28 +421,27 @@ describe("Task persistence", () => { // Skip waiting for assistant message task.assistantMessageSavedToHistory = true - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-fail", - toolName: "read_file", - output: { type: "text", value: "Result that should be retained" }, + type: "tool_result", + tool_use_id: "tool-fail", + content: "Result that should be retained", }, ] const saved = await task.flushPendingToolResultsToHistory() expect(saved).toBe(false) - // pendingToolResults should NOT be cleared on failure - expect(task.pendingToolResults.length).toBeGreaterThan(0) - expect(task.pendingToolResults[0]).toMatchObject({ - type: "tool-result", - toolCallId: "tool-fail", + // userMessageContent should NOT be cleared on failure + expect(task.userMessageContent.length).toBeGreaterThan(0) + expect(task.userMessageContent[0]).toMatchObject({ + type: "tool_result", + tool_use_id: "tool-fail", }) }) it("clears userMessageContent on save success", async () => { - mockSaveRooMessages.mockResolvedValueOnce(undefined) + mockSaveApiMessages.mockResolvedValueOnce(undefined) const task = new Task({ provider: mockProvider, @@ -457,20 +453,19 @@ describe("Task persistence", () => { // Skip waiting for assistant message task.assistantMessageSavedToHistory = true - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-ok", - toolName: "read_file", - output: { type: "text", value: "Result that should be cleared" }, + type: "tool_result", + tool_use_id: "tool-ok", + content: "Result that should be cleared", }, ] const saved = await task.flushPendingToolResultsToHistory() expect(saved).toBe(true) - // pendingToolResults should be cleared on success - expect(task.pendingToolResults).toEqual([]) + // userMessageContent should be cleared on success + expect(task.userMessageContent).toEqual([]) }) }) }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f412525f6a1..7e6ca950e5a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -528,7 +528,7 @@ describe("Cline", () => { } as ModelInfo, }) - clineWithImages.apiConversationHistory = conversationHistory as any + clineWithImages.apiConversationHistory = conversationHistory // Test with model that doesn't support images const [clineWithoutImages, taskWithoutImages] = Task.create({ @@ -550,7 +550,7 @@ describe("Cline", () => { } as ModelInfo, }) - clineWithoutImages.apiConversationHistory = conversationHistory as any + clineWithoutImages.apiConversationHistory = conversationHistory // Mock abort state for both instances Object.defineProperty(clineWithImages, "abort", { @@ -590,7 +590,7 @@ describe("Cline", () => { { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } }, ], }, - ] as any + ] clineWithImages.abandoned = true await taskWithImages.catch(() => {}) @@ -893,7 +893,7 @@ describe("Cline", () => { text: "Check 'some/path' (see below for file content)", }, ], - } as any, + } as Anthropic.ToolResultBlockParam, { type: "tool_result", tool_use_id: "test-id-2", @@ -903,7 +903,7 @@ describe("Cline", () => { text: "Regular tool result with 'path' (see below for file content)", }, ], - } as any, + } as Anthropic.ToolResultBlockParam, ] const { content: processedContent } = await processUserContentMentions({ @@ -924,12 +924,20 @@ describe("Cline", () => { "Text with 'some/path' (see below for file content) in user_message tags", ) - // tool_result blocks are passed through unchanged (no longer processed by processUserContentMentions) - const toolResult1 = processedContent[2] as any - expect(toolResult1.type).toBe("tool_result") + // user_message tag content should be processed + const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam + const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content + expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:") + expect((content1 as Anthropic.TextBlockParam).text).toContain( + "Check 'some/path' (see below for file content)", + ) - const toolResult2 = processedContent[3] as any - expect(toolResult2.type).toBe("tool_result") + // Regular tool result should not be processed + const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam + const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content + expect((content2 as Anthropic.TextBlockParam).text).toBe( + "Regular tool result with 'path' (see below for file content)", + ) await cline.abortTask(true) await task.catch(() => {}) @@ -2043,18 +2051,17 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const toolResult = { - type: "tool-result" as const, - toolCallId: "test-id-1", - toolName: "read_file", - output: { type: "text", value: "Test result" }, + const toolResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "test-id-1", + content: "Test result", } - const added = task.pushToolResultToUserContent(toolResult as any) + const added = task.pushToolResultToUserContent(toolResult) expect(added).toBe(true) - expect(task.pendingToolResults).toHaveLength(1) - expect(task.pendingToolResults[0]).toEqual(toolResult) + expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent[0]).toEqual(toolResult) }) it("should prevent duplicate tool_result with same tool_use_id", () => { @@ -2065,39 +2072,37 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const toolResult1 = { - type: "tool-result" as const, - toolCallId: "duplicate-id", - toolName: "read_file", - output: { type: "text", value: "First result" }, + const toolResult1: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "duplicate-id", + content: "First result", } - const toolResult2 = { - type: "tool-result" as const, - toolCallId: "duplicate-id", - toolName: "read_file", - output: { type: "text", value: "Second result (should be skipped)" }, + const toolResult2: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "duplicate-id", + content: "Second result (should be skipped)", } // Spy on console.warn to verify warning is logged const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) // Add first result - should succeed - const added1 = task.pushToolResultToUserContent(toolResult1 as any) + const added1 = task.pushToolResultToUserContent(toolResult1) expect(added1).toBe(true) - expect(task.pendingToolResults).toHaveLength(1) + expect(task.userMessageContent).toHaveLength(1) // Add second result with same ID - should be skipped - const added2 = task.pushToolResultToUserContent(toolResult2 as any) + const added2 = task.pushToolResultToUserContent(toolResult2) expect(added2).toBe(false) - expect(task.pendingToolResults).toHaveLength(1) + expect(task.userMessageContent).toHaveLength(1) // Verify only the first result is in the array - expect(task.pendingToolResults[0]).toEqual(toolResult1) + expect(task.userMessageContent[0]).toEqual(toolResult1) // Verify warning was logged expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Skipping duplicate tool_result for toolCallId: duplicate-id"), + expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"), ) warnSpy.mockRestore() @@ -2111,28 +2116,26 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const toolResult1 = { - type: "tool-result" as const, - toolCallId: "id-1", - toolName: "read_file", - output: { type: "text", value: "Result 1" }, + const toolResult1: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "id-1", + content: "Result 1", } - const toolResult2 = { - type: "tool-result" as const, - toolCallId: "id-2", - toolName: "write_to_file", - output: { type: "text", value: "Result 2" }, + const toolResult2: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "id-2", + content: "Result 2", } - const added1 = task.pushToolResultToUserContent(toolResult1 as any) - const added2 = task.pushToolResultToUserContent(toolResult2 as any) + const added1 = task.pushToolResultToUserContent(toolResult1) + const added2 = task.pushToolResultToUserContent(toolResult2) expect(added1).toBe(true) expect(added2).toBe(true) - expect(task.pendingToolResults).toHaveLength(2) - expect(task.pendingToolResults[0]).toEqual(toolResult1) - expect(task.pendingToolResults[1]).toEqual(toolResult2) + expect(task.userMessageContent).toHaveLength(2) + expect(task.userMessageContent[0]).toEqual(toolResult1) + expect(task.userMessageContent[1]).toEqual(toolResult2) }) it("should handle tool_result with is_error flag", () => { @@ -2143,19 +2146,18 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const errorResult = { - type: "tool-result" as const, - toolCallId: "error-id", - toolName: "execute_command", - output: { type: "text", value: "Error message" }, - isError: true, + const errorResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "error-id", + content: "Error message", + is_error: true, } - const added = task.pushToolResultToUserContent(errorResult as any) + const added = task.pushToolResultToUserContent(errorResult) expect(added).toBe(true) - expect(task.pendingToolResults).toHaveLength(1) - expect(task.pendingToolResults[0]).toEqual(errorResult) + expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent[0]).toEqual(errorResult) }) it("should not interfere with other content types in userMessageContent", () => { @@ -2167,21 +2169,23 @@ describe("pushToolResultToUserContent", () => { }) // Add text and image blocks manually - task.userMessageContent.push({ type: "text", text: "Some text" }) + task.userMessageContent.push( + { type: "text", text: "Some text" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } }, + ) - const toolResult = { - type: "tool-result" as const, - toolCallId: "test-id", - toolName: "read_file", - output: { type: "text", value: "Result" }, + const toolResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "test-id", + content: "Result", } - const added = task.pushToolResultToUserContent(toolResult as any) + const added = task.pushToolResultToUserContent(toolResult) expect(added).toBe(true) - expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent).toHaveLength(3) expect(task.userMessageContent[0].type).toBe("text") - expect(task.pendingToolResults).toHaveLength(1) - expect(task.pendingToolResults[0]).toEqual(toolResult) + expect(task.userMessageContent[1].type).toBe("image") + expect(task.userMessageContent[2]).toEqual(toolResult) }) }) diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index f210e870c5d..f19645d9697 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -249,30 +249,29 @@ describe("flushPendingToolResultsToHistory", () => { startTask: false, }) - // Set up pending tool result in pendingToolResults - task.pendingToolResults = [ + // Set up pending tool result in userMessageContent + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-123", - toolName: "write_to_file", - output: { type: "text", value: "File written successfully" }, + type: "tool_result", + tool_use_id: "tool-123", + content: "File written successfully", }, ] await task.flushPendingToolResultsToHistory() - // Should have saved 1 tool message + // Should have saved 1 user message expect(task.apiConversationHistory.length).toBe(1) - // Check tool message with tool result - const toolMessage = task.apiConversationHistory[0] as any - expect(toolMessage.role).toBe("tool") - expect(Array.isArray(toolMessage.content)).toBe(true) - expect((toolMessage.content as any[])[0].type).toBe("tool-result") - expect((toolMessage.content as any[])[0].toolCallId).toBe("tool-123") + // Check user message with tool result + const userMessage = task.apiConversationHistory[0] + expect(userMessage.role).toBe("user") + expect(Array.isArray(userMessage.content)).toBe(true) + expect((userMessage.content as any[])[0].type).toBe("tool_result") + expect((userMessage.content as any[])[0].tool_use_id).toBe("tool-123") }) - it("should clear pendingToolResults after flushing", async () => { + it("should clear userMessageContent after flushing", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -281,19 +280,18 @@ describe("flushPendingToolResultsToHistory", () => { }) // Set up pending tool result - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-456", - toolName: "execute_command", - output: { type: "text", value: "Command executed" }, + type: "tool_result", + tool_use_id: "tool-456", + content: "Command executed", }, ] await task.flushPendingToolResultsToHistory() - // pendingToolResults should be cleared - expect(task.pendingToolResults.length).toBe(0) + // userMessageContent should be cleared + expect(task.userMessageContent.length).toBe(0) }) it("should handle multiple tool results in a single flush", async () => { @@ -305,29 +303,27 @@ describe("flushPendingToolResultsToHistory", () => { }) // Set up multiple pending tool results - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-1", - toolName: "read_file", - output: { type: "text", value: "First result" }, + type: "tool_result", + tool_use_id: "tool-1", + content: "First result", }, { - type: "tool-result", - toolCallId: "tool-2", - toolName: "write_to_file", - output: { type: "text", value: "Second result" }, + type: "tool_result", + tool_use_id: "tool-2", + content: "Second result", }, ] await task.flushPendingToolResultsToHistory() - // Check tool message has both tool results - const toolMessage = task.apiConversationHistory[0] as any - expect(Array.isArray(toolMessage.content)).toBe(true) - expect((toolMessage.content as any[]).length).toBe(2) - expect((toolMessage.content as any[])[0].toolCallId).toBe("tool-1") - expect((toolMessage.content as any[])[1].toolCallId).toBe("tool-2") + // Check user message has both tool results + const userMessage = task.apiConversationHistory[0] + expect(Array.isArray(userMessage.content)).toBe(true) + expect((userMessage.content as any[]).length).toBe(2) + expect((userMessage.content as any[])[0].tool_use_id).toBe("tool-1") + expect((userMessage.content as any[])[1].tool_use_id).toBe("tool-2") }) it("should add timestamp to saved messages", async () => { @@ -340,12 +336,11 @@ describe("flushPendingToolResultsToHistory", () => { const beforeTs = Date.now() - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-ts", - toolName: "read_file", - output: { type: "text", value: "Result" }, + type: "tool_result", + tool_use_id: "tool-ts", + content: "Result", }, ] @@ -370,12 +365,11 @@ describe("flushPendingToolResultsToHistory", () => { task.assistantMessageSavedToHistory = true // Set up pending tool result - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-skip-wait", - toolName: "read_file", - output: { type: "text", value: "Result when flag is true" }, + type: "tool_result", + tool_use_id: "tool-skip-wait", + content: "Result when flag is true", }, ] @@ -389,7 +383,7 @@ describe("flushPendingToolResultsToHistory", () => { // Should still save the message expect(task.apiConversationHistory.length).toBe(1) - expect(((task.apiConversationHistory[0] as any).content as any[])[0].toolCallId).toBe("tool-skip-wait") + expect((task.apiConversationHistory[0].content as any[])[0].tool_use_id).toBe("tool-skip-wait") }) it("should wait for assistantMessageSavedToHistory when flag is false", async () => { @@ -404,12 +398,11 @@ describe("flushPendingToolResultsToHistory", () => { expect(task.assistantMessageSavedToHistory).toBe(false) // Set up pending tool result - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-wait", - toolName: "read_file", - output: { type: "text", value: "Result when flag is false" }, + type: "tool_result", + tool_use_id: "tool-wait", + content: "Result when flag is false", }, ] @@ -437,12 +430,11 @@ describe("flushPendingToolResultsToHistory", () => { task.assistantMessageSavedToHistory = false // Set up pending tool result - task.pendingToolResults = [ + task.userMessageContent = [ { - type: "tool-result", - toolCallId: "tool-aborted", - toolName: "read_file", - output: { type: "text", value: "Should not be saved" }, + type: "tool_result", + tool_use_id: "tool-aborted", + content: "Should not be saved", }, ] diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index 06d6875cbee..f6874a581e4 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -240,7 +240,7 @@ Sources: [1](https://example.com), [2](https://another.com) // Verify the API conversation history contains the cleaned message expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0] as any).content).toEqual([ + expect(task.apiConversationHistory[0].content).toEqual([ { type: "text", text: "This is the main response content." }, ]) }) @@ -273,7 +273,7 @@ Sources: [1](https://example.com), [2](https://another.com) }) // Message should remain unchanged - expect((task.apiConversationHistory[0] as any).content).toEqual([ + expect(task.apiConversationHistory[0].content).toEqual([ { type: "text", text: "This is a regular response without any sources." }, ]) }) diff --git a/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts index d5e62eec0df..94b0159c484 100644 --- a/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts +++ b/src/core/task/__tests__/mergeConsecutiveApiMessages.spec.ts @@ -11,12 +11,12 @@ describe("mergeConsecutiveApiMessages", () => { ]) expect(merged).toHaveLength(2) - expect((merged[0] as any).role).toBe("user") - expect((merged[0] as any).content).toEqual([ + expect(merged[0].role).toBe("user") + expect(merged[0].content).toEqual([ { type: "text", text: "A" }, { type: "text", text: "B" }, ]) - expect((merged[1] as any).role).toBe("assistant") + expect(merged[1].role).toBe("assistant") }) it("merges regular user message into a summary (API shaping only)", () => { @@ -27,7 +27,7 @@ describe("mergeConsecutiveApiMessages", () => { expect(merged).toHaveLength(1) expect(merged[0].isSummary).toBe(true) - expect((merged[0] as any).content).toEqual([ + expect(merged[0].content).toEqual([ { type: "text", text: "Summary" }, { type: "text", text: "After" }, ]) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index ef86eed78ef..2a3978e9111 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -238,8 +238,8 @@ describe("Task reasoning preservation", () => { // Verify the API conversation history contains the message with reasoning block expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0] as any).role).toBe("assistant") - expect((task.apiConversationHistory[0] as any).content).toEqual([ + expect(task.apiConversationHistory[0].role).toBe("assistant") + expect(task.apiConversationHistory[0].content).toEqual([ { type: "reasoning", text: reasoningMessage, summary: [] }, { type: "text", text: assistantMessage }, ]) @@ -285,8 +285,8 @@ describe("Task reasoning preservation", () => { // Verify the API conversation history contains a reasoning block (storage is unconditional) expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0] as any).role).toBe("assistant") - expect((task.apiConversationHistory[0] as any).content).toEqual([ + expect(task.apiConversationHistory[0].role).toBe("assistant") + expect(task.apiConversationHistory[0].content).toEqual([ { type: "reasoning", text: reasoningMessage, summary: [] }, { type: "text", text: assistantMessage }, ]) @@ -330,9 +330,7 @@ describe("Task reasoning preservation", () => { ) // Verify no reasoning blocks were added when reasoning is empty - expect((task.apiConversationHistory[0] as any).content).toEqual([ - { type: "text", text: "Here is my response." }, - ]) + expect(task.apiConversationHistory[0].content).toEqual([{ type: "text", text: "Here is my response." }]) }) it("should handle undefined preserveReasoning (defaults to false)", async () => { @@ -373,7 +371,7 @@ describe("Task reasoning preservation", () => { ) // Verify reasoning is stored even when preserveReasoning is undefined - expect((task.apiConversationHistory[0] as any).content).toEqual([ + expect(task.apiConversationHistory[0].content).toEqual([ { type: "reasoning", text: reasoningMessage, summary: [] }, { type: "text", text: assistantMessage }, ]) diff --git a/src/core/task/__tests__/task-tool-history.spec.ts b/src/core/task/__tests__/task-tool-history.spec.ts index f78fe589741..df74393156a 100644 --- a/src/core/task/__tests__/task-tool-history.spec.ts +++ b/src/core/task/__tests__/task-tool-history.spec.ts @@ -1,4 +1,3 @@ -import type { RooMessage } from "../../task-persistence/rooMessage" import { describe, it, expect, beforeEach, vi } from "vitest" import { Anthropic } from "@anthropic-ai/sdk" @@ -68,7 +67,7 @@ describe("Task Tool History Handling", () => { describe("convertToOpenAiMessages format", () => { it("should properly convert tool_use to tool_calls format", () => { - const anthropicMessage: any = { + const anthropicMessage: Anthropic.Messages.MessageParam = { role: "assistant", content: [ { @@ -85,9 +84,7 @@ describe("Task Tool History Handling", () => { } // Simulate what convertToOpenAiMessages does - const toolUseBlocks = ((anthropicMessage as any).content as any[]).filter( - (block) => block.type === "tool_use", - ) + const toolUseBlocks = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_use") const tool_calls = toolUseBlocks.map((toolMessage) => ({ id: toolMessage.id, @@ -110,7 +107,7 @@ describe("Task Tool History Handling", () => { }) it("should properly convert tool_result to tool role messages", () => { - const anthropicMessage: any = { + const anthropicMessage: Anthropic.Messages.MessageParam = { role: "user", content: [ { @@ -122,9 +119,7 @@ describe("Task Tool History Handling", () => { } // Simulate what convertToOpenAiMessages does - const toolMessages = ((anthropicMessage as any).content as any[]).filter( - (block) => block.type === "tool_result", - ) + const toolMessages = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_result") const openAiToolMessages = toolMessages.map((toolMessage) => ({ role: "tool" as const, diff --git a/src/core/task/__tests__/validateToolResultIds.spec.ts b/src/core/task/__tests__/validateToolResultIds.spec.ts index db9171e5b75..0926e899aad 100644 --- a/src/core/task/__tests__/validateToolResultIds.spec.ts +++ b/src/core/task/__tests__/validateToolResultIds.spec.ts @@ -1,5 +1,5 @@ +import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" -import type { RooMessage } from "../../task-persistence/rooMessage" import { validateAndFixToolResultIds, ToolResultIdMismatchError, @@ -23,18 +23,18 @@ describe("validateAndFixToolResultIds", () => { describe("when there is no previous assistant message", () => { it("should return the user message unchanged", () => { - const userMessage = { - role: "user" as const, + const userMessage: Anthropic.MessageParam = { + role: "user", content: [ { - type: "tool_result" as const, + type: "tool_result", tool_use_id: "tool-123", content: "Result", }, ], - } as unknown as RooMessage + } - const result = validateAndFixToolResultIds(userMessage as any, []) + const result = validateAndFixToolResultIds(userMessage, []) expect(result).toEqual(userMessage) }) @@ -42,7 +42,7 @@ describe("validateAndFixToolResultIds", () => { describe("when tool_result IDs match tool_use IDs", () => { it("should return the user message unchanged for single tool", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -54,7 +54,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -65,13 +65,13 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(result).toEqual(userMessage) }) it("should return the user message unchanged for multiple tools", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -89,7 +89,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -105,7 +105,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(result).toEqual(userMessage) }) @@ -113,7 +113,7 @@ describe("validateAndFixToolResultIds", () => { describe("when tool_result IDs do not match tool_use IDs", () => { it("should fix single mismatched tool_use_id by position", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -125,7 +125,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -136,16 +136,16 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] expect(resultContent[0].tool_use_id).toBe("correct-id-123") expect(resultContent[0].content).toBe("File content") }) it("should fix multiple mismatched tool_use_ids by position", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -163,7 +163,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -179,16 +179,16 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] expect(resultContent[0].tool_use_id).toBe("correct-1") expect(resultContent[1].tool_use_id).toBe("correct-2") }) it("should partially fix when some IDs match and some don't", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -206,7 +206,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -222,10 +222,10 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] expect(resultContent[0].tool_use_id).toBe("id-1") expect(resultContent[1].tool_use_id).toBe("id-2") }) @@ -233,7 +233,7 @@ describe("validateAndFixToolResultIds", () => { describe("when user message has non-tool_result content", () => { it("should preserve text blocks alongside tool_result blocks", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -245,7 +245,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -260,20 +260,20 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Array expect(resultContent[0].type).toBe("tool_result") - expect(resultContent[0].tool_use_id ?? resultContent[0].toolCallId).toBe("tool-123") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") expect(resultContent[1].type).toBe("text") - expect(resultContent[1].text).toBe("Additional context") + expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Additional context") }) }) describe("when assistant message has non-tool_use content", () => { it("should only consider tool_use blocks for matching", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -289,7 +289,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -300,17 +300,17 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] expect(resultContent[0].tool_use_id).toBe("tool-123") }) }) describe("when user message content is a string", () => { it("should return the message unchanged", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -322,12 +322,12 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: "Just a plain text message", } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(result).toEqual(userMessage) }) @@ -335,12 +335,12 @@ describe("validateAndFixToolResultIds", () => { describe("when assistant message content is a string", () => { it("should return the user message unchanged", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: "Just some text, no tool use", } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -351,7 +351,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(result).toEqual(userMessage) }) @@ -359,7 +359,7 @@ describe("validateAndFixToolResultIds", () => { describe("when there are more tool_results than tool_uses", () => { it("should filter out orphaned tool_results with invalid IDs", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -371,7 +371,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -387,10 +387,10 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] // Only one tool_result should remain - the first one gets fixed to tool-1 expect(resultContent.length).toBe(1) expect(resultContent[0].tool_use_id).toBe("tool-1") @@ -399,7 +399,7 @@ describe("validateAndFixToolResultIds", () => { it("should filter out duplicate tool_results when one already has a valid ID", () => { // This is the exact scenario from the PostHog error: // 2 tool_results (call_08230257, call_55577629), 1 tool_use (call_55577629) - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -411,7 +411,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -427,10 +427,10 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] // Should only keep one tool_result since there's only one tool_use // The first invalid one gets fixed to the valid ID, then the second one // (which already has that ID) becomes a duplicate and is filtered out @@ -439,7 +439,7 @@ describe("validateAndFixToolResultIds", () => { }) it("should preserve text blocks while filtering orphaned tool_results", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -451,7 +451,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -471,22 +471,22 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Array // Should have tool_result + text block, orphaned tool_result filtered out expect(resultContent.length).toBe(2) expect(resultContent[0].type).toBe("tool_result") - expect(resultContent[0].tool_use_id ?? resultContent[0].toolCallId).toBe("tool-1") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1") expect(resultContent[1].type).toBe("text") - expect(resultContent[1].text).toBe("Some additional context") + expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Some additional context") }) // Verifies fix for GitHub #10465: Terminal fallback race condition can generate // duplicate tool_results with the same valid tool_use_id, causing API protocol violations. it("should filter out duplicate tool_results with identical valid tool_use_ids (terminal fallback scenario)", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -499,7 +499,7 @@ describe("validateAndFixToolResultIds", () => { } // Two tool_results with the SAME valid tool_use_id from terminal fallback race condition - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -515,10 +515,10 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] // Only ONE tool_result should remain to prevent API protocol violation expect(resultContent.length).toBe(1) @@ -527,7 +527,7 @@ describe("validateAndFixToolResultIds", () => { }) it("should preserve text blocks while deduplicating tool_results with same valid ID", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -539,7 +539,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -559,24 +559,24 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Array // Should have: 1 tool_result + 1 text block (duplicate filtered out) expect(resultContent.length).toBe(2) expect(resultContent[0].type).toBe("tool_result") - expect(resultContent[0].tool_use_id ?? resultContent[0].toolCallId).toBe("tool-123") - expect(resultContent[0].content ?? resultContent[0].output.value).toBe("First result") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe("First result") expect(resultContent[1].type).toBe("text") - expect(resultContent[1].text).toBe("Environment details here") + expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Environment details here") }) }) describe("when there are more tool_uses than tool_results", () => { it("should fix the available tool_results and add missing ones", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -594,7 +594,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -605,23 +605,23 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] // Should now have 2 tool_results: one fixed and one added for the missing tool_use expect(resultContent.length).toBe(2) - // The missing tool_result is prepended (AI SDK format) - expect(resultContent[0].toolCallId).toBe("tool-2") - expect(resultContent[0].output.value).toBe("Tool execution was interrupted before completion.") - // The original is fixed (legacy format, tool_use_id updated) + // The missing tool_result is prepended + expect(resultContent[0].tool_use_id).toBe("tool-2") + expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.") + // The original is fixed expect(resultContent[1].tool_use_id).toBe("tool-1") }) }) describe("when tool_results are completely missing", () => { it("should add missing tool_result for single tool_use", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -633,7 +633,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -643,21 +643,23 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Array expect(resultContent.length).toBe(2) - // Missing tool_result should be prepended (AI SDK format) - expect(resultContent[0].type).toBe("tool-result") - expect(resultContent[0].toolCallId).toBe("tool-123") - expect(resultContent[0].output.value).toBe("Tool execution was interrupted before completion.") + // Missing tool_result should be prepended + expect(resultContent[0].type).toBe("tool_result") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe( + "Tool execution was interrupted before completion.", + ) // Original text block should be preserved expect(resultContent[1].type).toBe("text") }) it("should add missing tool_results for multiple tool_uses", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -675,7 +677,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -685,22 +687,22 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Array expect(resultContent.length).toBe(3) - // Both missing tool_results should be prepended (AI SDK format) - expect(resultContent[0].type).toBe("tool-result") - expect(resultContent[0].toolCallId).toBe("tool-1") - expect(resultContent[1].type).toBe("tool-result") - expect(resultContent[1].toolCallId).toBe("tool-2") + // Both missing tool_results should be prepended + expect(resultContent[0].type).toBe("tool_result") + expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1") + expect(resultContent[1].type).toBe("tool_result") + expect((resultContent[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-2") // Original text should be preserved expect(resultContent[2].type).toBe("text") }) it("should add only the missing tool_results when some exist", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -718,7 +720,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -729,21 +731,21 @@ describe("validateAndFixToolResultIds", () => { ], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] expect(resultContent.length).toBe(2) - // Missing tool_result for tool-2 should be prepended (AI SDK format) - expect(resultContent[0].toolCallId).toBe("tool-2") - expect(resultContent[0].output.value).toBe("Tool execution was interrupted before completion.") + // Missing tool_result for tool-2 should be prepended + expect(resultContent[0].tool_use_id).toBe("tool-2") + expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.") // Existing tool_result should be preserved expect(resultContent[1].tool_use_id).toBe("tool-1") expect(resultContent[1].content).toBe("Content for tool 1") }) it("should handle empty user content array by adding all missing tool_results", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -755,25 +757,25 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [], } - const result = validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) - expect(Array.isArray((result as any).content)).toBe(true) - const resultContent = (result as any).content as any[] + expect(Array.isArray(result.content)).toBe(true) + const resultContent = result.content as Anthropic.ToolResultBlockParam[] expect(resultContent.length).toBe(1) - expect(resultContent[0].type).toBe("tool-result") - expect(resultContent[0].toolCallId).toBe("tool-1") - expect(resultContent[0].output.value).toBe("Tool execution was interrupted before completion.") + expect(resultContent[0].type).toBe("tool_result") + expect(resultContent[0].tool_use_id).toBe("tool-1") + expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.") }) }) describe("telemetry", () => { it("should call captureException for both missing and mismatch when there is a mismatch", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -785,7 +787,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -796,7 +798,7 @@ describe("validateAndFixToolResultIds", () => { ], } - validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + validateAndFixToolResultIds(userMessage, [assistantMessage]) // A mismatch also triggers missing detection since the wrong-id doesn't match any tool_use expect(TelemetryService.instance.captureException).toHaveBeenCalledTimes(2) @@ -821,7 +823,7 @@ describe("validateAndFixToolResultIds", () => { }) it("should not call captureException when IDs match", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -833,7 +835,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -844,7 +846,7 @@ describe("validateAndFixToolResultIds", () => { ], } - validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(TelemetryService.instance.captureException).not.toHaveBeenCalled() }) @@ -882,7 +884,7 @@ describe("validateAndFixToolResultIds", () => { describe("telemetry for missing tool_results", () => { it("should call captureException when tool_results are missing", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -894,7 +896,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -904,7 +906,7 @@ describe("validateAndFixToolResultIds", () => { ], } - validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(TelemetryService.instance.captureException).toHaveBeenCalledTimes(1) expect(TelemetryService.instance.captureException).toHaveBeenCalledWith( @@ -919,7 +921,7 @@ describe("validateAndFixToolResultIds", () => { }) it("should call captureException twice when both mismatch and missing occur", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -937,7 +939,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -949,7 +951,7 @@ describe("validateAndFixToolResultIds", () => { ], } - validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + validateAndFixToolResultIds(userMessage, [assistantMessage]) // Should be called twice: once for missing, once for mismatch expect(TelemetryService.instance.captureException).toHaveBeenCalledTimes(2) @@ -964,7 +966,7 @@ describe("validateAndFixToolResultIds", () => { }) it("should not call captureException for missing when all tool_results exist", () => { - const assistantMessage = { + const assistantMessage: Anthropic.MessageParam = { role: "assistant", content: [ { @@ -976,7 +978,7 @@ describe("validateAndFixToolResultIds", () => { ], } - const userMessage = { + const userMessage: Anthropic.MessageParam = { role: "user", content: [ { @@ -987,7 +989,7 @@ describe("validateAndFixToolResultIds", () => { ], } - validateAndFixToolResultIds(userMessage as any, [assistantMessage] as any) + validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(TelemetryService.instance.captureException).not.toHaveBeenCalled() }) diff --git a/src/core/task/mergeConsecutiveApiMessages.ts b/src/core/task/mergeConsecutiveApiMessages.ts index 076b4f95f2a..d46d681a94c 100644 --- a/src/core/task/mergeConsecutiveApiMessages.ts +++ b/src/core/task/mergeConsecutiveApiMessages.ts @@ -1,15 +1,12 @@ -import type { RooMessage } from "../task-persistence/rooMessage" -import { isRooReasoningMessage } from "../task-persistence/rooMessage" +import { Anthropic } from "@anthropic-ai/sdk" -type Role = "user" | "assistant" | "tool" +import type { ApiMessage } from "../task-persistence" -/** - * Normalizes message content to an array of content parts. - * Handles both string and array content formats. - */ -function normalizeContentToArray(content: unknown): unknown[] { +type Role = ApiMessage["role"] + +function normalizeContentToBlocks(content: ApiMessage["content"]): Anthropic.Messages.ContentBlockParam[] { if (Array.isArray(content)) { - return content + return content as Anthropic.Messages.ContentBlockParam[] } if (content === undefined || content === null) { return [] @@ -22,28 +19,19 @@ function normalizeContentToArray(content: unknown): unknown[] { * * Used for *API request shaping only* (do not use for storage), so rewind/edit operations * can still reference the original individual messages. - * - * `RooReasoningMessage` items (which have no role) are always passed through unmerged. */ -export function mergeConsecutiveApiMessages(messages: RooMessage[], options?: { roles?: Role[] }): RooMessage[] { +export function mergeConsecutiveApiMessages(messages: ApiMessage[], options?: { roles?: Role[] }): ApiMessage[] { if (messages.length <= 1) { return messages } const mergeRoles = new Set(options?.roles ?? ["user"]) // default: user only - const out: RooMessage[] = [] + const out: ApiMessage[] = [] for (const msg of messages) { - // RooReasoningMessage has no role — always pass through unmerged - if (isRooReasoningMessage(msg)) { - out.push(msg) - continue - } - const prev = out[out.length - 1] - const prevHasRole = prev && !isRooReasoningMessage(prev) const canMerge = - prevHasRole && + prev && prev.role === msg.role && mergeRoles.has(msg.role) && // Allow merging regular messages into a summary (API-only shaping), @@ -57,17 +45,14 @@ export function mergeConsecutiveApiMessages(messages: RooMessage[], options?: { continue } - const mergedContent = [ - ...normalizeContentToArray((prev as any).content), - ...normalizeContentToArray((msg as any).content), - ] + const mergedContent = [...normalizeContentToBlocks(prev.content), ...normalizeContentToBlocks(msg.content)] // Preserve the newest ts to keep chronological ordering for downstream logic. out[out.length - 1] = { ...prev, content: mergedContent, ts: Math.max(prev.ts ?? 0, msg.ts ?? 0) || prev.ts || msg.ts, - } as RooMessage + } } return out diff --git a/src/core/task/validateToolResultIds.ts b/src/core/task/validateToolResultIds.ts index 8d8af476b44..a966d429ed5 100644 --- a/src/core/task/validateToolResultIds.ts +++ b/src/core/task/validateToolResultIds.ts @@ -1,22 +1,6 @@ +import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" import { findLastIndex } from "../../shared/array" -import type { - RooMessage, - RooRoleMessage, - ToolCallPart, - ToolResultPart, - AnyToolCallBlock, - AnyToolResultBlock, -} from "../task-persistence/rooMessage" -import { - isRooRoleMessage, - isAnyToolCallBlock, - isAnyToolResultBlock, - getToolCallId as sharedGetToolCallId, - getToolCallName, - getToolResultCallId as sharedGetToolResultCallId, - setToolResultCallId as sharedSetToolResultCallId, -} from "../task-persistence/rooMessage" /** * Custom error class for tool result ID mismatches. @@ -35,8 +19,8 @@ export class ToolResultIdMismatchError extends Error { /** * Custom error class for missing tool results. - * Used for structured error tracking via PostHog when tool-call blocks - * don't have corresponding tool-result blocks. + * Used for structured error tracking via PostHog when tool_use blocks + * don't have corresponding tool_result blocks. */ export class MissingToolResultError extends Error { constructor( @@ -49,117 +33,116 @@ export class MissingToolResultError extends Error { } } -/** Local aliases for shared dual-format helpers. */ -const isToolCallBlock = isAnyToolCallBlock -const isToolResultBlock = isAnyToolResultBlock -const getToolCallId = sharedGetToolCallId -const getToolResultCallId = sharedGetToolResultCallId -const setToolResultCallId = sharedSetToolResultCallId - /** - * Validates and fixes tool result IDs in a user/tool message against the previous assistant message. + * Validates and fixes tool_result IDs in a user message against the previous assistant message. * - * This is a centralized validation that catches all tool-call/tool-result issues + * This is a centralized validation that catches all tool_use/tool_result issues * before messages are added to the API conversation history. It handles scenarios like: * - Race conditions during streaming * - Message editing scenarios * - Resume/delegation scenarios - * - Missing tool-result blocks for tool-call calls + * - Missing tool_result blocks for tool_use calls * - * @param userMessage - The user or tool message being added to history + * @param userMessage - The user message being added to history * @param apiConversationHistory - The conversation history to find the previous assistant message from - * @returns The validated message with corrected tool call IDs and any missing tool results added + * @returns The validated user message with corrected tool_use_ids and any missing tool_results added */ -export function validateAndFixToolResultIds(userMessage: RooMessage, apiConversationHistory: RooMessage[]): RooMessage { - // Only process messages with array content that have a role - if (!isRooRoleMessage(userMessage) || !Array.isArray(userMessage.content)) { +export function validateAndFixToolResultIds( + userMessage: Anthropic.MessageParam, + apiConversationHistory: Anthropic.MessageParam[], +): Anthropic.MessageParam { + // Only process user messages with array content + if (userMessage.role !== "user" || !Array.isArray(userMessage.content)) { return userMessage } // Find the previous assistant message from conversation history - const prevAssistantIdx = findLastIndex(apiConversationHistory, (msg) => "role" in msg && msg.role === "assistant") + const prevAssistantIdx = findLastIndex(apiConversationHistory, (msg) => msg.role === "assistant") if (prevAssistantIdx === -1) { return userMessage } const previousAssistantMessage = apiConversationHistory[prevAssistantIdx] - // Get tool-call blocks from the assistant message - if (!isRooRoleMessage(previousAssistantMessage)) { - return userMessage - } + // Get tool_use blocks from the assistant message const assistantContent = previousAssistantMessage.content if (!Array.isArray(assistantContent)) { return userMessage } - const toolCallBlocks = (assistantContent as Array<{ type: string }>).filter(isToolCallBlock) + const toolUseBlocks = assistantContent.filter((block): block is Anthropic.ToolUseBlock => block.type === "tool_use") - // No tool-call blocks to match against - no validation needed - if (toolCallBlocks.length === 0) { + // No tool_use blocks to match against - no validation needed + if (toolUseBlocks.length === 0) { return userMessage } - // Find tool-result blocks in the user/tool message - const contentArray = userMessage.content as Array<{ type: string }> - let toolResults = contentArray.filter(isToolResultBlock) + // Find tool_result blocks in the user message + let toolResults = userMessage.content.filter( + (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", + ) - // Deduplicate tool-result blocks to prevent API protocol violations (GitHub #10465) + // Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465) + // This serves as a safety net for any potential race conditions that could generate + // duplicate tool_results with the same tool_use_id. The root cause (approval feedback + // creating duplicate results) has been fixed in presentAssistantMessage.ts, but this + // deduplication remains as a defensive measure for unknown edge cases. const seenToolResultIds = new Set() - const deduplicatedContent = contentArray.filter((block) => { - if (!isToolResultBlock(block)) { + const deduplicatedContent = userMessage.content.filter((block) => { + if (block.type !== "tool_result") { return true } - const callId = getToolResultCallId(block) - if (seenToolResultIds.has(callId)) { + if (seenToolResultIds.has(block.tool_use_id)) { return false // Duplicate - filter out } - seenToolResultIds.add(callId) + seenToolResultIds.add(block.tool_use_id) return true }) userMessage = { ...userMessage, content: deduplicatedContent, - } as RooMessage + } - toolResults = deduplicatedContent.filter(isToolResultBlock) + toolResults = deduplicatedContent.filter( + (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", + ) - // Build a set of valid tool-call IDs - const validToolCallIds = new Set(toolCallBlocks.map(getToolCallId)) + // Build a set of valid tool_use IDs + const validToolUseIds = new Set(toolUseBlocks.map((block) => block.id)) - // Build a set of existing tool-result IDs - const existingToolResultIds = new Set(toolResults.map(getToolResultCallId)) + // Build a set of existing tool_result IDs + const existingToolResultIds = new Set(toolResults.map((r) => r.tool_use_id)) - // Check for missing tool-results (tool-call IDs that don't have corresponding tool-results) - const missingToolCallIds = toolCallBlocks - .filter((tc) => !existingToolResultIds.has(getToolCallId(tc))) - .map(getToolCallId) + // Check for missing tool_results (tool_use IDs that don't have corresponding tool_results) + const missingToolUseIds = toolUseBlocks + .filter((toolUse) => !existingToolResultIds.has(toolUse.id)) + .map((toolUse) => toolUse.id) - // Check if any tool-result has an invalid ID - const hasInvalidIds = toolResults.some((result) => !validToolCallIds.has(getToolResultCallId(result))) + // Check if any tool_result has an invalid ID + const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.tool_use_id)) - // If no missing tool-results and no invalid IDs, no changes needed - if (missingToolCallIds.length === 0 && !hasInvalidIds) { + // If no missing tool_results and no invalid IDs, no changes needed + if (missingToolUseIds.length === 0 && !hasInvalidIds) { return userMessage } // We have issues - need to fix them - const toolResultIdList = toolResults.map(getToolResultCallId) - const toolCallIdList = toolCallBlocks.map(getToolCallId) + const toolResultIdList = toolResults.map((r) => r.tool_use_id) + const toolUseIdList = toolUseBlocks.map((b) => b.id) - // Report missing tool-results to PostHog error tracking - if (missingToolCallIds.length > 0 && TelemetryService.hasInstance()) { + // Report missing tool_results to PostHog error tracking + if (missingToolUseIds.length > 0 && TelemetryService.hasInstance()) { TelemetryService.instance.captureException( new MissingToolResultError( - `Detected missing tool_result blocks. Missing tool_use IDs: [${missingToolCallIds.join(", ")}], existing tool_result IDs: [${toolResultIdList.join(", ")}]`, - missingToolCallIds, + `Detected missing tool_result blocks. Missing tool_use IDs: [${missingToolUseIds.join(", ")}], existing tool_result IDs: [${toolResultIdList.join(", ")}]`, + missingToolUseIds, toolResultIdList, ), { - missingToolUseIds: missingToolCallIds, + missingToolUseIds, existingToolResultIds: toolResultIdList, - toolUseCount: toolCallBlocks.length, + toolUseCount: toolUseBlocks.length, toolResultCount: toolResults.length, }, ) @@ -169,75 +152,83 @@ export function validateAndFixToolResultIds(userMessage: RooMessage, apiConversa if (hasInvalidIds && TelemetryService.hasInstance()) { TelemetryService.instance.captureException( new ToolResultIdMismatchError( - `Detected tool_result ID mismatch. tool_result IDs: [${toolResultIdList.join(", ")}], tool_use IDs: [${toolCallIdList.join(", ")}]`, + `Detected tool_result ID mismatch. tool_result IDs: [${toolResultIdList.join(", ")}], tool_use IDs: [${toolUseIdList.join(", ")}]`, toolResultIdList, - toolCallIdList, + toolUseIdList, ), { toolResultIds: toolResultIdList, - toolUseIds: toolCallIdList, + toolUseIds: toolUseIdList, toolResultCount: toolResults.length, - toolUseCount: toolCallBlocks.length, + toolUseCount: toolUseBlocks.length, }, ) } - // Match tool-results to tool-calls by position and fix incorrect IDs - const usedToolCallIds = new Set() - // userMessage was reassigned above with deduplicatedContent, so we know it has array content - const correctedContentArray = (userMessage as RooRoleMessage).content as Array<{ type: string }> + // Match tool_results to tool_uses by position and fix incorrect IDs + const usedToolUseIds = new Set() + const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[] - const correctedContent = correctedContentArray - .map((block) => { - if (!isToolResultBlock(block)) { + const correctedContent = contentArray + .map((block: Anthropic.Messages.ContentBlockParam) => { + if (block.type !== "tool_result") { return block } - const callId = getToolResultCallId(block) - // If the ID is already valid and not yet used, keep it - if (validToolCallIds.has(callId) && !usedToolCallIds.has(callId)) { - usedToolCallIds.add(callId) + if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) { + usedToolUseIds.add(block.tool_use_id) return block } - // Find which tool-result index this block is by comparing references. - const toolResultIndex = toolResults.indexOf(block) + // Find which tool_result index this block is by comparing references. + // This correctly handles duplicate tool_use_ids - we find the actual block's + // position among all tool_results, not the first block with a matching ID. + const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam) - // Try to match by position - only fix if there's a corresponding tool-call - if (toolResultIndex !== -1 && toolResultIndex < toolCallBlocks.length) { - const correctId = getToolCallId(toolCallBlocks[toolResultIndex]) + // Try to match by position - only fix if there's a corresponding tool_use + if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) { + const correctId = toolUseBlocks[toolResultIndex].id // Only use this ID if it hasn't been used yet - if (!usedToolCallIds.has(correctId)) { - usedToolCallIds.add(correctId) - return setToolResultCallId(block, correctId) + if (!usedToolUseIds.has(correctId)) { + usedToolUseIds.add(correctId) + return { + ...block, + tool_use_id: correctId, + } } } - // No corresponding tool-call for this tool-result, or the ID is already used + // No corresponding tool_use for this tool_result, or the ID is already used return null }) .filter((block): block is NonNullable => block !== null) - // Add missing tool-result blocks for any tool-call that doesn't have one - const coveredToolCallIds = new Set(correctedContent.filter(isToolResultBlock).map(getToolResultCallId)) - - const stillMissingToolCalls = toolCallBlocks.filter((tc) => !coveredToolCallIds.has(getToolCallId(tc))) - - // Build final content: add missing tool-results at the beginning if any - // Create as AI SDK ToolResultPart format - const missingToolResults: ToolResultPart[] = stillMissingToolCalls.map((tc) => ({ - type: "tool-result" as const, - toolCallId: getToolCallId(tc), - toolName: getToolCallName(tc), - output: { type: "text" as const, value: "Tool execution was interrupted before completion." }, + // Add missing tool_result blocks for any tool_use that doesn't have one + const coveredToolUseIds = new Set( + correctedContent + .filter( + (b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam => + b.type === "tool_result", + ) + .map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id), + ) + + const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id)) + + // Build final content: add missing tool_results at the beginning if any + const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({ + type: "tool_result" as const, + tool_use_id: toolUse.id, + content: "Tool execution was interrupted before completion.", })) - // Insert missing tool-results at the beginning of the content array + // Insert missing tool_results at the beginning of the content array + // This ensures they come before any text blocks that may summarize the results const finalContent = missingToolResults.length > 0 ? [...missingToolResults, ...correctedContent] : correctedContent return { ...userMessage, content: finalContent, - } as RooMessage + } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c881cdbaa2f..332ae31c8b5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -97,7 +97,7 @@ import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" -import { readApiMessages, saveApiMessages, saveTaskMessages, type RooMessage } from "../task-persistence" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -1721,7 +1721,7 @@ export class ClineProvider taskDirPath: string apiConversationHistoryFilePath: string uiMessagesFilePath: string - apiConversationHistory: RooMessage[] + apiConversationHistory: Anthropic.MessageParam[] }> { const history = this.getGlobalState("taskHistory") ?? [] const historyItem = history.find((item) => item.id === id) @@ -1737,7 +1737,7 @@ export class ClineProvider const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) - let apiConversationHistory: RooMessage[] = [] + let apiConversationHistory: Anthropic.MessageParam[] = [] if (fileExists) { try { @@ -1793,7 +1793,7 @@ export class ClineProvider useWorkspace: false, fallbackDir: path.join(os.homedir(), "Downloads"), }) - const saveUri = await downloadTask(historyItem.ts, apiConversationHistory as any, defaultUri) + const saveUri = await downloadTask(historyItem.ts, apiConversationHistory, defaultUri) if (saveUri) { await saveLastExportPath(this.contextProxy, "lastTaskExportPath", saveUri) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5880059f68c..5a57fa96788 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1220,7 +1220,7 @@ describe("ClineProvider", () => { // Setup Task instance with auto-mock from the top of the file const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance mockCline.clineMessages = mockMessages // Set test-specific messages - mockCline.apiConversationHistory = mockApiHistory as any // Set API history + mockCline.apiConversationHistory = mockApiHistory // Set API history await provider.addClineToStack(mockCline) // Add the mocked instance to the stack // Mock getTaskWithId @@ -1308,7 +1308,7 @@ describe("ClineProvider", () => { // Setup Task instance with auto-mock from the top of the file const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance mockCline.clineMessages = mockMessages // Set test-specific messages - mockCline.apiConversationHistory = mockApiHistory as any // Set API history + mockCline.apiConversationHistory = mockApiHistory // Set API history // Explicitly mock the overwrite methods since they're not being called in the tests mockCline.overwriteClineMessages = vi.fn() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7438056db55..14a5646d481 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -384,7 +384,9 @@ export const webviewMessageHandler = async ( // Align API history truncation to the same user message timestamp if present const userTs = m.ts if (typeof userTs === "number") { - const apiIdx = currentCline.apiConversationHistory.findIndex((am) => (am as any).ts === userTs) + const apiIdx = currentCline.apiConversationHistory.findIndex( + (am: ApiMessage) => am.ts === userTs, + ) if (apiIdx !== -1) { deleteFromApiIndex = apiIdx } diff --git a/src/integrations/misc/export-markdown.ts b/src/integrations/misc/export-markdown.ts index b0eddd2bbad..d65bb3200e4 100644 --- a/src/integrations/misc/export-markdown.ts +++ b/src/integrations/misc/export-markdown.ts @@ -3,17 +3,6 @@ import os from "os" import * as path from "path" import * as vscode from "vscode" -import { - type AnyToolCallBlock, - type AnyToolResultBlock, - isAnyToolCallBlock, - isAnyToolResultBlock, - getToolCallName, - getToolCallInput, - getToolResultContent, - getToolResultIsError, -} from "../../core/task-persistence/rooMessage" - // Extended content block types to support new Anthropic API features interface ReasoningBlock { type: "reasoning" @@ -75,52 +64,44 @@ export async function downloadTask( } export function formatContentBlockToMarkdown(block: ExtendedContentBlock): string { - // Handle AI SDK tool-call format (alongside legacy tool_use below) - if (isAnyToolCallBlock(block as { type: string })) { - const tcBlock = block as unknown as AnyToolCallBlock - const name = getToolCallName(tcBlock) - const rawInput = getToolCallInput(tcBlock) - let input: string - if (typeof rawInput === "object" && rawInput !== null) { - input = Object.entries(rawInput) - .map(([key, value]) => { - const formattedKey = key.charAt(0).toUpperCase() + key.slice(1) - const formattedValue = - typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) - return `${formattedKey}: ${formattedValue}` - }) - .join("\n") - } else { - input = String(rawInput) - } - return `[Tool Use: ${name}]\n${input}` - } - - // Handle AI SDK tool-result format (alongside legacy tool_result below) - if (isAnyToolResultBlock(block as { type: string })) { - const trBlock = block as unknown as AnyToolResultBlock - const isError = getToolResultIsError(trBlock) - const errorSuffix = isError ? " (Error)" : "" - const rawContent = getToolResultContent(trBlock) - if (typeof rawContent === "string") { - return `[Tool${errorSuffix}]\n${rawContent}` - } else if (Array.isArray(rawContent)) { - return `[Tool${errorSuffix}]\n${rawContent - .map((contentBlock: ExtendedContentBlock) => formatContentBlockToMarkdown(contentBlock)) - .join("\n")}` - } else if (rawContent && typeof rawContent === "object" && "value" in rawContent) { - return `[Tool${errorSuffix}]\n${String((rawContent as { value: unknown }).value)}` - } - return `[Tool${errorSuffix}]` - } - switch (block.type) { case "text": return block.text case "image": return `[Image]` + case "tool_use": { + let input: string + if (typeof block.input === "object" && block.input !== null) { + input = Object.entries(block.input) + .map(([key, value]) => { + const formattedKey = key.charAt(0).toUpperCase() + key.slice(1) + // Handle nested objects/arrays by JSON stringifying them + const formattedValue = + typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) + return `${formattedKey}: ${formattedValue}` + }) + .join("\n") + } else { + input = String(block.input) + } + return `[Tool Use: ${block.name}]\n${input}` + } + case "tool_result": { + // For now we're not doing tool name lookup since we don't use tools anymore + // const toolName = findToolName(block.tool_use_id, messages) + const toolName = "Tool" + if (typeof block.content === "string") { + return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}` + } else if (Array.isArray(block.content)) { + return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content + .map((contentBlock) => formatContentBlockToMarkdown(contentBlock)) + .join("\n")}` + } else { + return `[${toolName}${block.is_error ? " (Error)" : ""}]` + } + } case "reasoning": - return `[Reasoning]\n${(block as ReasoningBlock).text}` + return `[Reasoning]\n${block.text}` case "thoughtSignature": // Not relevant for human-readable exports return "" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index a7298e946a7..48becaf028a 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -1,4 +1,4 @@ -import type { TextPart, ImagePart } from "../core/task-persistence/rooMessage" +import { Anthropic } from "@anthropic-ai/sdk" import type { ClineAsk, @@ -9,7 +9,7 @@ import type { GenerateImageParams, } from "@roo-code/types" -export type ToolResponse = string | Array +export type ToolResponse = string | Array export type AskApproval = ( type: ClineAsk,