From 5e8a771f4f64b2ca83bca9bb3b590e73bc6526ef Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 26 Oct 2025 18:11:18 +0000 Subject: [PATCH] feat: add toggle for single-file read mode to fix model confusion - Add useSingleFileReadMode setting to global settings schema - Update shouldUseSingleFileRead function to check user preference - Add UI toggle in Context settings section - Add comprehensive test coverage - Fixes issue #8848 where models like Qwen3-Coder get confused by multi-file args format --- .../__tests__/single-file-read-models.test.ts | 96 +++++++++++++++++++ packages/types/src/global-settings.ts | 1 + packages/types/src/single-file-read-models.ts | 14 ++- .../presentAssistantMessage.ts | 3 +- src/core/prompts/tools/index.ts | 3 +- src/core/webview/ClineProvider.ts | 3 + .../webview/__tests__/ClineProvider.spec.ts | 1 + src/core/webview/webviewMessageHandler.ts | 4 + src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 1 + .../settings/ContextManagementSettings.tsx | 22 +++++ .../src/components/settings/SettingsView.tsx | 2 + .../src/context/ExtensionStateContext.tsx | 4 + .../__tests__/ExtensionStateContext.spec.tsx | 1 + webview-ui/src/i18n/locales/en/settings.json | 4 + 15 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 packages/types/src/__tests__/single-file-read-models.test.ts diff --git a/packages/types/src/__tests__/single-file-read-models.test.ts b/packages/types/src/__tests__/single-file-read-models.test.ts new file mode 100644 index 000000000000..90df1480305f --- /dev/null +++ b/packages/types/src/__tests__/single-file-read-models.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest" +import { shouldUseSingleFileRead } from "../single-file-read-models.js" + +describe("shouldUseSingleFileRead", () => { + describe("with user setting", () => { + it("should return true when useSingleFileReadMode is true, regardless of model", () => { + // Test various models when user setting is enabled + expect(shouldUseSingleFileRead("claude-3-5-sonnet", true)).toBe(true) + expect(shouldUseSingleFileRead("gpt-4", true)).toBe(true) + expect(shouldUseSingleFileRead("qwen-coder", true)).toBe(true) + expect(shouldUseSingleFileRead("any-random-model", true)).toBe(true) + }) + + it("should return false when useSingleFileReadMode is false and model is not in the list", () => { + // Test models that are not in the single-file list when user setting is disabled + expect(shouldUseSingleFileRead("claude-3-5-sonnet", false)).toBe(false) + expect(shouldUseSingleFileRead("gpt-4", false)).toBe(false) + expect(shouldUseSingleFileRead("qwen-coder", false)).toBe(false) + }) + + it("should respect false setting even for models that normally use single-file mode", () => { + // When user explicitly sets to false, it should override model defaults + expect(shouldUseSingleFileRead("grok-code-fast-1", false)).toBe(false) + expect(shouldUseSingleFileRead("code-supernova", false)).toBe(false) + expect(shouldUseSingleFileRead("some-model-with-grok-code-fast-1-in-name", false)).toBe(false) + }) + }) + + describe("without user setting (undefined)", () => { + it("should return true for models that include the special strings", () => { + // Exact matches + expect(shouldUseSingleFileRead("grok-code-fast-1", undefined)).toBe(true) + expect(shouldUseSingleFileRead("code-supernova", undefined)).toBe(true) + + // Models that contain the special strings + expect(shouldUseSingleFileRead("x/grok-code-fast-1", undefined)).toBe(true) + expect(shouldUseSingleFileRead("provider/code-supernova-v2", undefined)).toBe(true) + expect(shouldUseSingleFileRead("grok-code-fast-1-turbo", undefined)).toBe(true) + }) + + it("should return false for models not in the single-file list", () => { + expect(shouldUseSingleFileRead("claude-3-5-sonnet", undefined)).toBe(false) + expect(shouldUseSingleFileRead("gpt-4", undefined)).toBe(false) + expect(shouldUseSingleFileRead("gemini-pro", undefined)).toBe(false) + expect(shouldUseSingleFileRead("any-other-model", undefined)).toBe(false) + expect(shouldUseSingleFileRead("", undefined)).toBe(false) + }) + + it("should return false when no parameters are provided", () => { + expect(shouldUseSingleFileRead(undefined, undefined)).toBe(false) + }) + }) + + describe("edge cases", () => { + it("should handle empty model string", () => { + expect(shouldUseSingleFileRead("", true)).toBe(true) // User setting takes precedence + expect(shouldUseSingleFileRead("", false)).toBe(false) + expect(shouldUseSingleFileRead("", undefined)).toBe(false) + }) + + it("should handle undefined model", () => { + expect(shouldUseSingleFileRead(undefined, true)).toBe(true) // User setting takes precedence + expect(shouldUseSingleFileRead(undefined, false)).toBe(false) + expect(shouldUseSingleFileRead(undefined, undefined)).toBe(false) + }) + + it("should handle partial model name matches correctly", () => { + // The function uses includes(), so partial matches matter + expect(shouldUseSingleFileRead("grok-code", undefined)).toBe(false) // Doesn't include full "grok-code-fast-1" + expect(shouldUseSingleFileRead("code-fast-1", undefined)).toBe(false) // Doesn't include full "grok-code-fast-1" + expect(shouldUseSingleFileRead("supernova", undefined)).toBe(false) // Doesn't include full "code-supernova" + expect(shouldUseSingleFileRead("grok", undefined)).toBe(false) // Too short + expect(shouldUseSingleFileRead("code", undefined)).toBe(false) // Too short + }) + + it("should be case-sensitive", () => { + // The function uses includes() which is case-sensitive + expect(shouldUseSingleFileRead("GROK-CODE-FAST-1", undefined)).toBe(false) + expect(shouldUseSingleFileRead("Code-Supernova", undefined)).toBe(false) + expect(shouldUseSingleFileRead("Grok-Code-Fast-1", undefined)).toBe(false) + expect(shouldUseSingleFileRead("CODE-SUPERNOVA", undefined)).toBe(false) + }) + }) + + describe("user preference priority", () => { + it("should always prioritize explicit user preference over model defaults", () => { + // User explicitly wants single-file mode for a model that doesn't require it + expect(shouldUseSingleFileRead("claude-3-5-sonnet", true)).toBe(true) + + // User explicitly doesn't want single-file mode for models that typically require it + expect(shouldUseSingleFileRead("grok-code-fast-1", false)).toBe(false) + expect(shouldUseSingleFileRead("code-supernova", false)).toBe(false) + expect(shouldUseSingleFileRead("provider/grok-code-fast-1-latest", false)).toBe(false) + }) + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 334766c3f087..3527b6f8bb4e 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -130,6 +130,7 @@ export const globalSettingsSchema = z.object({ maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), + useSingleFileReadMode: z.boolean().optional(), terminalOutputLineLimit: z.number().optional(), terminalOutputCharacterLimit: z.number().optional(), diff --git a/packages/types/src/single-file-read-models.ts b/packages/types/src/single-file-read-models.ts index 302b8d420231..4bb0719f9ed8 100644 --- a/packages/types/src/single-file-read-models.ts +++ b/packages/types/src/single-file-read-models.ts @@ -7,8 +7,20 @@ /** * Check if a model should use single file read format * @param modelId The model ID to check + * @param useSingleFileReadMode Optional user preference to force single file mode * @returns true if the model should use single file reads */ -export function shouldUseSingleFileRead(modelId: string): boolean { +export function shouldUseSingleFileRead(modelId: string | undefined, useSingleFileReadMode?: boolean): boolean { + // If user has explicitly set the preference, use it (both true and false) + if (useSingleFileReadMode !== undefined) { + return useSingleFileReadMode + } + + // If no modelId provided, default to false + if (!modelId) { + return false + } + + // Otherwise, check if the model is known to have issues with multi-file format return modelId.includes("grok-code-fast-1") || modelId.includes("code-supernova") } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 689675999fd1..0e0f1b842f34 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -470,7 +470,8 @@ export async function presentAssistantMessage(cline: Task) { case "read_file": // Check if this model should use the simplified single-file read tool const modelId = cline.api.getModel().id - if (shouldUseSingleFileRead(modelId)) { + const useSingleFileReadMode = (await cline.providerRef.deref()?.getState())?.useSingleFileReadMode + if (shouldUseSingleFileRead(modelId, useSingleFileReadMode)) { await simpleReadFileTool( cline, block, diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index c212b18a3de4..e3221c02a2d1 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -35,7 +35,8 @@ const toolDescriptionMap: Record string | undefined> read_file: (args) => { // Check if the current model should use the simplified read_file tool const modelId = args.settings?.modelId - if (modelId && shouldUseSingleFileRead(modelId)) { + const useSingleFileReadMode = args.settings?.useSingleFileReadMode + if (modelId && shouldUseSingleFileRead(modelId, useSingleFileReadMode)) { return getSimpleReadFileDescription(args) } return getReadFileDescription(args) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6c1d612943a6..e3ccdae17a75 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1794,6 +1794,7 @@ export class ClineProvider maxReadFileLine, maxImageFileSize, maxTotalImageSize, + useSingleFileReadMode, terminalCompressProgressBar, historyPreviewCollapsed, reasoningBlockCollapsed, @@ -1924,6 +1925,7 @@ export class ClineProvider maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, + useSingleFileReadMode: useSingleFileReadMode ?? false, maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, terminalCompressProgressBar: terminalCompressProgressBar ?? true, @@ -2143,6 +2145,7 @@ export class ClineProvider maxReadFileLine: stateValues.maxReadFileLine ?? -1, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, + useSingleFileReadMode: stateValues.useSingleFileReadMode ?? false, maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 3d68fac2acb0..71dc8037c762 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -562,6 +562,7 @@ describe("ClineProvider", () => { taskSyncEnabled: false, featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + useSingleFileReadMode: false, } const message: ExtensionMessage = { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 38b51c712380..9c1b508da13e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1639,6 +1639,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("maxReadFileLine", message.value) await provider.postStateToWebview() break + case "useSingleFileReadMode": + await updateGlobalState("useSingleFileReadMode", message.bool ?? false) + await provider.postStateToWebview() + break case "maxImageFileSize": await updateGlobalState("maxImageFileSize", message.value) await provider.postStateToWebview() diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 42adea6d3965..9fa252ba448b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -316,6 +316,7 @@ export type ExtensionState = Pick< maxReadFileLine: number // Maximum number of lines to read from a file before truncating maxImageFileSize: number // Maximum size of image files to process in MB maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB + useSingleFileReadMode: boolean // Force use of single-file read mode for models that struggle with multi-file args experiments: Experiments // Map of experiment IDs to their enabled state diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e460f20384c4..ba6ac2521b86 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -174,6 +174,7 @@ export interface WebviewMessage { | "maxReadFileLine" | "maxImageFileSize" | "maxTotalImageSize" + | "useSingleFileReadMode" | "maxConcurrentFileReads" | "includeDiagnosticMessages" | "maxDiagnosticMessages" diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 88484e1d63ba..5da17312ef23 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -22,6 +22,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxReadFileLine?: number maxImageFileSize?: number maxTotalImageSize?: number + useSingleFileReadMode?: boolean maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean @@ -36,6 +37,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxReadFileLine" | "maxImageFileSize" | "maxTotalImageSize" + | "useSingleFileReadMode" | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" @@ -55,6 +57,7 @@ export const ContextManagementSettings = ({ maxReadFileLine, maxImageFileSize, maxTotalImageSize, + useSingleFileReadMode, maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, @@ -435,6 +438,25 @@ export const ContextManagementSettings = ({ : t("settings:contextManagement.condensingThreshold.profileDescription")} + +
+
+ + {t("settings:contextManagement.useSingleFileReadMode.label")} + + + setCachedStateField("useSingleFileReadMode", e.target.checked) + } + data-testid="use-single-file-read-mode-checkbox"> + {t("settings:contextManagement.useSingleFileReadMode.checkboxLabel")} + +
+ {t("settings:contextManagement.useSingleFileReadMode.description")} +
+
+
)} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 385eb2b832e1..a6c02a344e7c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -181,6 +181,7 @@ const SettingsView = forwardRef(({ onDone, t maxReadFileLine, maxImageFileSize, maxTotalImageSize, + useSingleFileReadMode, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, @@ -371,6 +372,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 }) vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 }) vscode.postMessage({ type: "maxTotalImageSize", value: maxTotalImageSize ?? 20 }) + vscode.postMessage({ type: "useSingleFileReadMode", bool: useSingleFileReadMode ?? false }) vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 }) vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages }) vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 05868c31a5d0..01cf48076013 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -140,6 +140,8 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxImageFileSize: (value: number) => void maxTotalImageSize: number setMaxTotalImageSize: (value: number) => void + useSingleFileReadMode: boolean + setUseSingleFileReadMode: (value: boolean) => void machineId?: string pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void @@ -238,6 +240,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode maxReadFileLine: -1, // Default max read file line limit maxImageFileSize: 5, // Default max image file size in MB maxTotalImageSize: 20, // Default max total image size in MB + useSingleFileReadMode: false, // Default to multi-file mode pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting maxConcurrentFileReads: 5, // Default concurrent file reads @@ -531,6 +534,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })), + setUseSingleFileReadMode: (value) => setState((prevState) => ({ ...prevState, useSingleFileReadMode: value })), setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })), setTerminalCompressProgressBar: (value) => setState((prevState) => ({ ...prevState, terminalCompressProgressBar: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 92652733ddf1..cbc5bac540f2 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -215,6 +215,7 @@ describe("mergeExtensionState", () => { taskSyncEnabled: false, featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property + useSingleFileReadMode: false, } const prevState: ExtensionState = { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index e03598358dee..8b64f99c5033 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -580,6 +580,10 @@ "lines": "lines", "always_full_read": "Always read entire file" }, + "useSingleFileReadMode": { + "label": "Force single-file read mode", + "description": "When enabled, forces the read_file tool to use single-file mode syntax. This helps with models like Qwen3-Coder that get confused by the multi-file XML args format and incorrectly use it with other tools. Only enable if you're experiencing 'required parameter not provided' errors." + }, "maxImageFileSize": { "label": "Max image file size", "mb": "MB",