diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 840ffce0dff..cb218917c9a 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -331,10 +331,15 @@ export class WebAuthService extends EventEmitter implements A await this.storeCredentials(credentials) - // Store the provider model if provided + // Store the provider model if provided, or flag that no model was selected if (providerModel) { await this.context.globalState.update("roo-provider-model", providerModel) + await this.context.globalState.update("roo-auth-skip-model", undefined) this.log(`[auth] Stored provider model: ${providerModel}`) + } else { + // No model was selected during signup - flag this for the webview + await this.context.globalState.update("roo-auth-skip-model", true) + this.log(`[auth] No provider model selected during signup`) } const vscode = await importVscode() diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index 7129af4d372..1138ccff021 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -395,9 +395,38 @@ describe("WebAuthService", () => { await authService.handleCallback("auth-code", storedState, null, "xai/grok-code-fast-1") expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-provider-model", "xai/grok-code-fast-1") + expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", undefined) expect(mockLog).toHaveBeenCalledWith("[auth] Stored provider model: xai/grok-code-fast-1") }) + it("should set skip model flag when provider model is NOT provided in callback", async () => { + const storedState = "valid-state" + mockContext.globalState.get.mockReturnValue(storedState) + + // Mock successful Clerk sign-in response + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + response: { created_session_id: "session-123" }, + }), + headers: { + get: (header: string) => (header === "authorization" ? "Bearer token-123" : null), + }, + } + mockFetch.mockResolvedValue(mockResponse) + + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + // Call without provider model + await authService.handleCallback("auth-code", storedState, null) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", true) + expect(mockLog).toHaveBeenCalledWith("[auth] No provider model selected during signup") + }) + it("should handle Clerk API errors", async () => { const storedState = "valid-state" mockContext.globalState.get.mockReturnValue(storedState) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 24c59e5aa0a..5ce94b3cdc8 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.94.0", + "version": "1.95.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/providers/zgsm.ts b/packages/types/src/providers/zgsm.ts index a3c428376b9..662ee91af25 100644 --- a/packages/types/src/providers/zgsm.ts +++ b/packages/types/src/providers/zgsm.ts @@ -8,7 +8,7 @@ export const zgsmModels = { contextWindow: 128_000, maxTokensKey: undefined, supportsImages: false, - supportsNativeTools: true, + supportsNativeTools: undefined, supportsComputerUse: false, supportsPromptCache: true, supportsReasoningBudget: false, diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts new file mode 100644 index 00000000000..1a3b93b9c21 --- /dev/null +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -0,0 +1,77 @@ +import OpenAI from "openai" + +import { OpenAiHandler } from "../openai" + +describe("OpenAiHandler native tools", () => { + it("includes tools in request when custom model info lacks supportsNativeTools (regression test)", async () => { + const mockCreate = vi.fn().mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + // Set openAiCustomModelInfo WITHOUT supportsNativeTools to simulate + // a user-provided custom model info that doesn't specify native tool support. + // The getModel() fix should merge NATIVE_TOOL_DEFAULTS to ensure + // supportsNativeTools defaults to true. + const handler = new OpenAiHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://example.com/v1", + openAiModelId: "test-model", + openAiCustomModelInfo: { + maxTokens: 4096, + contextWindow: 128000, + }, + } as unknown as import("../../../shared/api").ApiHandlerOptions) + + // Patch the OpenAI client call + const mockClient = { + chat: { + completions: { + create: mockCreate, + }, + }, + } as unknown as OpenAI + ;(handler as unknown as { client: OpenAI }).client = mockClient + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "test_tool", + description: "test", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + // Mimic the behavior in Task.attemptApiRequest() where tools are only + // included when modelInfo.supportsNativeTools is true. This is the + // actual regression path being tested - without the getModel() fix, + // supportsNativeTools would be undefined and tools wouldn't be passed. + const modelInfo = handler.getModel().info + const supportsNativeTools = modelInfo.supportsNativeTools ?? false + + const stream = handler.createMessage("system", [], { + taskId: "test-task-id", + ...(supportsNativeTools && { tools }), + ...(supportsNativeTools && { toolProtocol: "native" as const }), + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "test_tool" }), + }), + ]), + parallel_tool_calls: false, + }), + expect.anything(), + ) + }) +}) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index d832508f635..8a80f0d7765 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -90,7 +90,13 @@ export abstract class BaseOpenAiCompatibleProvider model, max_tokens, temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + // Enable mergeToolResultText to merge environment_details and other text content + // after tool_results into the last tool message. This prevents reasoning/thinking + // models from dropping reasoning_content when they see a user message after tool results. + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index 88184cc7c8d..3c4ca2bec25 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -106,7 +106,7 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" // Convert Anthropic messages to OpenAI format (Cerebras is OpenAI-compatible) - const openaiMessages = convertToOpenAiMessages(messages) + const openaiMessages = convertToOpenAiMessages(messages, { mergeToolResultText: true }) // Prepare request body following Cerebras API specification exactly const requestBody: Record = { diff --git a/src/api/providers/chutes.ts b/src/api/providers/chutes.ts index 02dcc152a50..f06e0e76f20 100644 --- a/src/api/providers/chutes.ts +++ b/src/api/providers/chutes.ts @@ -44,7 +44,10 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model, max_tokens, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, ...(metadata?.tools && { tools: metadata.tools }), diff --git a/src/api/providers/deepinfra.ts b/src/api/providers/deepinfra.ts index e9bb2961d3d..46def1c09fe 100644 --- a/src/api/providers/deepinfra.ts +++ b/src/api/providers/deepinfra.ts @@ -72,7 +72,10 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, reasoning_effort, diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts index 3dcd0821b8c..64df12bc972 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -44,7 +44,10 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider() + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. if ("error" in chunk) { @@ -438,22 +446,28 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content let reasoningText: string | undefined if (detail.type === "reasoning.text" && typeof detail.text === "string") { reasoningText = detail.text } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { reasoningText = detail.summary } - // Note: reasoning.encrypted types are intentionally skipped as they contain redacted content if (reasoningText) { + hasYieldedReasoningFromDetails = true yield { type: "reasoning", text: reasoningText } } } - } else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - // Handle legacy reasoning format - only if reasoning_details is not present - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens - yield { type: "reasoning", text: delta.reasoning } + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } + } } // Emit raw tool call chunks - NativeToolCallParser handles state management @@ -488,7 +502,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // After streaming completes, store the accumulated reasoning_details + // After streaming completes, store ONLY the reasoning_details we received from the API. if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) } diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index 1a3cfb0558b..742ea750231 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -222,7 +222,7 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan content: systemPrompt, } - const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText: true })] const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: model.id, diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 649a6ac23fc..a39c9c6644c 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -140,7 +140,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] // Map extended efforts to OpenAI Chat Completions-accepted values (omit unsupported) diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 6519ea01244..75542c38957 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -147,7 +147,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const stream = await this.createStream(systemPrompt, messages, metadata, { headers }) let lastUsage: RooUsage | undefined = undefined - // Accumulator for reasoning_details: accumulate text by type-index key + // Accumulator for reasoning_details FROM the API. + // We preserve the original shape of reasoning_details to prevent malformed responses. const reasoningDetailsAccumulator = new Map< string, { @@ -162,6 +163,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } >() + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + for await (const chunk of stream) { const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason @@ -224,29 +230,32 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content let reasoningText: string | undefined if (detail.type === "reasoning.text" && typeof detail.text === "string") { reasoningText = detail.text } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { reasoningText = detail.summary } - // Note: reasoning.encrypted types are intentionally skipped as they contain redacted content if (reasoningText) { + hasYieldedReasoningFromDetails = true yield { type: "reasoning", text: reasoningText } } } - } else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - // Handle legacy reasoning format - only if reasoning_details is not present - yield { - type: "reasoning", - text: delta.reasoning, + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } } } else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") { // Also check for reasoning_content for backward compatibility - yield { - type: "reasoning", - text: delta.reasoning_content, + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning_content } } } @@ -283,7 +292,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // After streaming completes, store the accumulated reasoning_details + // After streaming completes, store ONLY the reasoning_details we received from the API. if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) } diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 775decf6e0c..0ce8cdb877e 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -86,7 +86,7 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] if (info.supportsPromptCache) { diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index d9692f5a9eb..4378f978210 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -45,7 +45,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] if (VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS.has(modelId) && info.supportsPromptCache) { diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 5b66b9de890..9501804b84a 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -66,7 +66,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler temperature: this.options.modelTemperature ?? XAI_DEFAULT_TEMPERATURE, messages: [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] as OpenAI.Chat.ChatCompletionMessageParam[], stream: true as const, stream_options: { include_usage: true }, diff --git a/src/api/providers/zgsm.ts b/src/api/providers/zgsm.ts index 09294411787..683da01291f 100644 --- a/src/api/providers/zgsm.ts +++ b/src/api/providers/zgsm.ts @@ -7,6 +7,7 @@ import { azureOpenAiDefaultApiVersion, DEEP_SEEK_DEFAULT_TEMPERATURE, OPENAI_AZURE_AI_INFERENCE_PATH, + NATIVE_TOOL_DEFAULTS, zgsmDefaultModelId, zgsmModels, } from "@roo-code/types" @@ -332,7 +333,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl } : { role: "system" as const, content: systemPrompt } - convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText: true })] } // Apply cache control logic @@ -427,7 +428,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) : isLegacyFormat ? [systemMessage, ...convertToSimpleMessages(messages)] - : [systemMessage, ...convertToOpenAiMessages(messages)], + : [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText: true })], ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), ...(metadata?.toolProtocol === "native" && { @@ -605,7 +606,10 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl const id = this.options.zgsmModelId ?? zgsmDefaultModelId const defaultInfo = this.modelInfo const info = this.options.useZgsmCustomConfig - ? (this.options.zgsmAiCustomModelInfo ?? defaultInfo) + ? { + ...NATIVE_TOOL_DEFAULTS, + ...(this.options.zgsmAiCustomModelInfo ?? defaultInfo) + } : defaultInfo const params = getModelParams({ format: "zgsm", modelId: id, model: info, settings: this.options }) return { id, info, ...params } @@ -678,7 +682,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl role: "developer", content: `Formatting re-enabled\n${systemPrompt}`, }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ], stream: true, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), @@ -716,7 +720,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl role: "developer", content: `Formatting re-enabled\n${systemPrompt}`, }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ], reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, temperature: undefined, diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 29fd712c84f..2e7f61c9f34 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -401,4 +401,371 @@ describe("convertToOpenAiMessages", () => { expect(openAiMessages[0].role).toBe("user") }) }) + + describe("reasoning_details transformation", () => { + it("should preserve reasoning_details when assistant content is a string", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Why don't scientists trust atoms? Because they make up everything!", + reasoning_details: [ + { + type: "reasoning.summary", + summary: "The user asked for a joke.", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data_here", + id: "rs_abc", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!") + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") + expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc") + }) + + it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Ok.", + reasoning_details: [ + { + type: "reasoning.summary", + id: "rs_should_be_stripped", + format: "openai-responses-v1", + index: 0, + summary: "internal", + data: "gAAAAA...", + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + }) + + it("should pass through all reasoning_details without extracting to top-level reasoning", () => { + // This simulates the stored format after receiving from xAI/Roo API + // The provider (roo.ts) now consolidates all reasoning into reasoning_details + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "I'll help you with that." }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: '\n\n## Reviewing task progress', + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", + id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + expect(assistantMessage.role).toBe("assistant") + + // Should NOT have top-level reasoning field - we only use reasoning_details now + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details preserving all fields + expect(assistantMessage.reasoning_details).toHaveLength(2) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[0].summary).toBe( + '\n\n## Reviewing task progress', + ) + expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") + expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") + expect(assistantMessage.reasoning_details[1].data).toBe( + "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", + ) + }) + + it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => { + // IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for + // reasoning blocks with format "openai-responses-v1" because we don't use + // `store: true` (we handle conversation state client-side). The error message is: + // "'{id}' not found. Items are not persisted when `store` is set to false." + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh", + name: "attempt_completion", + input: { + result: "Why did the developer go broke?\n\nBecause they used up all their cache.", + }, + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5", + format: "openai-responses-v1", + index: 0, + summary: "It looks like I need to make sure I'm using the tool every time.", + data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field - we only use reasoning_details now + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through reasoning_details preserving most fields BUT stripping id + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + // id should be STRIPPED for openai-responses-v1 format to avoid 404 errors + expect(assistantMessage.reasoning_details[0].id).toBeUndefined() + expect(assistantMessage.reasoning_details[0].summary).toBe( + "It looks like I need to make sure I'm using the tool every time.", + ) + expect(assistantMessage.reasoning_details[0].data).toBe( + "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", + ) + expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") + + // Should have tool_calls + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh") + }) + + it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => { + // For other formats like xai-responses-v1, we should preserve the id + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response" }], + reasoning_details: [ + { + type: "reasoning.encrypted", + id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", + format: "xai-responses-v1", + data: "encrypted_data_here", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should preserve id for xai-responses-v1 format + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") + expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1") + }) + + it("should handle assistant messages with tool_calls and reasoning_details", () => { + // This simulates a message with both tool calls and reasoning + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "call_62462410", + name: "read_file", + input: { files: [{ path: "alphametics.go" }] }, + }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "## Reading the file to understand the structure", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data_here", + id: "rs_12345", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(2) + + // Should have tool_calls + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls[0].id).toBe("call_62462410") + expect(assistantMessage.tool_calls[0].function.name).toBe("read_file") + }) + + it("should pass through reasoning_details with only encrypted blocks", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response text" }], + reasoning_details: [ + { + type: "reasoning.encrypted", + data: "encrypted_data", + id: "rs_only_encrypted", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should still pass through reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") + }) + + it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response text" }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "Just a summary, no encrypted content", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through reasoning_details preserving the summary block + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") + expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content") + }) + + it("should handle messages without reasoning_details", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Simple response" }], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should not have reasoning or reasoning_details + expect(assistantMessage.reasoning).toBeUndefined() + expect(assistantMessage.reasoning_details).toBeUndefined() + }) + + it("should pass through multiple reasoning_details blocks preserving all fields", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Response" }], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "First part of thinking. ", + format: "xai-responses-v1", + index: 0, + }, + { + type: "reasoning.summary", + summary: "Second part of thinking.", + format: "xai-responses-v1", + index: 1, + }, + { + type: "reasoning.encrypted", + data: "encrypted_data", + id: "rs_multi", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as any + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + + expect(openAiMessages).toHaveLength(1) + const assistantMessage = openAiMessages[0] as any + + // Should NOT have top-level reasoning field + expect(assistantMessage.reasoning).toBeUndefined() + + // Should pass through all reasoning_details + expect(assistantMessage.reasoning_details).toHaveLength(3) + expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ") + expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.") + expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") + }) + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index e481864034b..de48d27a3f0 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -27,12 +27,47 @@ export function convertToOpenAiMessages( ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] + const mapReasoningDetails = (details: unknown): any[] | undefined => { + if (!Array.isArray(details)) { + return undefined + } + + return details.map((detail: any) => { + // Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API + // requires `store: true` to persist reasoning blocks. Since we manage + // conversation state client-side, we don't use `store: true`, and sending + // back the `id` field causes a 404 error. + if (detail?.format === "openai-responses-v1" && detail?.id) { + const { id, ...rest } = detail + return rest + } + return detail + }) + } + // Use provided normalization function or identity function const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id) for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { - openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content }) + // 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: anthropicMessage.role, + content: anthropicMessage.content, + } + + if (anthropicMessage.role === "assistant") { + const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) + if (mapped) { + ;(baseMessage as any).reasoning_details = mapped + } + } + + openAiMessages.push(baseMessage) } else { // image_url.url is base64 encoded image data // ensure it contains the content-type of the image: data:image/png;base64, @@ -178,24 +213,24 @@ export function convertToOpenAiMessages( }, })) - // Check if the message has reasoning_details (used by Gemini 3, etc.) + // 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[] } = { + const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_details?: any[] + } = { role: "assistant", content, } - // Add reasoning_details first (before tool_calls) to preserve provider-expected order - // Strip the id field from each reasoning detail as it's only used internally for accumulation - if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) { - baseMessage.reasoning_details = messageWithDetails.reasoning_details.map((detail: any) => { - const { id, ...rest } = detail - return rest - }) + // 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 } // Add tool_calls after reasoning_details diff --git a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts index 24382273cb1..3cb3fb51d03 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -1,5 +1,6 @@ import { getToolUseGuidelinesSection } from "../tool-use-guidelines" import { TOOL_PROTOCOL } from "@roo-code/types" +import { EXPERIMENT_IDS } from "../../../../shared/experiments" describe("getToolUseGuidelinesSection", () => { describe("XML protocol", () => { @@ -35,30 +36,53 @@ describe("getToolUseGuidelinesSection", () => { }) describe("native protocol", () => { - it("should include proper numbered guidelines", () => { - const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, true) + describe("with MULTIPLE_NATIVE_TOOL_CALLS disabled (default)", () => { + it("should include proper numbered guidelines", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) - // Check that all numbered items are present with correct numbering - expect(guidelines).toContain("1. Assess what information") - expect(guidelines).toContain("2. Choose the most appropriate tool") - expect(guidelines).toContain("3. If multiple actions are needed") - expect(guidelines).toContain("4. After each tool use") - }) + // Check that all numbered items are present with correct numbering + expect(guidelines).toContain("1. Assess what information") + expect(guidelines).toContain("2. Choose the most appropriate tool") + expect(guidelines).toContain("3. If multiple actions are needed") + expect(guidelines).toContain("4. After each tool use") + }) + + it("should include single-tool-per-message guidance when experiment disabled", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, {}) + + expect(guidelines).toContain("use one tool at a time per message") + expect(guidelines).not.toContain("you may use multiple tools in a single message") + expect(guidelines).not.toContain("Formulate your tool use using the XML format") + expect(guidelines).not.toContain("ALWAYS wait for user confirmation") + }) - it("should include native protocol-specific guidelines", () => { - const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, true) + it("should include simplified iterative process guidelines", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) - expect(guidelines).toContain("you may use multiple tools in a single message") - expect(guidelines).not.toContain("Formulate your tool use using the XML format") - expect(guidelines).not.toContain("ALWAYS wait for user confirmation") + expect(guidelines).toContain("carefully considering the user's response after tool executions") + // Native protocol doesn't have the step-by-step list + expect(guidelines).not.toContain("It is crucial to proceed step-by-step") + }) }) - it("should include simplified iterative process guidelines", () => { - const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE) + describe("with MULTIPLE_NATIVE_TOOL_CALLS enabled", () => { + it("should include multiple-tools-per-message guidance when experiment enabled", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, { + [EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS]: true, + }) + + expect(guidelines).toContain("you may use multiple tools in a single message") + expect(guidelines).not.toContain("use one tool at a time per message") + }) + + it("should include simplified iterative process guidelines", () => { + const guidelines = getToolUseGuidelinesSection(TOOL_PROTOCOL.NATIVE, { + [EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS]: true, + }) - expect(guidelines).toContain("carefully considering the user's response after tool executions") - // Native protocol doesn't have the step-by-step list - expect(guidelines).not.toContain("It is crucial to proceed step-by-step") + expect(guidelines).toContain("carefully considering the user's response after tool executions") + expect(guidelines).not.toContain("It is crucial to proceed step-by-step") + }) }) }) diff --git a/src/core/prompts/sections/__tests__/tool-use.spec.ts b/src/core/prompts/sections/__tests__/tool-use.spec.ts new file mode 100644 index 00000000000..c8e3a9b5d06 --- /dev/null +++ b/src/core/prompts/sections/__tests__/tool-use.spec.ts @@ -0,0 +1,69 @@ +import { getSharedToolUseSection } from "../tool-use" +import { TOOL_PROTOCOL } from "@roo-code/types" + +describe("getSharedToolUseSection", () => { + describe("XML protocol", () => { + it("should include one tool per message requirement", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.XML) + + expect(section).toContain("You must use exactly one tool per message") + expect(section).toContain("every assistant message must include a tool call") + }) + + it("should include XML formatting instructions", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.XML) + + expect(section).toContain("XML-style tags") + expect(section).toContain("Always use the actual tool name as the XML tag name") + }) + }) + + describe("native protocol", () => { + it("should include one tool per message requirement when experiment is disabled", () => { + // No experiment flags passed (default: disabled) + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE) + + expect(section).toContain("You must use exactly one tool call per assistant response") + expect(section).toContain("Do not call zero tools or more than one tool") + }) + + it("should include one tool per message requirement when experiment is explicitly disabled", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE, { multipleNativeToolCalls: false }) + + expect(section).toContain("You must use exactly one tool call per assistant response") + expect(section).toContain("Do not call zero tools or more than one tool") + }) + + it("should NOT include one tool per message requirement when experiment is enabled", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE, { multipleNativeToolCalls: true }) + + expect(section).not.toContain("You must use exactly one tool per message") + expect(section).not.toContain("every assistant message must include a tool call") + expect(section).toContain("You must call at least one tool per assistant response") + expect(section).toContain("Prefer calling as many tools as are reasonably needed") + }) + + it("should include native tool-calling instructions", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE) + + expect(section).toContain("provider-native tool-calling mechanism") + expect(section).toContain("Do not include XML markup or examples") + }) + + it("should NOT include XML formatting instructions", () => { + const section = getSharedToolUseSection(TOOL_PROTOCOL.NATIVE) + + expect(section).not.toContain("XML-style tags") + expect(section).not.toContain("Always use the actual tool name as the XML tag name") + }) + }) + + describe("default protocol", () => { + it("should default to XML protocol when no protocol is specified", () => { + const section = getSharedToolUseSection() + + expect(section).toContain("XML-style tags") + expect(section).toContain("You must use exactly one tool per message") + }) + }) +}) diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 658fccd6914..a5dad2cc0b9 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,9 +1,11 @@ import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" import { isNativeProtocol } from "@roo-code/types" +import { experiments, EXPERIMENT_IDS } from "../../../shared/experiments" + export function getToolUseGuidelinesSection( protocol: ToolProtocol = TOOL_PROTOCOL.XML, - parallelToolCallsEnabled?: boolean, + experimentFlags?: Record, ): string { // Build guidelines array with automatic numbering let itemNumber = 1 @@ -19,10 +21,22 @@ export function getToolUseGuidelinesSection( ) // Remaining guidelines - different for native vs XML protocol - if (isNativeProtocol(protocol) && parallelToolCallsEnabled) { - guidelinesList.push( - `${itemNumber++}. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, + if (isNativeProtocol(protocol)) { + // Check if multiple native tool calls is enabled via experiment + const isMultipleNativeToolCallsEnabled = experiments.isEnabled( + experimentFlags ?? {}, + EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS, ) + + if (isMultipleNativeToolCallsEnabled) { + guidelinesList.push( + `${itemNumber++}. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, + ) + } else { + guidelinesList.push( + `${itemNumber++}. If multiple actions are needed, use one tool at a time per message to accomplish the task iteratively, with each tool use being informed by the result of the previous tool use. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, + ) + } } else { guidelinesList.push( `${itemNumber++}. If multiple actions are needed, use one tool at a time per message to accomplish the task iteratively, with each tool use being informed by the result of the previous tool use. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.`, diff --git a/src/core/prompts/sections/tool-use.ts b/src/core/prompts/sections/tool-use.ts index 9ece848fb4e..c3f5e221b80 100644 --- a/src/core/prompts/sections/tool-use.ts +++ b/src/core/prompts/sections/tool-use.ts @@ -1,12 +1,27 @@ import { ToolProtocol, TOOL_PROTOCOL, isNativeProtocol } from "@roo-code/types" -export function getSharedToolUseSection(protocol: ToolProtocol = TOOL_PROTOCOL.XML): string { +import { experiments, EXPERIMENT_IDS } from "../../../shared/experiments" + +export function getSharedToolUseSection( + protocol: ToolProtocol = TOOL_PROTOCOL.XML, + experimentFlags?: Record, +): string { if (isNativeProtocol(protocol)) { + // Check if multiple native tool calls is enabled via experiment + const isMultipleNativeToolCallsEnabled = experiments.isEnabled( + experimentFlags ?? {}, + EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS, + ) + + const toolUseGuidance = isMultipleNativeToolCallsEnabled + ? " You must call at least one tool per assistant response. Prefer calling as many tools as are reasonably needed in a single response to reduce back-and-forth and complete tasks faster." + : " You must use exactly one tool call per assistant response. Do not call zero tools or more than one tool in the same response." + return `==== TOOL USE -You have access to a set of tools that are executed upon the user's approval. Use the provider-native tool-calling mechanism. Do not include XML markup or examples.` +You have access to a set of tools that are executed upon the user's approval. Use the provider-native tool-calling mechanism. Do not include XML markup or examples.${toolUseGuidance}` } return `==== diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 4c71d8e96b8..56e4611734f 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -166,9 +166,9 @@ async function generatePrompt(data: { ${markdownFormattingSection()} -${getSharedToolUseSection(effectiveProtocol)}${toolsCatalog} +${getSharedToolUseSection(effectiveProtocol, experiments)}${toolsCatalog} -${getToolUseGuidelinesSection(effectiveProtocol, parallelToolCallsEnabled)} +${getToolUseGuidelinesSection(effectiveProtocol, experiments)} ${mcpServersSection} diff --git a/src/core/prompts/tools/native-tools/write_to_file.ts b/src/core/prompts/tools/native-tools/write_to_file.ts index 8119fd67646..b9e9b313a22 100644 --- a/src/core/prompts/tools/native-tools/write_to_file.ts +++ b/src/core/prompts/tools/native-tools/write_to_file.ts @@ -4,20 +4,16 @@ const WRITE_TO_FILE_DESCRIPTION = `Request to write content to a file. This tool **Important:** You should prefer using other editing tools over write_to_file when making changes to existing files, since write_to_file is slower and cannot handle large files. Use write_to_file primarily for new file creation. -When using this tool, use it directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code. +When using this tool, use it directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. Failure to do so will result in incomplete or broken code. When creating a new project, organize all new files within a dedicated project directory unless the user specifies otherwise. Structure the project logically, adhering to best practices for the specific type of project being created. -Parameters: -- path: (required) The path of the file to write to (relative to the current workspace directory) -- content: (required) The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content. - Example: Writing a configuration file { "path": "frontend-config.json", "content": "{\\n \\"apiEndpoint\\": \\"https://api.example.com\\",\\n \\"theme\\": {\\n \\"primaryColor\\": \\"#007bff\\"\\n }\\n}" }` -const PATH_PARAMETER_DESCRIPTION = `Path to the file to write, relative to the workspace` +const PATH_PARAMETER_DESCRIPTION = `The path of the file to write to (relative to the current workspace directory)` -const CONTENT_PARAMETER_DESCRIPTION = `Full contents that the file should contain with no omissions or line numbers` +const CONTENT_PARAMETER_DESCRIPTION = `The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content.` export default { type: "function", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ff59dc3d781..76b93fbba8d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -329,6 +329,7 @@ export class Task extends EventEmitter implements TaskLike { consecutiveMistakeLimit: number consecutiveMistakeCountForApplyDiff: Map = new Map() consecutiveNoToolUseCount: number = 0 + consecutiveNoAssistantMessagesCount: number = 0 toolUsage: ToolUsage = {} // Checkpoints @@ -1261,14 +1262,42 @@ export class Task extends EventEmitter implements TaskLike { } } } - // No need to ask about tool calls in review mode; this is a temporary measure and needs to be removed later. if (this._taskMode === "review" && type === "tool") { this.approveAsk() } + // Wait for askResponse to be set + await pWaitFor( + () => { + if (this.askResponse !== undefined || this.lastMessageTs !== askTs) { + return true + } - // Wait for askResponse to be set. - await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + // If a queued message arrives while we're blocked on an ask (e.g. a follow-up + // suggestion click that was incorrectly queued due to UI state), consume it + // immediately so the task doesn't hang. + if (!this.messageQueueService.isEmpty()) { + const message = this.messageQueueService.dequeueMessage() + if (message) { + // If this is a tool approval ask, we need to approve first (yesButtonClicked) + // and include any queued text/images. + if ( + type === "tool" || + type === "command" || + type === "browser_action_launch" || + type === "use_mcp_server" + ) { + this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images) + } else { + this.handleWebviewAskResponse("messageResponse", message.text, message.images) + } + } + } + + return false + }, + { interval: 100 }, + ) if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with @@ -2054,6 +2083,7 @@ export class Task extends EventEmitter implements TaskLike { // Reset consecutive error counters on abort (manual intervention) this.consecutiveNoToolUseCount = 0 + this.consecutiveNoAssistantMessagesCount = 0 // Force final token usage update before abort event this.emitFinalTokenUsageUpdate() @@ -3279,6 +3309,8 @@ export class Task extends EventEmitter implements TaskLike { ) if (hasTextContent || hasToolUses) { + // Reset counter when we get a successful response with content + this.consecutiveNoAssistantMessagesCount = 0 // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) @@ -3416,6 +3448,15 @@ export class Task extends EventEmitter implements TaskLike { requestId = this.lastApiRequestHeaders["X-Request-ID"] } + // Increment consecutive no-assistant-messages counter + this.consecutiveNoAssistantMessagesCount++ + + // Only show error and count toward mistake limit after 2 consecutive failures + // This provides a "grace retry" - first failure retries silently + if (this.consecutiveNoAssistantMessagesCount >= 2) { + await this.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + // IMPORTANT: For native tool protocol, we already added the user message to // apiConversationHistory at line 1876. Since the assistant failed to respond, // we need to remove that message before retrying to avoid having two consecutive @@ -3955,7 +3996,7 @@ export class Task extends EventEmitter implements TaskLike { // CRITICAL: Use the task's locked protocol to ensure tasks that started with XML // tools continue using XML even if NTC settings have since changed. const modelInfo = this.api.getModel().info - const taskProtocol = this._taskToolProtocol ?? "xml" + const taskProtocol = this._taskToolProtocol ?? TOOL_PROTOCOL.XML const shouldIncludeTools = taskProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) // Build complete tools array: native tools + dynamic MCP tools, filtered by mode restrictions diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts new file mode 100644 index 00000000000..3b4097a9407 --- /dev/null +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -0,0 +1,38 @@ +import { Task } from "../Task" + +// Keep this test focused: if a queued message arrives while Task.ask() is blocked, +// it should be consumed and used to fulfill the ask. + +describe("Task.ask queued message drain", () => { + it("consumes queued message while blocked on followup ask", async () => { + const task = Object.create(Task.prototype) as Task + ;(task as any).abort = false + ;(task as any).clineMessages = [] + ;(task as any).askResponse = undefined + ;(task as any).askResponseText = undefined + ;(task as any).askResponseImages = undefined + ;(task as any).lastMessageTs = undefined + + // Message queue service exists in constructor; for unit test we can attach a real one. + const { MessageQueueService } = await import("../../message-queue/MessageQueueService") + ;(task as any).messageQueueService = new MessageQueueService() + + // Minimal stubs used by ask() + ;(task as any).addToClineMessages = vi.fn(async () => {}) + ;(task as any).saveClineMessages = vi.fn(async () => {}) + ;(task as any).updateClineMessage = vi.fn(async () => {}) + ;(task as any).cancelAutoApprovalTimeout = vi.fn(() => {}) + ;(task as any).checkpointSave = vi.fn(async () => {}) + ;(task as any).emit = vi.fn() + ;(task as any).providerRef = { deref: () => undefined } + + const askPromise = task.ask("followup", "Q?", false) + + // Simulate webview queuing the user's selection text while the ask is pending. + ;(task as any).messageQueueService.addMessage("picked answer") + + const result = await askPromise + expect(result.response).toBe("messageResponse") + expect(result.text).toBe("picked answer") + }) +}) diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts new file mode 100644 index 00000000000..ccf7e1c5f47 --- /dev/null +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -0,0 +1,464 @@ +// npx vitest core/task/__tests__/grace-retry-errors.spec.ts + +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +// Mock @roo-code/core +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + getTools: vi.fn().mockReturnValue([]), + hasTool: vi.fn().mockReturnValue(false), + getTool: vi.fn().mockReturnValue(undefined), + }, +})) + +// Mock delay before any imports that might use it +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs/promises", async (importOriginal) => { + const actual = (await importOriginal()) as Record + const mockFunctions = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockImplementation(() => Promise.resolve("[]")), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + } + + return { + ...actual, + ...mockFunctions, + default: mockFunctions, + } +}) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("vscode", async (importOriginal) => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + ...(await importOriginal()), + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + })), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + extensions: { + getExtension: (extensionId: string) => ({ + extensionPath: "/mock/extension/path", + extensionUri: { fsPath: "/mock/extension/path", path: "/mock/extension/path", scheme: "file" }, + packageJSON: { + name: "zgsm", + publisher: "zgsm-ai", + version: "2.0.27", + }, + }), + all: [], + }, + EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => { + return Promise.resolve(`processed: ${text}`) + }), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockImplementation(() => false), +})) + +describe("Grace Retry Error Handling", () => { + let mockProvider: any + let mockApiConfig: ProviderSettings + let mockOutputChannel: any + let mockExtensionContext: vscode.ExtensionContext + + beforeEach(() => { + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const storageUri = { + fsPath: path.join(os.tmpdir(), "test-storage"), + } + + mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), + store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + delete: vi.fn().mockImplementation((_key) => Promise.resolve()), + }, + extensionUri: { + fsPath: "/mock/extension/path", + }, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as any + + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.getState = vi.fn().mockResolvedValue({}) + }) + + describe("consecutiveNoAssistantMessagesCount", () => { + it("should initialize to 0", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + + it("should reset to 0 when abortTask is called", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Manually set the counter to simulate consecutive failures + task.consecutiveNoAssistantMessagesCount = 5 + + // Mock dispose to prevent actual cleanup + vi.spyOn(task, "dispose").mockImplementation(() => {}) + + await task.abortTask() + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + + it("should reset consecutiveNoToolUseCount when abortTask is called", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Manually set both counters + task.consecutiveNoAssistantMessagesCount = 3 + task.consecutiveNoToolUseCount = 4 + + // Mock dispose to prevent actual cleanup + vi.spyOn(task, "dispose").mockImplementation(() => {}) + + await task.abortTask() + + // Both counters should be reset + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + expect(task.consecutiveNoToolUseCount).toBe(0) + }) + }) + + describe("consecutiveNoToolUseCount", () => { + it("should initialize to 0", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(task.consecutiveNoToolUseCount).toBe(0) + }) + }) + + describe("Grace Retry Pattern", () => { + it("should not show error on first failure (grace retry)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate first empty response - should NOT show error + task.consecutiveNoAssistantMessagesCount = 0 + task.consecutiveNoAssistantMessagesCount++ + expect(task.consecutiveNoAssistantMessagesCount).toBe(1) + + // First failure: grace retry (silent) + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify error was NOT called (grace retry on first failure) + expect(saySpy).not.toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + + it("should show error after 2 consecutive failures", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate second consecutive empty response + task.consecutiveNoAssistantMessagesCount = 1 + task.consecutiveNoAssistantMessagesCount++ + expect(task.consecutiveNoAssistantMessagesCount).toBe(2) + + // Second failure: should show error + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify error was called (after 2 consecutive failures) + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + + it("should show error on third consecutive failure", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate third consecutive empty response + task.consecutiveNoAssistantMessagesCount = 2 + task.consecutiveNoAssistantMessagesCount++ + expect(task.consecutiveNoAssistantMessagesCount).toBe(3) + + // Third failure: should also show error + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify error was called + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + }) + + describe("Counter Reset on Success", () => { + it("should be able to simulate counter reset when valid content is received", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Simulate some consecutive failures + task.consecutiveNoAssistantMessagesCount = 3 + + // Simulate receiving valid content + const hasTextContent = true + const hasToolUses = false + + if (hasTextContent || hasToolUses) { + task.consecutiveNoAssistantMessagesCount = 0 + } + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + + it("should reset counter when tool uses are present", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Simulate some consecutive failures + task.consecutiveNoAssistantMessagesCount = 2 + + // Simulate receiving tool uses + const hasTextContent = false + const hasToolUses = true + + if (hasTextContent || hasToolUses) { + task.consecutiveNoAssistantMessagesCount = 0 + } + + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + }) + }) + + describe("Error Marker", () => { + it("should use MODEL_NO_ASSISTANT_MESSAGES marker for error display", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate the error condition (2 consecutive failures) + task.consecutiveNoAssistantMessagesCount = 2 + + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + // Verify the exact marker is used + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + }) + }) + + describe("Parallel with noToolsUsed error handling", () => { + it("should have separate counters for noToolsUsed and noAssistantMessages", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Both counters should start at 0 + expect(task.consecutiveNoToolUseCount).toBe(0) + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + + // Incrementing one should not affect the other + task.consecutiveNoToolUseCount = 3 + expect(task.consecutiveNoAssistantMessagesCount).toBe(0) + + task.consecutiveNoAssistantMessagesCount = 2 + expect(task.consecutiveNoToolUseCount).toBe(3) + }) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7da27f095bc..c1bcfad3263 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1145,7 +1145,8 @@ export class ClineProvider const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai" // Extract the domain for CSP const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai" - + // Determine platform information + const platform = isJetbrainsPlatform() ? "jetbrains" : "" const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ "webview-ui", "build", @@ -1207,7 +1208,7 @@ export class ClineProvider CoStrict - +
${reactRefresh} @@ -1268,7 +1269,8 @@ export class ClineProvider const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai" // Extract the domain for CSP const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai" - + // Determine platform information + const platform = isJetbrainsPlatform() ? "jetbrains" : "" // Tip: Install the es6-string-html VS Code extension to enable code highlighting below return /*html*/ ` @@ -1292,7 +1294,7 @@ export class ClineProvider CoStrict - +
@@ -2133,6 +2135,8 @@ export class ClineProvider cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, // cloudOrganizations, + // cloudOrganizations, + cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, sharingEnabled: sharingEnabled ?? false, publicSharingEnabled: publicSharingEnabled ?? false, organizationAllowList, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4d3c1757df3..76c14f62d10 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -370,6 +370,7 @@ export type ExtensionState = Pick< cloudUserInfo: CloudUserInfo | null cloudIsAuthenticated: boolean + cloudAuthSkipModel?: boolean // Flag indicating auth completed without model selection (user should pick 3rd-party provider) cloudApiUrl?: string cloudOrganizations?: CloudOrganizationMembership[] sharingEnabled: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 04d60466451..0364c49e62d 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -124,6 +124,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "hasOpenedModeSelector" + | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" | "cloudLandingPageSignIn" diff --git a/src/utils/resolveToolProtocol.ts b/src/utils/resolveToolProtocol.ts index c8575469540..d30538e30a1 100644 --- a/src/utils/resolveToolProtocol.ts +++ b/src/utils/resolveToolProtocol.ts @@ -41,17 +41,16 @@ export function resolveToolProtocol( if (["openai", "zgsm", "gemini-cli"].includes(_providerSettings.apiProvider || "")) { // If model doesn't support native tools, return XML immediately - // Treat undefined as unsupported (only allow native when explicitly true) - if (_modelInfo?.supportsNativeTools === true) { - return TOOL_PROTOCOL.NATIVE - } - // 1. User Preference - Per-Profile (explicit profile setting, highest priority) if (_providerSettings.toolProtocol) { return _providerSettings.toolProtocol } + // 2.Treat undefined as unsupported (only allow native when explicitly true) + if (_modelInfo?.supportsNativeTools === true) { + return TOOL_PROTOCOL.NATIVE + } - // 2. Model Default - model's preferred protocol + // 3. Model Default - model's preferred protocol if (_modelInfo?.defaultToolProtocol) { return _modelInfo.defaultToolProtocol } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index d58143c1503..2cec3c58922 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1525,6 +1525,7 @@ export const ChatRowContent = ({ case "error": // Check if this is a model response error based on marker strings from backend const isNoToolsUsedError = message.text === "MODEL_NO_TOOLS_USED" + const isNoAssistantMessagesError = message.text === "MODEL_NO_ASSISTANT_MESSAGES" if (isNoToolsUsedError) { return ( @@ -1537,6 +1538,17 @@ export const ChatRowContent = ({ ) } + if (isNoAssistantMessagesError) { + return ( + + ) + } + // Fallback for generic errors return case "completion_result": diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 5045d0a289b..1994f9fef3b 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -519,6 +519,11 @@ const ApiOptions = ({ options.unshift(rooOption) } } else { + // Filter out roo from the welcome view + const filteredOptions = options.filter((opt) => opt.value !== "roo") + options.length = 0 + options.push(...filteredOptions) + const openRouterIndex = options.findIndex((opt) => opt.value === "openrouter") const zgsmIndex = options.findIndex((opt) => opt.value === "zgsm") if (openRouterIndex > 0) { diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 1bcaa210a8a..e466fa70ac3 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -294,7 +294,9 @@ "modelResponseIncomplete": "Model Response Incomplete", "modelResponseErrors": { "noToolsUsed": "The model failed to use any tools in its response. This typically happens when the model provides only text/reasoning without calling the required tools to complete the task.", - "noToolsUsedDetails": "The model provided text/reasoning but did not call any of the required tools. This usually indicates the model misunderstood the task or is having difficulty determining which tool to use. The model has been automatically prompted to retry with proper tool usage." + "noToolsUsedDetails": "The model provided text/reasoning but did not call any of the required tools. This usually indicates the model misunderstood the task or is having difficulty determining which tool to use. The model has been automatically prompted to retry with proper tool usage.", + "noAssistantMessages": "The model did not provide any response content. This may indicate an issue with the API or the model's output.", + "noAssistantMessagesDetails": "The language model returned an empty response without any text or tool calls. This can happen due to API issues, rate limiting, or model-specific problems. The model has been automatically prompted to retry." }, "errorDetails": { "link": "Details", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index f540292d24d..f4dc606c86a 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -260,6 +260,7 @@ "configProfile": "Configuration Profile", "description": "Save different API configurations to quickly switch between providers and settings.", "apiProvider": "API Provider", + "apiProviderDocs": "Provider Docs", "model": "Model", "nameEmpty": "Name cannot be empty", "nameExists": "A profile with this name already exists", diff --git a/webview-ui/src/i18n/locales/en/welcome.json b/webview-ui/src/i18n/locales/en/welcome.json index 46a399997d7..4fbe4be33a9 100644 --- a/webview-ui/src/i18n/locales/en/welcome.json +++ b/webview-ui/src/i18n/locales/en/welcome.json @@ -1,8 +1,4 @@ { - "greeting": "Welcome to Roo Code!", - "introduction": "With a range of built-in and extensible Modes, Roo Code lets you plan, architect, code, debug and boost your productivity like never before.", - "notice": "To get started, this extension needs an API provider.", - "start": "Let's go!", "routers": { "requesty": { "description": "Your optimized LLM router", @@ -16,22 +12,33 @@ "incentive": "Try Roo out for free" } }, - "chooseProvider": "Roo needs an LLM provider to work. Choose yours:", + "landing": { + "greeting": "Welcome to Roo Code!", + "introduction": "With a range of built-in and extensible Modes, Roo Code lets you plan, architect, code, debug and boost your productivity like never before.", + "accountMention": "To get started, create your Roo Code Cloud account. Get powerful models, web control, analytics, support and more.", + "getStarted": "Get started", + "noAccount": "or use without an account" + }, "providerSignup": { + "heading": "Choose your provider", + "chooseProvider": "Roo needs an LLM provider to work. Choose one to get started, you can add more later.", "rooCloudProvider": "Roo Code Cloud Provider", - "rooCloudDescription": "The simplest way to use Roo. A curated mix of free and paid models at a low cost", - "learnMore": "learn more", - "useAnotherProvider": "Use another provider", + "rooCloudDescription": "The easiest way to start is with the Roo Code Cloud Provider: a curated mix of free and paid models at a low cost.", + "learnMore": "Learn more", + "useAnotherProvider": "3rd-party Provider", "useAnotherProviderDescription": "Enter an API key and get going.", - "noApiKeys": "Don't want to deal with keys? Go with the Roo Code Cloud Provider.", - "getStarted": "Get started" + "noApiKeys": "Don't want to deal with API keys and separate accounts?", + "backToRoo": "Go with the Roo Code Cloud Provider.", + "goBack": "Back", + "finish": "Finish" }, "waitingForCloud": { - "heading": "Taking you to Roo Code Cloud...", + "heading": "Logging into Roo Code Cloud...", "description": "We'll take you to your browser to sign up for Roo Code Cloud. We'll then bring you back here to finish.", "noPrompt": "If you don't get prompted to open a URL, click here.", "havingTrouble": "If you've completed sign up but are having trouble, click here.", "pasteUrl": "Paste the callback URL shown in your browser:", + "docsLink": "Not working? Check the docs.", "invalidURL": "That doesn't look like a valid callback URL. Please copy what Roo Code Cloud is showing in your browser.", "goBack": "Go back" }, diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index af1eb38ea26..457b48e44e5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -268,7 +268,9 @@ "modelResponseIncomplete": "模型响应不完整", "modelResponseErrors": { "noToolsUsed": "模型在响应中未使用任何工具。这通常发生在模型仅提供文本/推理而未调用完成任务所需的工具时。", - "noToolsUsedDetails": "模型提供了文本/推理,但未调用任何必需的工具。这通常表明模型误解了任务,或在确定使用哪个工具时遇到困难。系统已自动提示模型使用正确的工具重试。" + "noToolsUsedDetails": "模型提供了文本/推理,但未调用任何必需的工具。这通常表明模型误解了任务,或在确定使用哪个工具时遇到困难。系统已自动提示模型使用正确的工具重试。", + "noAssistantMessages": "模型未提供任何响应内容。这可能表示 API 或模型输出存在问题。", + "noAssistantMessagesDetails": "语言模型返回了一个没有任何文本或工具调用的空响应。这可能是由于 API 问题、速率限制或模型特定问题所导致。系统已自动提示模型重试。" }, "errorDetails": { "link": "详情", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 04d511910e1..9d14652270a 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -251,6 +251,7 @@ "configProfile": "配置文件", "description": "保存多组API配置便于快速切换", "apiProvider": "API提供商", + "apiProviderDocs": "提供商文档", "model": "模型", "nameEmpty": "名称不能为空", "nameExists": "已存在同名的配置文件", diff --git a/webview-ui/src/i18n/locales/zh-CN/welcome.json b/webview-ui/src/i18n/locales/zh-CN/welcome.json index 48405069b1c..df6fe968128 100644 --- a/webview-ui/src/i18n/locales/zh-CN/welcome.json +++ b/webview-ui/src/i18n/locales/zh-CN/welcome.json @@ -1,45 +1,52 @@ { - "greeting": "欢迎使用 Roo Code!", - "introduction": "通过一系列内置和可扩展的模式,Roo Code 让你能够以前所未有的方式进行规划、架构设计、编码、调试并提升工作效率。", - "notice": "请先配置大语言模型API提供商", - "start": "开始吧!", "routers": { "requesty": { - "description": "智能调度多个大语言模型", + "description": "您优化的 LLM 路由器", "incentive": "$1 免费额度" }, "openrouter": { - "description": "统一了大语言模型的接口" + "description": "LLM 统一接口" }, "roo": { - "description": "最优秀的免费模型助你开始", - "incentive": "免费试用 Roo" + "description": "开始使用的最佳免费模型", + "incentive": "免费尝试 Roo" } }, - "chooseProvider": "开始使用需要 LLM 提供商:", + "landing": { + "greeting": "欢迎使用 Roo Code!", + "introduction": "通过一系列内置和可扩展的模式,Roo Code 让你能够以前所未有的方式进行规划、架构设计、编码、调试并提升工作效率。", + "accountMention": "开始使用,创建你的 Roo Code Cloud 账户。获得强大模型、网络控制、分析、支持等。", + "getStarted": "开始使用", + "noAccount": "或不使用账户" + }, "providerSignup": { + "heading": "选择你的供应商", + "chooseProvider": "Roo 需要 LLM 提供商才能工作。选择一个开始使用,你可以稍后添加更多。", "rooCloudProvider": "Roo Code Cloud 提供商", - "rooCloudDescription": "使用 Roo 最简单的方式。低成本精选的免费和付费模型组合", + "rooCloudDescription": "开始使用最简单的方式是使用 Roo Code Cloud 提供商:精心选择的免费和付费模型组合,价格低廉。", "learnMore": "了解更多", - "useAnotherProvider": "使用其他提供商", - "useAnotherProviderDescription": "输入 API 密钥即可开始。", - "noApiKeys": "不想处理密钥?选择 Roo Code Cloud 提供商吧。", - "getStarted": "开始使用" + "useAnotherProvider": "第三方供应商", + "useAnotherProviderDescription": "输入 API 密钥开始使用。", + "noApiKeys": "不想处理 API 密钥和单独的账户?", + "backToRoo": "选择 Roo Code Cloud 提供商。", + "goBack": "返回", + "finish": "完成" }, "waitingForCloud": { - "heading": "正在跳转到 Roo Code Cloud...", - "description": "在浏览器中完成注册,随后你将自动返回此处。", - "noPrompt": "如果你未被提示打开 URL,请点击此处。", - "havingTrouble": "如果你已完成注册但遇到问题,请点击此处。", - "pasteUrl": "从浏览器粘贴回调 URL:", - "invalidURL": "这看起来不像是有效的回调 URL。请复制 Roo Code Cloud 在你的浏览器中显示的内容。", + "heading": "登录到 Roo Code Cloud...", + "description": "我们将带你前往浏览器注册 Roo Code Cloud。然后我们会把你带回这里以完成设置。", + "noPrompt": "如果没有提示你打开 URL,点击这里。", + "havingTrouble": "如果你已完成注册但遇到问题,点击这里。", + "pasteUrl": "粘贴浏览器中显示的回调 URL:", + "docsLink": "无法正常工作?查看文档。", + "invalidURL": "这不像是有效的回调 URL。请复制 Roo Code Cloud 在你的浏览器中显示的内容。", "goBack": "返回" }, - "startRouter": "我们推荐使用 LLM 路由器:", - "startCustom": "或者你可以使用自己的 API 密钥:", + "startRouter": "我们建议使用 LLM 路由器:", + "startCustom": "或者你可以带上自己的 API 密钥:", "telemetry": { - "helpImprove": "Help Improve Roo Code", - "helpImproveMessage": "Roo Code 收集错误和使用数据来帮助我们修复 bug 并改进扩展。此遥测不会收集代码、提示词或个人信息。您可以在设置中关闭此设置。" + "helpImprove": "帮助改进 Roo Code", + "helpImproveMessage": "Roo Code 收集错误和使用数据以帮助我们修复 Bug 并改进扩展。此遥测不收集代码、提示或个人信息。你可以在设置中将其关闭。" }, "importSettings": "导入设置" } diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index cbe599f9658..3b9cf14a850 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -297,7 +297,9 @@ "modelResponseIncomplete": "模型回應不完整", "modelResponseErrors": { "noToolsUsed": "模型在回應中未使用任何工具。這通常發生在模型僅提供文字/推理而未呼叫完成工作所需的工具時。", - "noToolsUsedDetails": "模型提供了文字/推理,但未呼叫任何必需的工具。這通常表示模型誤解了工作,或在確定使用哪個工具時遇到困難。系統已自動提示模型使用正確的工具重試。" + "noToolsUsedDetails": "模型提供了文字/推理,但未呼叫任何必需的工具。這通常表示模型誤解了工作,或在確定使用哪個工具時遇到困難。系統已自動提示模型使用正確的工具重試。", + "noAssistantMessages": "模型未提供任何回應內容。這可能表示 API 或模型輸出存在問題。", + "noAssistantMessagesDetails": "語言模型回傳了一個沒有任何文字或工具呼叫的空回應。這可能是由於 API 問題、速率限制或模型特定問題所導致。系統已自動提示模型重試。" }, "diffError": { "title": "編輯失敗" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index cddbfa60e4b..222199e441a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -251,6 +251,7 @@ "configProfile": "設定檔", "description": "儲存不同的 API 設定以快速切換供應商和設定。", "apiProvider": "API 供應商", + "apiProviderDocs": "供應商文檔", "model": "模型", "nameEmpty": "名稱不能為空", "nameExists": "已存在同名的設定檔", diff --git a/webview-ui/src/i18n/locales/zh-TW/welcome.json b/webview-ui/src/i18n/locales/zh-TW/welcome.json index 7ce67724d1c..4d6c3da122a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/welcome.json +++ b/webview-ui/src/i18n/locales/zh-TW/welcome.json @@ -1,45 +1,52 @@ { - "greeting": "歡迎使用 Roo Code!", - "introduction": "Roo Code 提供一系列內建和可擴充的模式,讓您以前所未有的方式規劃專案、設計架構、編寫程式碼、除錯並提升工作效率。", - "notice": "開始使用前,此擴充功能需要一個 API 提供者。", - "start": "讓我們開始吧!", "routers": { "requesty": { - "description": "您的最佳化 LLM 路由器", + "description": "您最佳化的 LLM 路由器", "incentive": "$1 免費額度" }, "openrouter": { - "description": "LLM 的統一介面" + "description": "LLM 統一介面" }, "roo": { - "description": "最優秀的免費模型助你開始", + "description": "開始使用的最佳免費模型", "incentive": "免費試用 Roo" } }, - "chooseProvider": "開始使用需要 LLM 提供者:", + "landing": { + "greeting": "歡迎使用 Roo Code!", + "introduction": "Roo Code 提供一系列內建和可擴充的模式,讓您以前所未有的方式規劃專案、設計架構、編寫程式碼、除錯並提升工作效率。", + "accountMention": "開始使用,建立您的 Roo Code Cloud 帳戶。取得強大的模型、網路控制、分析、支援等功能。", + "getStarted": "開始使用", + "noAccount": "或不使用帳戶" + }, "providerSignup": { - "rooCloudProvider": "Roo Code Cloud 提供者", - "rooCloudDescription": "使用 Roo 最簡單的方式。低成本精選的免費和付費模型組合", - "learnMore": "了解更多", - "useAnotherProvider": "使用其他提供者", - "useAnotherProviderDescription": "輸入 API 金鑰即可開始。", - "noApiKeys": "不想處理金鑰?選擇 Roo Code Cloud 提供者吧。", - "getStarted": "開始使用" + "heading": "選擇您的供應商", + "chooseProvider": "Roo 需要 LLM 提供商才能運作。選擇一個開始使用,您可以稍後新增更多。", + "rooCloudProvider": "Roo Code Cloud 提供商", + "rooCloudDescription": "開始使用最簡單的方式是使用 Roo Code Cloud 提供商:精選的免費和付費模型組合,價格低廉。", + "learnMore": "深入瞭解", + "useAnotherProvider": "第三方供應商", + "useAnotherProviderDescription": "輸入 API 金鑰並開始使用。", + "noApiKeys": "不想處理 API 金鑰和個別帳戶?", + "backToRoo": "選擇 Roo Code Cloud 提供商。", + "goBack": "返回", + "finish": "完成" }, "waitingForCloud": { - "heading": "正在帶您前往 Roo Code Cloud...", - "description": "在瀏覽器中完成註冊,您將自動返回此處。", - "noPrompt": "如果您未被提示開啟 URL,請點擊此處。", - "havingTrouble": "如果您已完成註冊但遇到問題,請點擊此處。", - "pasteUrl": "從瀏覽器貼上回呼 URL:", - "invalidURL": "這看起來不像是有效的回呼 URL。請複製 Roo Code Cloud 在您的瀏覽器中顯示的內容。", + "heading": "登入 Roo Code Cloud...", + "description": "我們會帶您前往瀏覽器註冊 Roo Code Cloud。然後我們會將您帶回這裡以完成設定。", + "noPrompt": "如果沒有提示您開啟 URL,按一下這裡。", + "havingTrouble": "如果您已完成註冊但遇到問題,按一下這裡。", + "pasteUrl": "貼上瀏覽器中顯示的回撥 URL:", + "docsLink": "無法正常運作?查看文件。", + "invalidURL": "這看起來不像有效的回撥 URL。請複製 Roo Code Cloud 在您的瀏覽器中顯示的內容。", "goBack": "返回" }, "startRouter": "我們建議使用 LLM 路由器:", - "startCustom": "或者您可以使用自己的 API 金鑰:", + "startCustom": "或者您可以帶上自己的 API 金鑰:", "telemetry": { - "helpImprove": "協助改進 Roo Code", - "helpImproveMessage": "Roo Code 會收集錯誤和使用資料,協助我們修復錯誤並改善擴充功能。此遙測不會收集程式碼、提示或個人資訊。您可以在設定中關閉此設定。" + "helpImprove": "幫助改進 Roo Code", + "helpImproveMessage": "Roo Code 收集錯誤和使用情況資料以幫助我們修正錯誤並改進延伸模組。此遙測不會收集程式碼、提示或個人資訊。您可以在設定中將其關閉。" }, "importSettings": "匯入設定" } diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 9decf136fb8..0ddddadf318 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -25,10 +25,11 @@ @theme { --font-display: var(--vscode-font-family); - --text-xs: 12px; - --text-sm: 14px; - --text-base: 16px; - --text-lg: calc(16px * 1.1); + /* 默认字体大小 */ + --text-xs: calc(var(--vscode-font-size) * 0.85); + --text-sm: calc(var(--vscode-font-size) * 0.9); + --text-base: var(--vscode-font-size); + --text-lg: calc(var(--vscode-font-size) * 1.1); --color-background: var(--background); --color-foreground: var(--foreground); @@ -181,6 +182,14 @@ body { --vscode-input-border: var(--border); } + + /* JetBrains 平台 - 增大字体以适应 JetBrains 界面 */ + [data-platform="jetbrains"] { + --text-xs: calc(var(--vscode-font-size) * 0.95); + --text-sm: calc(var(--vscode-font-size) * 1); + --text-base: calc(var(--vscode-font-size) * 1.15); + --text-lg: calc(var(--vscode-font-size) * 1.3); + } } @layer components { @@ -520,3 +529,29 @@ input[cmdk-input]:focus { .diff-content-context { background-color: color-mix(in srgb, var(--vscode-editorGroup-border) 100%, transparent); } + +@keyframes ground-slide { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-30px); + } +} + +.animate-ground-slide { + animation: ground-slide 1s linear infinite; +} + +@keyframes sun { + 0% { + transform: translateX(200px); + } + 100% { + transform: translateX(-200px); + } +} + +.animate-sun { + animation: sun 30s linear infinite; +}