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\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, "-")