Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions src/api/providers/__tests__/vscode-lm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
})
})

Expand Down
10 changes: 7 additions & 3 deletions src/api/providers/vscode-lm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
87 changes: 80 additions & 7 deletions src/core/context-management/__tests__/context-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -1608,6 +1608,7 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
lastMessageTokens: 0,
useAvailableInputForContextPercent: true,
})
expect(resultWithoutLastMessage).toBe(false)

Expand All @@ -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,
Expand Down Expand Up @@ -1665,6 +1667,7 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
lastMessageTokens: 0,
useAvailableInputForContextPercent: true,
})
expect(result).toBe(true)
})
Expand All @@ -1682,6 +1685,7 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
lastMessageTokens: 0,
useAvailableInputForContextPercent: true,
})
expect(result).toBe(true)
})
Expand All @@ -1701,6 +1705,7 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
lastMessageTokens: 0,
useAvailableInputForContextPercent: true,
})
expect(result).toBe(true)
})
Expand Down Expand Up @@ -1738,6 +1743,7 @@ describe("Context Management", () => {
taskId,
profileThresholds: {},
currentProfileId: "default",
useAvailableInputForContextPercent: true,
})

expect(summarizeSpy).toHaveBeenCalled()
Expand All @@ -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
*/
Expand Down
69 changes: 53 additions & 16 deletions src/core/context-management/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -143,6 +172,11 @@ export type WillManageContextOptions = {
profileThresholds: Record<string, number>
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
}

/**
Expand All @@ -163,6 +197,7 @@ export function willManageContext({
profileThresholds,
currentProfileId,
lastMessageTokens,
useAvailableInputForContextPercent,
}: WillManageContextOptions): boolean {
if (!autoCondenseContext) {
// When auto-condense is disabled, only truncation can occur
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 & {
Expand Down Expand Up @@ -267,6 +305,7 @@ export async function manageContext({
filesReadByRoo,
cwd,
rooIgnoreController,
useAvailableInputForContextPercent,
}: ContextManagementOptions): Promise<ContextManagementResult> {
let error: string | undefined
let errorDetails: string | undefined
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3752,6 +3752,9 @@ export class Task extends EventEmitter<TaskEvents> 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)
Expand Down Expand Up @@ -3815,6 +3818,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
currentProfileId,
metadata,
environmentDetails,
useAvailableInputForContextPercent,
})

if (truncateResult.messages !== this.apiConversationHistory) {
Expand Down Expand Up @@ -3945,6 +3949,9 @@ export class Task extends EventEmitter<TaskEvents> 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)
Expand All @@ -3969,6 +3976,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
profileThresholds,
currentProfileId,
lastMessageTokens,
useAvailableInputForContextPercent,
})

// Send condenseTaskContextStarted BEFORE manageContext to show in-progress indicator
Expand Down Expand Up @@ -4046,6 +4054,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
filesReadByRoo: contextMgmtFilesReadByRoo,
cwd: this.cwd,
rooIgnoreController: this.rooIgnoreController,
useAvailableInputForContextPercent,
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
Expand Down
Loading
Loading