diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 84919f88a4..ea90275239 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3341,6 +3341,10 @@ export class Task extends EventEmitter implements TaskLike { const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] + // Check if current provider is Gemini to decide whether to keep thoughtSignature blocks + const isGeminiProvider = + this.apiConfiguration.apiProvider === "gemini" || this.apiConfiguration.apiProvider === "vertex" + for (const msg of messages) { // Legacy path: standalone reasoning items stored as separate messages if (msg.type === "reasoning" && msg.encrypted_content) { @@ -3365,7 +3369,16 @@ export class Task extends EventEmitter implements TaskLike { ] as Anthropic.Messages.ContentBlockParam[]) : [] - const [first, ...rest] = contentArray + // Filter out thoughtSignature blocks when not using Gemini + const filteredContent = contentArray.filter((block: any) => { + // Keep all blocks except thoughtSignature when not using Gemini + if (block.type === "thoughtSignature" && !isGeminiProvider) { + return false + } + return true + }) + + const [first, ...rest] = filteredContent const hasEmbeddedReasoning = first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" @@ -3399,13 +3412,36 @@ export class Task extends EventEmitter implements TaskLike { continue } + + // Handle the filtered content + let assistantContent: Anthropic.Messages.MessageParam["content"] + if (filteredContent.length === 0) { + assistantContent = "" + } else if (filteredContent.length === 1 && filteredContent[0].type === "text") { + assistantContent = (filteredContent[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = filteredContent + } + + cleanConversationHistory.push({ + role: "assistant", + content: assistantContent, + } satisfies Anthropic.Messages.MessageParam) + + continue } // Default path for regular messages (no embedded reasoning) if (msg.role) { + // Filter out thoughtSignature blocks from user messages too + let content = msg.content + if (Array.isArray(content) && !isGeminiProvider) { + content = content.filter((block: any) => block.type !== "thoughtSignature") + } + cleanConversationHistory.push({ role: msg.role, - content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, + content: content as Anthropic.Messages.ContentBlockParam[] | string, }) } } diff --git a/src/core/task/__tests__/thoughtSignature-filtering.test.ts b/src/core/task/__tests__/thoughtSignature-filtering.test.ts new file mode 100644 index 0000000000..e01eae1e23 --- /dev/null +++ b/src/core/task/__tests__/thoughtSignature-filtering.test.ts @@ -0,0 +1,355 @@ +// npx vitest run src/core/task/__tests__/thoughtSignature-filtering.test.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { Task } from "../Task" +import type { ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +// Mock modules +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: any) => defaultValue })), + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, +})) + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi.fn().mockResolvedValue("/mock/storage/tasks/123"), + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/mock/storage/settings"), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(false), +})) + +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") + +describe("thoughtSignature filtering in buildCleanConversationHistory", () => { + let mockProvider: any + let task: Task + + beforeEach(() => { + // Initialize telemetry service if not already initialized + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + // Create mock provider + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/storage" }, + globalState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + }, + }, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({}), + log: vi.fn(), + } + }) + + it("should keep thoughtSignature blocks when using Gemini provider", () => { + const geminiConfig: ProviderSettings = { + apiProvider: "gemini", + apiModelId: "gemini-3-pro", + geminiApiKey: "test-key", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: geminiConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with thoughtSignature blocks + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Assistant response" }, + { type: "thoughtSignature" as const, thoughtSignature: "test-signature-123" }, + ], + ts: Date.now(), + }, + { + role: "user" as const, + content: [{ type: "text" as const, text: "User message" }], + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the assistant message in the result + const assistantMessage = result.find((msg: any) => msg.role === "assistant") + + // Verify thoughtSignature blocks are preserved for Gemini + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toEqual([ + { type: "text", text: "Assistant response" }, + { type: "thoughtSignature", thoughtSignature: "test-signature-123" }, + ]) + }) + + it("should filter out thoughtSignature blocks when using Claude/Anthropic provider", () => { + const anthropicConfig: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-key", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: anthropicConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with thoughtSignature blocks + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Assistant response" }, + { type: "thoughtSignature" as const, thoughtSignature: "test-signature-123" }, + ], + ts: Date.now(), + }, + { + role: "user" as const, + content: [{ type: "text" as const, text: "User message" }], + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the assistant message in the result + const assistantMessage = result.find((msg: any) => msg.role === "assistant") + + // Verify thoughtSignature blocks are filtered out for non-Gemini providers + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toBe("Assistant response") + }) + + it("should filter out thoughtSignature blocks from user messages for non-Gemini providers", () => { + const openaiConfig: ProviderSettings = { + apiProvider: "openai", + apiModelId: "gpt-4", + openAiApiKey: "test-key", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: openaiConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with thoughtSignature blocks in user message + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "User message" }, + { type: "thoughtSignature" as const, thoughtSignature: "user-signature-456" }, + ], + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the user message in the result + const userMessage = result.find((msg: any) => msg.role === "user") + + // Verify thoughtSignature blocks are filtered out from user messages + expect(userMessage).toBeDefined() + expect(userMessage.content).toEqual([{ type: "text", text: "User message" }]) + }) + + it("should preserve thoughtSignature blocks when using Vertex provider (for Gemini models)", () => { + const vertexConfig: ProviderSettings = { + apiProvider: "vertex", + apiModelId: "gemini-2.5-flash", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: vertexConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with thoughtSignature blocks + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Assistant response" }, + { type: "thoughtSignature" as const, thoughtSignature: "vertex-signature-789" }, + ], + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the assistant message in the result + const assistantMessage = result.find((msg: any) => msg.role === "assistant") + + // Verify thoughtSignature blocks are preserved for Vertex (Gemini models) + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toEqual([ + { type: "text", text: "Assistant response" }, + { type: "thoughtSignature", thoughtSignature: "vertex-signature-789" }, + ]) + }) + + it("should handle empty content arrays correctly", () => { + const anthropicConfig: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-key", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: anthropicConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with only thoughtSignature blocks + const messages = [ + { + role: "assistant" as const, + content: [{ type: "thoughtSignature" as const, thoughtSignature: "only-signature" }], + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the assistant message in the result + const assistantMessage = result.find((msg: any) => msg.role === "assistant") + + // Verify empty string is used when all content is filtered out + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toBe("") + }) + + it("should handle mixed content types correctly", () => { + const anthropicConfig: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-key", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: anthropicConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with mixed content types + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "First text" }, + { type: "thoughtSignature" as const, thoughtSignature: "signature-1" }, + { type: "text" as const, text: "Second text" }, + { type: "thoughtSignature" as const, thoughtSignature: "signature-2" }, + { type: "text" as const, text: "Third text" }, + ], + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the assistant message in the result + const assistantMessage = result.find((msg: any) => msg.role === "assistant") + + // Verify only text blocks remain after filtering + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toEqual([ + { type: "text", text: "First text" }, + { type: "text", text: "Second text" }, + { type: "text", text: "Third text" }, + ]) + }) + + it("should handle string content without modification", () => { + const anthropicConfig: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-key", + } + + task = new Task({ + provider: mockProvider, + apiConfiguration: anthropicConfig, + task: "test task", + startTask: false, + }) + + // Create test messages with string content + const messages = [ + { + role: "assistant" as const, + content: "Simple string message", + ts: Date.now(), + }, + ] + + // Call the private method using bracket notation + const result = (task as any).buildCleanConversationHistory(messages) + + // Find the assistant message in the result + const assistantMessage = result.find((msg: any) => msg.role === "assistant") + + // Verify string content is preserved as-is + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toBe("Simple string message") + }) +})