diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index b2ac0d4346..9ba2c98c2c 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -35,7 +35,7 @@ "cmdk": "^1.1.0", "fuzzysort": "^3.1.0", "lucide-react": "^0.518.0", - "next": "~15.2.6", + "next": "~15.2.8", "next-themes": "^0.4.6", "p-map": "^7.0.3", "react": "^18.3.1", diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index cd5af759be..d82cad56ab 100644 --- a/apps/web-roo-code/package.json +++ b/apps/web-roo-code/package.json @@ -25,7 +25,7 @@ "embla-carousel-react": "^8.6.0", "framer-motion": "12.15.0", "lucide-react": "^0.518.0", - "next": "~15.2.6", + "next": "~15.2.8", "next-themes": "^0.4.6", "posthog-js": "^1.248.1", "react": "^18.3.1", diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts index af1c4c70ee..61048f50f0 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -3,7 +3,7 @@ import type { ModelInfo } from "../model.js" // https://ai.google.dev/gemini-api/docs/models/gemini export type GeminiModelId = keyof typeof geminiModels -export const geminiDefaultModelId: GeminiModelId = "gemini-2.5-pro" +export const geminiDefaultModelId: GeminiModelId = "gemini-3-pro-preview" export const geminiModels = { "gemini-3-pro-preview": { @@ -32,6 +32,22 @@ export const geminiModels = { }, ], }, + "gemini-3-flash-preview": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 0.3, + outputPrice: 2.5, + cacheReadsPrice: 0.075, + cacheWritesPrice: 1.0, + }, // 2.5 Pro models "gemini-2.5-pro": { maxTokens: 64_000, diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index 82f317a6a5..373d180cd6 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -32,6 +32,22 @@ export const vertexModels = { }, ], }, + "gemini-3-flash-preview": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 0.3, + outputPrice: 2.5, + cacheReadsPrice: 0.075, + cacheWritesPrice: 1.0, + }, "gemini-2.5-flash-preview-05-20:thinking": { maxTokens: 65_535, contextWindow: 1_048_576, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f6b7da30c..8baa67466a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,8 +196,8 @@ importers: specifier: ^0.518.0 version: 0.518.0(react@18.3.1) next: - specifier: ~15.2.6 - version: 15.2.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ~15.2.8 + version: 15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -305,8 +305,8 @@ importers: specifier: ^0.518.0 version: 0.518.0(react@18.3.1) next: - specifier: ~15.2.6 - version: 15.2.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ~15.2.8 + version: 15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -373,7 +373,7 @@ importers: version: 10.4.22(postcss@8.5.6) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@15.2.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) postcss: specifier: ^8.5.4 version: 8.5.6 @@ -2229,8 +2229,8 @@ packages: '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} - '@next/env@15.2.6': - resolution: {integrity: sha512-kp1Mpm4K1IzSSJ5ZALfek0JBD2jBw9VGMXR/aT7ykcA2q/ieDARyBzg+e8J1TkeIb5AFj/YjtZdoajdy5uNy6w==} + '@next/env@15.2.8': + resolution: {integrity: sha512-TaEsAki14R7BlgywA05t2PFYfwZiNlGUHyIQHVyloXX3y+Dm0HUITe5YwTkjtuOQuDhuuLotNEad4VtnmE11Uw==} '@next/eslint-plugin-next@15.5.7': resolution: {integrity: sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==} @@ -7507,10 +7507,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.2.6: - resolution: {integrity: sha512-DIKFctUpZoCq5ok2ztVU+PqhWsbiqM9xNP7rHL2cAp29NQcmDp7Y6JnBBhHRbFt4bCsCZigj6uh+/Gwh2158Wg==} + next@15.2.8: + resolution: {integrity: sha512-pe2trLKZTdaCuvNER0S9Wp+SP2APf7SfFmyUP9/w1SFA2UqmW0u+IsxCKkiky3n6um7mryaQIlgiDnKrf1ZwIw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -11409,7 +11408,7 @@ snapshots: '@next/env@13.5.11': {} - '@next/env@15.2.6': {} + '@next/env@15.2.8': {} '@next/eslint-plugin-next@15.5.7': dependencies: @@ -17366,22 +17365,22 @@ snapshots: netmask@2.0.2: {} - next-sitemap@4.2.3(next@15.2.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 15.2.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@15.2.6(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.2.6 + '@next/env': 15.2.8 '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 01e747e11b..4e5aef23a5 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -54,7 +54,13 @@ export class DeepSeekHandler extends OpenAiHandler { // Convert messages to R1 format (merges consecutive same-role messages) // This is required for DeepSeek which does not support successive messages with the same role - const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + // For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content + // during tool call sequences. Without this, environment_details text after tool_results would + // create user messages that cause DeepSeek to drop all previous reasoning_content. + // See: https://api-docs.deepseek.com/guides/thinking_mode + const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], { + mergeToolResultText: isThinkingModel, + }) const requestOptions: DeepSeekChatCompletionParams = { model: modelId, diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index c0e3e9103d..7daf186f47 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -141,10 +141,10 @@ describe("convertToBedrockConverseMessages", () => { } }) - it("converts tool result messages correctly", () => { + it("converts tool result messages to XML text format (default, useNativeTools: false)", () => { const messages: Anthropic.Messages.MessageParam[] = [ { - role: "assistant", + role: "user", content: [ { type: "tool_result", @@ -155,6 +155,8 @@ describe("convertToBedrockConverseMessages", () => { }, ] + // Default behavior (useNativeTools: false) converts tool_result to XML text format + // This fixes the Bedrock error "toolConfig field must be defined when using toolUse and toolResult content blocks" const result = convertToBedrockConverseMessages(messages) if (!result[0] || !result[0].content) { @@ -162,7 +164,41 @@ describe("convertToBedrockConverseMessages", () => { return } - expect(result[0].role).toBe("assistant") + expect(result[0].role).toBe("user") + const textBlock = result[0].content[0] as ContentBlock + if ("text" in textBlock) { + expect(textBlock.text).toContain("") + expect(textBlock.text).toContain("test-id") + expect(textBlock.text).toContain("File contents here") + expect(textBlock.text).toContain("") + } else { + expect.fail("Expected text block with XML content not found") + } + }) + + it("converts tool result messages to native format (useNativeTools: true)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "test-id", + content: [{ type: "text", text: "File contents here" }], + }, + ], + }, + ] + + // With useNativeTools: true, keeps tool_result as native format + const result = convertToBedrockConverseMessages(messages, { useNativeTools: true }) + + if (!result[0] || !result[0].content) { + expect.fail("Expected result to have content") + return + } + + expect(result[0].role).toBe("user") const resultBlock = result[0].content[0] as ContentBlock if ("toolResult" in resultBlock && resultBlock.toolResult) { const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }] @@ -176,7 +212,7 @@ describe("convertToBedrockConverseMessages", () => { } }) - it("converts tool result messages with string content correctly", () => { + it("converts tool result messages with string content to XML text format (default)", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -197,6 +233,39 @@ describe("convertToBedrockConverseMessages", () => { return } + expect(result[0].role).toBe("user") + const textBlock = result[0].content[0] as ContentBlock + if ("text" in textBlock) { + expect(textBlock.text).toContain("") + expect(textBlock.text).toContain("test-id") + expect(textBlock.text).toContain("File: test.txt") + expect(textBlock.text).toContain("Hello World") + } else { + expect.fail("Expected text block with XML content not found") + } + }) + + it("converts tool result messages with string content to native format (useNativeTools: true)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "test-id", + content: "File: test.txt\nLines 1-5:\nHello World", + } as any, // Anthropic types don't allow string content but runtime can have it + ], + }, + ] + + const result = convertToBedrockConverseMessages(messages, { useNativeTools: true }) + + if (!result[0] || !result[0].content) { + expect.fail("Expected result to have content") + return + } + expect(result[0].role).toBe("user") const resultBlock = result[0].content[0] as ContentBlock if ("toolResult" in resultBlock && resultBlock.toolResult) { @@ -210,6 +279,56 @@ describe("convertToBedrockConverseMessages", () => { } }) + it("converts both tool_use and tool_result consistently when native tools disabled", () => { + // This test ensures tool_use AND tool_result are both converted to XML text + // when useNativeTools is false, preventing Bedrock toolConfig errors + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call-123", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call-123", + content: "File contents here", + } as any, + ], + }, + ] + + const result = convertToBedrockConverseMessages(messages) // default useNativeTools: false + + // Both should be text blocks, not native toolUse/toolResult + const assistantContent = result[0]?.content?.[0] as ContentBlock + const userContent = result[1]?.content?.[0] as ContentBlock + + // tool_use should be XML text + expect("text" in assistantContent).toBe(true) + if ("text" in assistantContent) { + expect(assistantContent.text).toContain("") + } + + // tool_result should also be XML text (this is what the fix addresses) + expect("text" in userContent).toBe(true) + if ("text" in userContent) { + expect(userContent.text).toContain("") + } + + // Neither should have native format + expect("toolUse" in assistantContent).toBe(false) + expect("toolResult" in userContent).toBe(false) + }) + it("handles text content correctly", () => { const messages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts index edfe9dc5d1..3d875e9392 100644 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ b/src/api/transform/__tests__/r1-format.spec.ts @@ -394,5 +394,226 @@ describe("convertToR1Format", () => { content: "Follow up response", }) }) + + describe("mergeToolResultText option for DeepSeek interleaved thinking", () => { + it("should merge text content into last tool message when mergeToolResultText is true", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce only one tool message with merged content + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result content\n\n\nSome context\n", + }) + }) + + it("should NOT merge text when mergeToolResultText is false (default behavior)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result content", + }, + { + type: "text", + text: "Please continue", + }, + ], + }, + ] + + // Without option (default behavior) + const result = convertToR1Format(input) + + // Should produce two messages: tool message + user message + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result content", + }) + expect(result[1]).toEqual({ + role: "user", + content: "Please continue", + }) + }) + + it("should merge text into last tool message when multiple tool results exist", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "First result", + }, + { + type: "tool_result", + tool_use_id: "call_2", + content: "Second result", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce two tool messages, with text merged into the last one + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: "First result", + }) + expect(result[1]).toEqual({ + role: "tool", + tool_call_id: "call_2", + content: "Second result\n\nContext", + }) + }) + + it("should NOT merge when there are images (images need user message)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_123", + content: "Tool result", + }, + { + type: "text", + text: "Check this image", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/jpeg", + data: "imagedata", + }, + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce tool message + user message with image + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Tool result", + }) + expect(result[1]).toMatchObject({ + role: "user", + content: expect.arrayContaining([ + { type: "text", text: "Check this image" }, + { type: "image_url", image_url: expect.any(Object) }, + ]), + }) + }) + + it("should NOT merge when there are no tool results (text-only should remain user message)", () => { + const input: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Just a regular message", + }, + ], + }, + ] + + const result = convertToR1Format(input, { mergeToolResultText: true }) + + // Should produce user message as normal + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: "Just a regular message", + }) + }) + + it("should preserve reasoning_content on assistant messages in same conversation", () => { + const input = [ + { role: "user" as const, content: "Start" }, + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_123", + name: "test_tool", + input: {}, + }, + ], + reasoning_content: "Let me think about this...", + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_123", + content: "Result", + }, + { + type: "text" as const, + text: "Context", + }, + ], + }, + ] + + const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], { + mergeToolResultText: true, + }) + + // Should have: user, assistant (with reasoning + tool_calls), tool + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ role: "user", content: "Start" }) + expect((result[1] as any).reasoning_content).toBe("Let me think about this...") + expect((result[1] as any).tool_calls).toBeDefined() + // Tool message should have merged content + expect(result[2]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Result\n\nContext", + }) + // Most importantly: NO user message after tool message + expect(result.filter((m) => m.role === "user")).toHaveLength(1) + }) + }) }) }) diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts index b6f9b7232a..1a8e49a20b 100644 --- a/src/api/transform/bedrock-converse-format.ts +++ b/src/api/transform/bedrock-converse-format.ts @@ -111,7 +111,50 @@ export function convertToBedrockConverseMessages( } if (messageBlock.type === "tool_result") { - // Handle content field - can be string or array + // When NOT using native tools, convert tool_result to text format + // This matches how tool_use is converted to XML text when native tools are disabled. + // Without this, Bedrock will error with "toolConfig field must be defined when using + // toolUse and toolResult content blocks" because toolResult blocks require toolConfig. + if (!useNativeTools) { + let toolResultContent: string + if (messageBlock.content) { + if (typeof messageBlock.content === "string") { + toolResultContent = messageBlock.content + } else if (Array.isArray(messageBlock.content)) { + toolResultContent = messageBlock.content + .map((item) => (typeof item === "string" ? item : item.text || String(item))) + .join("\n") + } else { + toolResultContent = String(messageBlock.output || "") + } + } else if (messageBlock.output) { + if (typeof messageBlock.output === "string") { + toolResultContent = messageBlock.output + } else if (Array.isArray(messageBlock.output)) { + toolResultContent = messageBlock.output + .map((part) => { + if (typeof part === "object" && "text" in part) { + return part.text + } + if (typeof part === "object" && "type" in part && part.type === "image") { + return "(see following message for image)" + } + return String(part) + }) + .join("\n") + } else { + toolResultContent = String(messageBlock.output) + } + } else { + toolResultContent = "" + } + + return { + text: `\n${messageBlock.tool_use_id || ""}\n${toolResultContent}\n`, + } as ContentBlock + } + + // Handle content field - can be string or array (native tool format) if (messageBlock.content) { // Content is a string if (typeof messageBlock.content === "string") { diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts index d4a7bef1ae..8231e24f76 100644 --- a/src/api/transform/r1-format.ts +++ b/src/api/transform/r1-format.ts @@ -26,11 +26,20 @@ export type DeepSeekAssistantMessage = AssistantMessage & { * - Preserves reasoning_content on assistant messages for tool call continuations * - Tool result messages are converted to OpenAI tool messages * - reasoning_content from previous assistant messages is preserved until a new user turn + * - Text content after tool_results (like environment_details) is merged into the last tool message + * to avoid creating user messages that would cause reasoning_content to be dropped * * @param messages Array of Anthropic messages + * @param options Optional configuration for message conversion + * @param options.mergeToolResultText If true, merge text content after tool_results into the last + * tool message instead of creating a separate user message. + * This is critical for DeepSeek's interleaved thinking mode. * @returns Array of OpenAI messages where consecutive messages with the same role are combined */ -export function convertToR1Format(messages: AnthropicMessage[]): Message[] { +export function convertToR1Format( + messages: AnthropicMessage[], + options?: { mergeToolResultText?: boolean }, +): Message[] { const result: Message[] = [] for (const message of messages) { @@ -87,37 +96,54 @@ export function convertToR1Format(messages: AnthropicMessage[]): Message[] { result.push(toolMessage) } - // Then add user message with text/image content if any + // Handle text/image content after tool results if (textParts.length > 0 || imageParts.length > 0) { - let content: UserMessage["content"] - if (imageParts.length > 0) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) + // For DeepSeek interleaved thinking: when mergeToolResultText is enabled and we have + // tool results followed by text, merge the text into the last tool message to avoid + // creating a user message that would cause reasoning_content to be dropped. + // This is critical because DeepSeek drops all reasoning_content when it sees a user message. + const shouldMergeIntoToolMessage = + options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 + + if (shouldMergeIntoToolMessage) { + // Merge text content into the last tool message + const lastToolMessage = result[result.length - 1] as ToolMessage + if (lastToolMessage?.role === "tool") { + const additionalText = textParts.join("\n") + lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` } - parts.push(...imageParts) - content = parts } else { - content = textParts.join("\n") - } + // Standard behavior: add user message with text/image content + let content: UserMessage["content"] + if (imageParts.length > 0) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + content = parts + } else { + content = textParts.join("\n") + } - // Check if we can merge with the last message - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - // Merge with existing user message - if (typeof lastMessage.content === "string" && typeof content === "string") { - lastMessage.content += `\n${content}` + // Check if we can merge with the last message + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "user") { + // Merge with existing user message + if (typeof lastMessage.content === "string" && typeof content === "string") { + lastMessage.content += `\n${content}` + } else { + const lastContent = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text" as const, text: lastMessage.content || "" }] + const newContent = Array.isArray(content) + ? content + : [{ type: "text" as const, text: content }] + lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] + } } else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - const newContent = Array.isArray(content) - ? content - : [{ type: "text" as const, text: content }] - lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] + result.push({ role: "user", content }) } - } else { - result.push({ role: "user", content }) } } } else { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1052757180..0e32f93f86 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1416,57 +1416,11 @@ export class Task extends EventEmitter implements TaskLike { } async handleTerminalOperation(terminalOperation: "continue" | "abort", pid?: number, executionId?: string) { + const _process = this.terminalProcess || this.persistedTerminalProcess if (terminalOperation === "continue") { - // Try terminalProcess first, fall back to persistedTerminalProcess - const process = this.terminalProcess || this.persistedTerminalProcess - if (process) { - process.continue() - } + _process?.continue() } else if (terminalOperation === "abort") { - const provider = this.providerRef.deref() - if (this.terminalProcess) { - this.terminalProcess.abort() - } else { - // Use provided pid or fall back to currentProcessPid - const targetPid = pid || this.currentProcessPid - if (targetPid) { - psTree(targetPid, async (err, children) => { - if (!err) { - const pids = children.map((p) => parseInt(p.PID)) - console.error(`[ExecaTerminalProcess#abort] SIGKILL children -> ${pids.join(", ")}`) - - for (const pid of pids) { - try { - process.kill(pid, "SIGKILL") - } catch (e) { - console.warn( - `[ExecaTerminalProcess#abort] Failed to send SIGKILL to child PID ${pid}: ${e instanceof Error ? e.message : String(e)}`, - ) - } - } - } else { - console.error( - `[ExecaTerminalProcess#abort] Failed to get process tree for PID ${targetPid}: ${err.message}`, - ) - } - - try { - process.kill(targetPid, "SIGKILL") - if (executionId) { - const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: 9 } - this.providerRef.deref()?.postMessageToWebview({ - type: "commandExecutionStatus", - text: JSON.stringify(status), - }) - } - } catch (e) { - console.warn( - `[ExecaTerminalProcess#abort#handleTerminalOperation] Failed to kill process ${targetPid}: ${e instanceof Error ? e.message : String(e)}`, - ) - } - }) - } - } + _process?.abort() } } diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 16b8d3c6a0..a11144fd19 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -128,6 +128,8 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } pushToolResult(result) + } else if ((error as any)["__IS_ESRCH__"]) { + pushToolResult((error as any).message) } else { pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) } @@ -276,7 +278,6 @@ export async function executeCommandInTerminal( // command actually executed. workingDir = terminal.getCurrentWorkingDirectory() } - console.time("runCommand") // Clear old persisted process when starting a new command task.persistedTerminalProcess = undefined @@ -326,7 +327,6 @@ export async function executeCommandInTerminal( try { await process } finally { - console.timeEnd("runCommand") clearTerminalProcess(task) } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 6a5c645357..fc1980b896 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -75,6 +75,7 @@ "url_fetch_failed": "Failed to fetch URL content: {{error}}", "url_fetch_error_with_url": "Error fetching content for {{url}}: {{error}}", "command_timeout": "Command execution timed out after {{seconds}} seconds", + "command_esrch": "Command Abort Failed: failed to find target process. pid: ({{pid}}) command: ({{command}})", "share_task_failed": "Failed to share task. Please try again.", "share_no_active_task": "No active task to share", "share_auth_required": "Authentication required. Please sign in to share tasks.", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 3ed951dbce..8f5d3eba3b 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -80,6 +80,7 @@ "url_fetch_failed": "获取 URL 内容失败:{{error}}", "url_fetch_error_with_url": "获取 {{url}} 内容时出错:{{error}}", "command_timeout": "命令执行超时,{{seconds}} 秒后", + "command_esrch": "中止命令失败:无法找到目标进程,pid: ({{pid}}) command: ({{command}})", "share_task_failed": "分享任务失败。请重试。", "share_no_active_task": "没有活跃任务可分享", "share_auth_required": "需要身份验证。请登录以分享任务。", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index cc57567d5c..782e574967 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -75,6 +75,7 @@ "url_fetch_failed": "取得 URL 內容失敗:{{error}}", "url_fetch_error_with_url": "取得 {{url}} 內容時發生錯誤:{{error}}", "command_timeout": "命令執行超時,{{seconds}} 秒後", + "command_esrch": "中止命令失敗:無法找到目標進程,pid: ({{pid}}) command: ({{command}})", "share_task_failed": "分享工作失敗。請重試。", "share_no_active_task": "沒有活躍的工作可分享", "share_auth_required": "需要身份驗證。請登入以分享工作。", diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index 1cf739f5a7..07f95409ce 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -7,6 +7,7 @@ import type { RooTerminal } from "./types" import { BaseTerminalProcess } from "./BaseTerminalProcess" import { getIdeaShellEnvWithUpdatePath } from "../../utils/ideaShellEnvLoader" import { isJetbrainsPlatform } from "../../utils/platform" +import { t } from "../../i18n" export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef @@ -188,6 +189,15 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { try { process.kill(this.pid, "SIGKILL") } catch (e) { + // "error" + if (e.code === "ESRCH") { + const error = new Error( + t("common:errors.command_esrch", { pid: this.pid, command: this.command }), + ) + Object.assign(error, { __IS_ESRCH__: true }) + // this.emit("shell_execution_complete", { exitCode: e.exitCode ?? -1, signalName: e.signal ?? t("common:errors.command_esrch", { pid: this.pid, command: this.command })}) + this.emit("error", error) + } console.warn( `[ExecaTerminalProcess#abort] Failed to kill process ${this.pid}: ${e instanceof Error ? e.message : String(e)}`, ) diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 6bdd6c4bcf..ef36789319 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -117,10 +117,11 @@ describe("ExecaTerminalProcess", () => { it("should set and clear active stream", async () => { await terminalProcess.run("echo test") - expect(mockTerminal.setActiveStream).toHaveBeenCalledTimes(2) + const setActiveStreamMock = vitest.mocked(mockTerminal.setActiveStream) + expect(setActiveStreamMock).toHaveBeenCalledTimes(2) // First call sets the stream, second call clears it - expect(mockTerminal.setActiveStream.mock.calls[0][0]).toBeDefined() - expect(mockTerminal.setActiveStream.mock.calls[1][0]).toBeUndefined() + expect(setActiveStreamMock.mock.calls[0][0]).toBeDefined() + expect(setActiveStreamMock.mock.calls[1][0]).toBeUndefined() }) }) }) diff --git a/src/integrations/terminal/__tests__/shellEncoding.test.ts b/src/integrations/terminal/__tests__/shellEncoding.test.ts index 9eff87d63e..24e66f5418 100644 --- a/src/integrations/terminal/__tests__/shellEncoding.test.ts +++ b/src/integrations/terminal/__tests__/shellEncoding.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { execa } from "execa" +import { execa, Options } from "execa" import { ExecaTerminalProcess } from "../ExecaTerminalProcess" import { getShell } from "../../../utils/shell" @@ -58,7 +58,7 @@ describe("ExecaTerminalProcess Shell Encoding", () => { encoding: "buffer", }) // Verify shell property exists (can be true or a path) - expect(call[0].shell).toBeTruthy() + expect((call[0] as Options).shell).toBeTruthy() }) it("should use CMD encoding command for CMD", async () => { @@ -91,7 +91,7 @@ describe("ExecaTerminalProcess Shell Encoding", () => { encoding: "buffer", }) // Verify shell property exists (can be true or a path) - expect(call[0].shell).toBeTruthy() + expect((call[0] as Options).shell).toBeTruthy() }) it("should not modify command on non-Windows platforms", async () => { @@ -129,7 +129,7 @@ describe("ExecaTerminalProcess Shell Encoding", () => { encoding: "buffer", }) // Verify shell property exists (can be true or a path) - expect(call[0].shell).toBeTruthy() + expect((call[0] as Options).shell).toBeTruthy() } finally { // Restore original platform Object.defineProperty(global.process, "platform", { @@ -169,7 +169,7 @@ describe("ExecaTerminalProcess Shell Encoding", () => { encoding: "buffer", }) // Verify shell property exists (can be true or a path) - expect(call[0].shell).toBeTruthy() + expect((call[0] as Options).shell).toBeTruthy() }) it("should handle legacy PowerShell path correctly", async () => { @@ -202,6 +202,6 @@ describe("ExecaTerminalProcess Shell Encoding", () => { encoding: "buffer", }) // Verify shell property exists (can be true or a path) - expect(call[0].shell).toBeTruthy() + expect((call[0] as Options).shell).toBeTruthy() }) }) diff --git a/src/utils/__tests__/mcp-name.spec.ts b/src/utils/__tests__/mcp-name.spec.ts index 76c069ee8d..b28c2e504c 100644 --- a/src/utils/__tests__/mcp-name.spec.ts +++ b/src/utils/__tests__/mcp-name.spec.ts @@ -23,18 +23,24 @@ describe("mcp-name utilities", () => { expect(sanitizeMcpName("test#$%^&*()")).toBe("test") }) - it("should keep valid characters (alphanumeric, underscore, dot, colon, dash)", () => { + it("should keep valid characters (alphanumeric, underscore, dash)", () => { expect(sanitizeMcpName("server_name")).toBe("server_name") - expect(sanitizeMcpName("server.name")).toBe("server.name") - expect(sanitizeMcpName("server:name")).toBe("server:name") expect(sanitizeMcpName("server-name")).toBe("server-name") expect(sanitizeMcpName("Server123")).toBe("Server123") }) + it("should remove dots and colons for AWS Bedrock compatibility", () => { + // Dots and colons are NOT allowed due to AWS Bedrock restrictions + expect(sanitizeMcpName("server.name")).toBe("servername") + expect(sanitizeMcpName("server:name")).toBe("servername") + expect(sanitizeMcpName("awslabs.aws-documentation-mcp-server")).toBe("awslabsaws-documentation-mcp-server") + }) + it("should prepend underscore if name starts with non-letter/underscore", () => { expect(sanitizeMcpName("123server")).toBe("_123server") expect(sanitizeMcpName("-server")).toBe("_-server") - expect(sanitizeMcpName(".server")).toBe("_.server") + // Dots are removed, so ".server" becomes "server" which starts with a letter + expect(sanitizeMcpName(".server")).toBe("server") }) it("should not modify names that start with letter or underscore", () => { diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index af50f392cf..55845d67ed 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -1,17 +1,12 @@ /** * Utilities for sanitizing MCP server and tool names to conform to - * API function name requirements (e.g., Gemini's restrictions). - * - * Gemini function name requirements: - * - Must start with a letter or an underscore - * - Must be alphanumeric (a-z, A-Z, 0-9), underscores (_), dots (.), colons (:), or dashes (-) - * - Maximum length of 64 characters + * API function name requirements across all providers. */ /** * Separator used between MCP prefix, server name, and tool name. * We use "--" (double hyphen) because: - * 1. It's allowed by Gemini (dashes are permitted in function names) + * 1. It's allowed by all providers (dashes are permitted in function names) * 2. It won't conflict with underscores in sanitized server/tool names * 3. It's unique enough to be a reliable delimiter for parsing */ @@ -40,8 +35,8 @@ export function sanitizeMcpName(name: string): string { // Replace spaces with underscores first let sanitized = name.replace(/\s+/g, "_") - // Remove any characters that are not alphanumeric, underscores, dots, colons, or dashes - sanitized = sanitized.replace(/[^a-zA-Z0-9_.\-:]/g, "") + // Only allow alphanumeric, underscores, and dashes + sanitized = sanitized.replace(/[^a-zA-Z0-9_\-]/g, "") // Replace any double-hyphen sequences with single hyphen to avoid separator conflicts sanitized = sanitized.replace(/--+/g, "-")