diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 1b20597dd97..fccbad330ef 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -55,7 +55,7 @@ vi.mock("vscode", () => { }) import * as vscode from "vscode" -import { openAiModelInfoSaneDefaults, vscodeLlmModels } from "@roo-code/types" +import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" import { VsCodeLmHandler, extractLeakedToolCalls, @@ -122,13 +122,41 @@ describe("VsCodeLmHandler", () => { opusHandler.dispose() }) - it("falls back to the live model context window for families not in the static table", () => { - // "test-family" isn't in vscodeLlmModels; with a live client present we fall back to - // getModel().info.contextWindow (the live maxInputTokens). + it("falls back to the default-row maxInputTokens for an unknown family (catalog drift)", () => { + // "test-family" isn't a curated row, so the gate resolves the default row rather than + // trusting the live window, which VS Code inflates for some models. handler["client"] = mockLanguageModelChat as unknown as vscode.LanguageModelChat - expect(handler.getCondenseContextWindow()).toBe(handler.getModel().info.contextWindow) - expect(handler.getCondenseContextWindow()).toBe(mockLanguageModelChat.maxInputTokens) + expect(handler.getCondenseContextWindow()).toBe(vscodeLlmModels[vscodeLlmDefaultModelId].maxInputTokens) + }) + + it("falls back to the default-row maxInputTokens when no family is resolvable", () => { + const noFamilyHandler = new VsCodeLmHandler({ vsCodeLmModelSelector: { vendor: "copilot" } }) + noFamilyHandler["client"] = null + + expect(noFamilyHandler.getCondenseContextWindow()).toBe( + vscodeLlmModels[vscodeLlmDefaultModelId].maxInputTokens, + ) + + noFamilyHandler.dispose() + }) + + it("falls back to the live window when the static row's maxInputTokens is non-positive", () => { + const family = "claude-opus-4.8" + const original = vscodeLlmModels[family].maxInputTokens + try { + ;(vscodeLlmModels[family] as { maxInputTokens: number }).maxInputTokens = 0 + const guardHandler = new VsCodeLmHandler({ + vsCodeLmModelSelector: { vendor: "copilot", family }, + }) + guardHandler["client"] = null + + expect(guardHandler.getCondenseContextWindow()).toBe(guardHandler.getModel().info.contextWindow) + + guardHandler.dispose() + } finally { + ;(vscodeLlmModels[family] as { maxInputTokens: number }).maxInputTokens = original + } }) }) diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 04e2c5b6266..6809fe33a37 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" import OpenAI from "openai" -import { type ModelInfo, openAiModelInfoSaneDefaults, vscodeLlmModels } from "@roo-code/types" +import { type ModelInfo, openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils" @@ -922,11 +922,15 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan * static table's `maxInputTokens` — the same value the context bar uses via * `useSelectedModel` — so the gate and the gauge stay on one source of truth. * - * Falls back to the live runtime window when the selected model isn't in the static table. + * An unrecognized family (catalog drift, e.g. a selector left over from a dropped model) resolves + * to the curated default row rather than the inflated live window; only a non-positive static + * `maxInputTokens` falls back to the live runtime window. */ getCondenseContextWindow(): number { const family = this.client?.family ?? this.options.vsCodeLmModelSelector?.family - const staticModel = family ? vscodeLlmModels[family as keyof typeof vscodeLlmModels] : undefined + const staticModel = family + ? (vscodeLlmModels[family as keyof typeof vscodeLlmModels] ?? vscodeLlmModels[vscodeLlmDefaultModelId]) + : vscodeLlmModels[vscodeLlmDefaultModelId] if (staticModel && typeof staticModel.maxInputTokens === "number" && staticModel.maxInputTokens > 0) { return staticModel.maxInputTokens diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index e239670338e..5d0c375ad6e 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -1596,7 +1596,7 @@ describe("Context Management", () => { }) it("should include lastMessageTokens in the calculation", () => { - // Usage is measured against available input space (contextWindow - maxTokens = 70000). + // Opt-in denominator: available input space (contextWindow - maxTokens = 70000). // Without lastMessageTokens: 34000 / 70000 ~= 49% // With lastMessageTokens: (34000 + 2000) / 70000 ~= 51% const resultWithoutLastMessage = willManageContext({ @@ -1608,6 +1608,7 @@ describe("Context Management", () => { profileThresholds: {}, currentProfileId: "default", lastMessageTokens: 0, + useAvailableInputForContextPercent: true, }) expect(resultWithoutLastMessage).toBe(false) @@ -1620,21 +1621,22 @@ describe("Context Management", () => { profileThresholds: {}, currentProfileId: "default", lastMessageTokens: 2000, // Pushes usage just over the 50% threshold + useAvailableInputForContextPercent: true, }) expect(resultWithLastMessage).toBe(true) }) }) /** - * Regression: the condense percentage must be measured against the AVAILABLE input - * space (contextWindow - reservedForOutput), not the full contextWindow. This is the - * real vscode-lm / claude-opus-4.8 failure: with a large window and a meaningful output - * reserve, the old full-window denominator under-reported usage and condensation never - * fired even though the UI gauge showed the context as effectively full. + * Regression: with the opt-in flag on (vscode-lm only), the condense percentage is measured + * against the AVAILABLE input space (contextWindow - reservedForOutput), not the full + * contextWindow. This is the real vscode-lm / claude-opus-4.8 failure: with a large window and + * a meaningful output reserve, the full-window denominator under-reported usage and + * condensation never fired even though the UI gauge showed the context as effectively full. * See myplans/VSCode LM Model Table Integrity/vscode_lm_opus_data_integrity_design.md and * GitHub issue simurg79/Roo-Code#10. */ - describe("contextPercent uses available input space (regression)", () => { + describe("contextPercent uses available input space (opt-in, regression)", () => { const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({ contextWindow, supportsPromptCache: true, @@ -1665,6 +1667,7 @@ describe("Context Management", () => { profileThresholds: {}, currentProfileId: "default", lastMessageTokens: 0, + useAvailableInputForContextPercent: true, }) expect(result).toBe(true) }) @@ -1682,6 +1685,7 @@ describe("Context Management", () => { profileThresholds: {}, currentProfileId: "default", lastMessageTokens: 0, + useAvailableInputForContextPercent: true, }) expect(result).toBe(true) }) @@ -1701,6 +1705,7 @@ describe("Context Management", () => { profileThresholds: {}, currentProfileId: "default", lastMessageTokens: 0, + useAvailableInputForContextPercent: true, }) expect(result).toBe(true) }) @@ -1738,6 +1743,7 @@ describe("Context Management", () => { taskId, profileThresholds: {}, currentProfileId: "default", + useAvailableInputForContextPercent: true, }) expect(summarizeSpy).toHaveBeenCalled() @@ -1750,6 +1756,73 @@ describe("Context Management", () => { }) }) + /** + * Scoping: the available-input denominator is opt-in (vscode-lm only). Every other provider + * keeps dividing by the full context window. The maxTokens <= 0 reserve guard stays global. + */ + describe("contextPercent denominator is opt-in (default = full window)", () => { + const messages: ApiMessage[] = [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Second message" }, + { role: "user", content: "Third message" }, + { role: "assistant", content: "Fourth message" }, + { role: "user", content: "" }, + ] + + it("willManageContext divides by the full window when the flag is omitted", () => { + // Same inputs as the opt-in case above: 100000/200000 = 50% < 70, so it must NOT fire. + const result = willManageContext({ + totalTokens: 100000, + contextWindow: 200000, + maxTokens: 64000, + autoCondenseContext: true, + autoCondenseContextPercent: 70, + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(result).toBe(false) + }) + + it("keeps the maxTokens:-1 reserve guard on the default (full-window) path", () => { + const result = willManageContext({ + totalTokens: 85000, + contextWindow: 100000, + maxTokens: -1, + autoCondenseContext: false, + autoCondenseContextPercent: 50, + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(result).toBe(true) + }) + + it("manageContext does NOT summarize on the default path where the opt-in math would have", async () => { + const summarizeSpy = vi.spyOn(condenseModule, "summarizeConversation") + + const result = await manageContext({ + messages, + totalTokens: 100000, + contextWindow: 200000, + maxTokens: 64000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 70, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(summarizeSpy).not.toHaveBeenCalled() + expect(result.summary).toBe("") + expect(result.prevContextTokens).toBe(100000) + + summarizeSpy.mockRestore() + }) + }) + /** * Tests for newContextTokensAfterTruncation including system prompt */ diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 2ec477d1037..0de799a1ac2 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -38,6 +38,35 @@ export async function estimateTokenCount( return apiHandler.countTokens(content) } +/** + * Percentage of the context budget consumed by the prior context. + * + * Default: divide by the full context window. Opt-in (vscode-lm only) divides by available input + * space (window minus reserved output), matching the UI context gauge, because vscode-lm's window + * would otherwise keep auto-condense from ever firing. Shared by `willManageContext` and + * `manageContext` so the gate and the action cannot disagree. + */ +function computeContextPercent({ + prevContextTokens, + contextWindow, + maxTokens, + useAvailableInputForContextPercent, +}: { + prevContextTokens: number + contextWindow: number + maxTokens?: number | null + useAvailableInputForContextPercent?: boolean +}): number { + if (!useAvailableInputForContextPercent) { + return (100 * prevContextTokens) / contextWindow + } + + // vscode-lm reports maxTokens: -1 (unlimited); a negative reserve must not inflate the denominator. + const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0 + const availableInputTokens = contextWindow - reservedForOutput + return availableInputTokens > 0 ? (100 * prevContextTokens) / availableInputTokens : 100 +} + /** * Result of truncation operation, includes the truncation ID for UI events. */ @@ -143,6 +172,11 @@ export type WillManageContextOptions = { profileThresholds: Record currentProfileId: string lastMessageTokens: number + /** + * Opt-in (vscode-lm): measure the condense percentage against available input space + * (contextWindow - reserved output) instead of the full window. Others leave it undefined. + */ + useAvailableInputForContextPercent?: boolean } /** @@ -163,6 +197,7 @@ export function willManageContext({ profileThresholds, currentProfileId, lastMessageTokens, + useAvailableInputForContextPercent, }: WillManageContextOptions): boolean { if (!autoCondenseContext) { // When auto-condense is disabled, only truncation can occur @@ -190,14 +225,12 @@ export function willManageContext({ // Invalid values fall back to global setting (effectiveThreshold already set) } - // Measure usage against the available input space (context window minus the - // reserved output budget), matching the context gauge shown in the UI. Reserved - // output tokens can never hold conversation context, so this is the meaningful - // "how full is my usable input" figure. When the reserve is unknown/unlimited - // (e.g., vscode-lm reports -1), fall back to the full context window. - const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0 - const availableInputTokens = contextWindow - reservedForOutput - const contextPercent = availableInputTokens > 0 ? (100 * prevContextTokens) / availableInputTokens : 100 + const contextPercent = computeContextPercent({ + prevContextTokens, + contextWindow, + maxTokens, + useAvailableInputForContextPercent, + }) return contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens } @@ -234,6 +267,11 @@ export type ContextManagementOptions = { cwd?: string /** Optional controller for file access validation */ rooIgnoreController?: RooIgnoreController + /** + * Opt-in (vscode-lm): measure the condense percentage against available input space + * (contextWindow - reserved output) instead of the full window. Others leave it undefined. + */ + useAvailableInputForContextPercent?: boolean } export type ContextManagementResult = SummarizeResponse & { @@ -267,6 +305,7 @@ export async function manageContext({ filesReadByRoo, cwd, rooIgnoreController, + useAvailableInputForContextPercent, }: ContextManagementOptions): Promise { let error: string | undefined let errorDetails: string | undefined @@ -310,14 +349,12 @@ export async function manageContext({ // If no specific threshold is found for the profile, fall back to global setting if (autoCondenseContext) { - // Measure usage against the available input space (context window minus the - // reserved output budget), matching the context gauge shown in the UI. Reserved - // output tokens can never hold conversation context, so this is the meaningful - // "how full is my usable input" figure. When the reserve is unknown/unlimited - // (e.g., vscode-lm reports -1), fall back to the full context window. - const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0 - const availableInputTokens = contextWindow - reservedForOutput - const contextPercent = availableInputTokens > 0 ? (100 * prevContextTokens) / availableInputTokens : 100 + const contextPercent = computeContextPercent({ + prevContextTokens, + contextWindow, + maxTokens, + useAvailableInputForContextPercent, + }) if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) { // Attempt to intelligently condense the context const result = await summarizeConversation({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5c6aef9e046..31bbdbb037b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3752,6 +3752,9 @@ export class Task extends EventEmitter implements TaskLike { // inflated live window, so context management runs in line with the context bar. Every // other provider returns undefined here and falls back to modelInfo.contextWindow. const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow + // Only vscode-lm implements getCondenseContextWindow, so the available-input denominator + // stays scoped to it; every other provider keeps dividing by the full context window. + const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" // Get the current profile ID using the helper method const currentProfileId = this.getCurrentProfileId(state) @@ -3815,6 +3818,7 @@ export class Task extends EventEmitter implements TaskLike { currentProfileId, metadata, environmentDetails, + useAvailableInputForContextPercent, }) if (truncateResult.messages !== this.apiConversationHistory) { @@ -3945,6 +3949,9 @@ export class Task extends EventEmitter implements TaskLike { // inflated live window, so auto-condense fires in line with the context bar. Every other // provider returns undefined here and falls back to modelInfo.contextWindow. const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow + // Only vscode-lm implements getCondenseContextWindow, so the available-input denominator + // stays scoped to it; every other provider keeps dividing by the full context window. + const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" // Get the current profile ID using the helper method const currentProfileId = this.getCurrentProfileId(state) @@ -3969,6 +3976,7 @@ export class Task extends EventEmitter implements TaskLike { profileThresholds, currentProfileId, lastMessageTokens, + useAvailableInputForContextPercent, }) // Send condenseTaskContextStarted BEFORE manageContext to show in-progress indicator @@ -4046,6 +4054,7 @@ export class Task extends EventEmitter implements TaskLike { filesReadByRoo: contextMgmtFilesReadByRoo, cwd: this.cwd, rooIgnoreController: this.rooIgnoreController, + useAvailableInputForContextPercent, }) if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) diff --git a/src/package.json b/src/package.json index bc0eb924317..f8adf9ee8dc 100644 --- a/src/package.json +++ b/src/package.json @@ -411,6 +411,7 @@ "%settings.workspace.rootResolution.firstFolder.description%" ], "default": "activeEditor", + "scope": "machine", "description": "%settings.workspace.rootResolution.description%", "markdownDescription": "%settings.workspace.rootResolution.description%" } diff --git a/src/utils/__tests__/path.spec.ts b/src/utils/__tests__/path.spec.ts index d1e805196d1..eeef29db120 100644 --- a/src/utils/__tests__/path.spec.ts +++ b/src/utils/__tests__/path.spec.ts @@ -9,6 +9,7 @@ import * as path from "path" // against a `resolve.alias` mapping. import * as vscodeMock from "vscode" +import { Package } from "../../shared/package" import { arePathsEqual, getReadablePath, getWorkspacePath, getWorkspacePathForContext } from "../path" // Loose typing for the mock object — the file is plain JS and exposes a @@ -32,13 +33,13 @@ const mockWindow = ( ).window /** - * Set the value returned for `roo-cline.workspace.rootResolution` in tests. + * Set the value returned for `.workspace.rootResolution` in tests. * Pass `undefined` to fall back to the default ("activeEditor"). */ function setRootResolution(value: "activeEditor" | "firstFolder" | undefined) { mockWorkspace.getConfiguration = (section?: string) => ({ get: (key: string, defaultValue?: unknown) => { - if (section === "roo-cline" && key === "workspace.rootResolution") { + if (section === Package.name && key === "workspace.rootResolution") { return value ?? defaultValue } return defaultValue @@ -54,19 +55,24 @@ function withWorkspaceMock(opts: { folders?: Array<{ uri: { fsPath: string }; name: string; index: number }> | undefined getWorkspaceFolder?: (...args: unknown[]) => { uri: { fsPath: string } } | null | undefined activeEditor?: { document: { uri: { fsPath: string } } } | null + getConfiguration?: (section?: string) => { get: (key: string, defaultValue?: unknown) => unknown } }): () => void { const previousFolders = mockWorkspace.workspaceFolders const previousGetWorkspaceFolder = mockWorkspace.getWorkspaceFolder const previousActiveEditor = mockWindow.activeTextEditor + // Captured so a test that installs a throwing/custom config mock cannot leak it into siblings. + const previousGetConfiguration = mockWorkspace.getConfiguration if ("folders" in opts) mockWorkspace.workspaceFolders = opts.folders if (opts.getWorkspaceFolder) mockWorkspace.getWorkspaceFolder = opts.getWorkspaceFolder if ("activeEditor" in opts) mockWindow.activeTextEditor = opts.activeEditor ?? null + if (opts.getConfiguration) mockWorkspace.getConfiguration = opts.getConfiguration return () => { mockWorkspace.workspaceFolders = previousFolders mockWorkspace.getWorkspaceFolder = previousGetWorkspaceFolder mockWindow.activeTextEditor = previousActiveEditor + mockWorkspace.getConfiguration = previousGetConfiguration } } @@ -203,13 +209,13 @@ describe("Path Utilities", () => { }) it("falls back to default behavior when reading the setting throws", () => { - mockWorkspace.getConfiguration = () => { - throw new Error("not available in this context") - } const restore = withWorkspaceMock({ folders: [{ uri: { fsPath: "/test/workspace" }, name: "test", index: 0 }], activeEditor: { document: { uri: { fsPath: "/test/workspaceFolder/file.ts" } } }, getWorkspaceFolder: () => ({ uri: { fsPath: "/test/workspaceFolder" } }), + getConfiguration: () => { + throw new Error("not available in this context") + }, }) try { // Should not throw and should resolve via active-editor logic. @@ -218,6 +224,24 @@ describe("Path Utilities", () => { restore() } }) + + it("reads the setting from the extension's configuration section", () => { + const sections: Array = [] + const restore = withWorkspaceMock({ + folders: [{ uri: { fsPath: "/test/workspace" }, name: "test", index: 0 }], + activeEditor: null, + getConfiguration: (section?: string) => { + sections.push(section) + return { get: (_key: string, defaultValue?: unknown) => defaultValue } + }, + }) + try { + getWorkspacePath() + expect(sections).toContain(Package.name) + } finally { + restore() + } + }) }) describe("getWorkspacePathForContext", () => { diff --git a/src/utils/path.ts b/src/utils/path.ts index 3fa053e1232..9b3727652db 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -2,6 +2,8 @@ import * as path from "path" import os from "os" import * as vscode from "vscode" +import { Package } from "../shared/package" + /* The Node.js 'path' module resolves and normalizes paths differently depending on the platform: - On Windows, it uses backslashes (\) as the default path separator. @@ -121,14 +123,14 @@ export const toRelativePath = (filePath: string, cwd: string) => { * (`vscode.workspace.workspaceFolders[0]`). Deterministic — independent of * which file is currently focused. * - * Surfaced to users via the `roo-cline.workspace.rootResolution` setting. + * Surfaced to users via the `.workspace.rootResolution` setting. */ export type WorkspaceRootResolution = "activeEditor" | "firstFolder" const DEFAULT_ROOT_RESOLUTION: WorkspaceRootResolution = "activeEditor" /** - * Read the `roo-cline.workspace.rootResolution` setting safely. + * Read the `.workspace.rootResolution` setting safely. * * Wrapped in try/catch because: * 1. `vscode.workspace.getConfiguration` is unavailable in some test/CLI @@ -138,11 +140,8 @@ const DEFAULT_ROOT_RESOLUTION: WorkspaceRootResolution = "activeEditor" */ function getRootResolutionStrategy(): WorkspaceRootResolution { try { - // Read directly from the `roo-cline` section to avoid importing - // `Package` here (path.ts is a low-level utility and we want to keep - // it free of circular dependencies on shared/* and package.json). const value = vscode.workspace - .getConfiguration("roo-cline") + .getConfiguration(Package.name) .get("workspace.rootResolution", DEFAULT_ROOT_RESOLUTION) return value === "firstFolder" ? "firstFolder" : DEFAULT_ROOT_RESOLUTION } catch {