diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f5757a51c7..809802bafc 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -187,6 +187,38 @@ export const globalSettingsSchema = z.object({ allowedMaxCost: z.number().nullish(), autoCondenseContext: z.boolean().optional(), autoCondenseContextPercent: z.number().optional(), + /** + * Master toggle for idle condensing: opportunistically condense the context + * while the task is waiting on the user, once the context exceeds the + * (typically lower) idle trigger threshold and the idle time has elapsed. + * @default false + */ + idleCondenseContext: z.boolean().optional(), + /** + * Context-window percentage (relative to the condensing context window) at + * or above which an idle condense is allowed to run. Must not exceed the + * effective autoCondenseContextPercent. + * @default 50 + */ + idleCondenseContextPercent: z.number().optional(), + /** + * Seconds of waiting-on-user (idle) time before an idle condense runs. + * The countdown restarts when the user types in the prompt input. + * @default 180 + */ + idleCondenseTimeInSeconds: z.number().optional(), + /** + * Per-model-profile overrides for idleCondenseContextPercent. A value of -1 + * inherits the global idleCondenseContextPercent; values outside + * [MIN_CONDENSE_THRESHOLD, MAX_CONDENSE_THRESHOLD] fall back to global. + */ + profileIdleThresholds: z.record(z.string(), z.number()).optional(), + /** + * Per-model-profile overrides for idleCondenseTimeInSeconds. A value of -1 + * inherits the global idleCondenseTimeInSeconds; negative values other than + * -1 fall back to global. + */ + profileIdleCondenseTimes: z.record(z.string(), z.number()).optional(), /** * Whether to include current time in the environment details diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 8a006c0639..c8ad6d43fc 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -335,6 +335,8 @@ export type ExtensionState = Pick< | "codebaseIndexConfig" | "codebaseIndexModels" | "profileThresholds" + | "profileIdleThresholds" + | "profileIdleCondenseTimes" | "includeDiagnosticMessages" | "maxDiagnosticMessages" | "imageGenerationProvider" @@ -412,6 +414,9 @@ export type ExtensionState = Pick< autoCondenseContext: boolean autoCondenseContextPercent: number + idleCondenseContext: boolean + idleCondenseContextPercent: number + idleCondenseTimeInSeconds: number marketplaceItems?: MarketplaceItem[] // eslint-disable-next-line @typescript-eslint/no-explicit-any marketplaceInstalledMetadata?: { project: Record; global: Record } @@ -629,6 +634,7 @@ export interface WebviewMessage { | "zooCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" + | "idleCondenseReset" | "requestIndexingStatus" | "startIndexing" | "stopIndexing" diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 38c941cf4f..c7434e68b8 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -15,6 +15,7 @@ import { truncateConversation, manageContext, willManageContext, + computeEffectiveCondenseThreshold, } from "../index" // Create a mock ApiHandler for testing @@ -1247,6 +1248,96 @@ describe("Context Management", () => { }) }) + /** + * Tests for the computeEffectiveCondenseThreshold helper + */ + describe("computeEffectiveCondenseThreshold", () => { + it("returns the profile threshold when it is within the valid range", () => { + expect( + computeEffectiveCondenseThreshold({ + globalPercent: 80, + profileThresholds: { architect: 40 }, + currentProfileId: "architect", + }), + ).toBe(40) + }) + + it("returns the global percent when the profile threshold is -1 (inheritance)", () => { + expect( + computeEffectiveCondenseThreshold({ + globalPercent: 80, + profileThresholds: { architect: -1 }, + currentProfileId: "architect", + }), + ).toBe(80) + }) + + it("returns the global percent when the profile has no specific threshold", () => { + expect( + computeEffectiveCondenseThreshold({ + globalPercent: 80, + profileThresholds: { code: 40 }, + currentProfileId: "architect", + }), + ).toBe(80) + }) + + it("warns and returns the global percent when the profile threshold is below the minimum", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + expect( + computeEffectiveCondenseThreshold({ + globalPercent: 80, + profileThresholds: { architect: 1 }, + currentProfileId: "architect", + }), + ).toBe(80) + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid profile threshold 1 for profile "architect". Using global default of 80%', + ) + } finally { + warnSpy.mockRestore() + } + }) + + it("warns and returns the global percent when the profile threshold is above the maximum", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + expect( + computeEffectiveCondenseThreshold({ + globalPercent: 80, + profileThresholds: { architect: 150 }, + currentProfileId: "architect", + }), + ).toBe(80) + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid profile threshold 150 for profile "architect". Using global default of 80%', + ) + } finally { + warnSpy.mockRestore() + } + }) + + it("uses a custom label in the warning message", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + expect( + computeEffectiveCondenseThreshold({ + globalPercent: 180, + profileThresholds: { architect: -5 }, + currentProfileId: "architect", + label: "idle time", + }), + ).toBe(180) + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid profile idle time -5 for profile "architect". Using global default of 180%', + ) + } finally { + warnSpy.mockRestore() + } + }) + }) + /** * Tests for the getMaxTokens function (private but tested through manageContext) */ diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 2050cef373..5b7991daa2 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -47,7 +47,7 @@ export async function estimateTokenCount( * (window minus reserved output); an unknown/unlimited reserve (maxTokens -1) falls back to the * full window. Shared by `willManageContext` and `manageContext` so the two stay in lockstep. */ -function computeContextPercent({ +export function computeContextPercent({ prevContextTokens, contextWindow, maxTokens, @@ -158,6 +158,40 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe } } +/** + * Resolves the effective condense threshold percent for the current model + * profile, mirroring the semantics used by `willManageContext`/`manageContext`: + * a profile value of -1 inherits the global percent, a value within + * [MIN_CONDENSE_THRESHOLD, MAX_CONDENSE_THRESHOLD] overrides it, and any other + * value falls back to the global percent with a warning. + */ +export function computeEffectiveCondenseThreshold({ + globalPercent, + profileThresholds, + currentProfileId, + label = "threshold", +}: { + globalPercent: number + profileThresholds: Record + currentProfileId: string + label?: string +}): number { + const profileThreshold = profileThresholds[currentProfileId] + if (profileThreshold === undefined) { + return globalPercent + } + if (profileThreshold === -1) { + return globalPercent + } + if (profileThreshold >= MIN_CONDENSE_THRESHOLD && profileThreshold <= MAX_CONDENSE_THRESHOLD) { + return profileThreshold + } + console.warn( + `Invalid profile ${label} ${profileThreshold} for profile "${currentProfileId}". Using global default of ${globalPercent}%`, + ) + return globalPercent +} + /** * Options for checking if context management will likely run. * A subset of ContextManagementOptions with only the fields needed for threshold calculation. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index aa6026e56b..0af60bd983 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -112,7 +112,13 @@ import { type AssistantMessageContent, presentAssistantMessage } from "../assist import { DispatchState, type PendingWork } from "./dispatchState" import { type RequestPolicySnapshot } from "../prompts/tools/effective-tool-policy" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" -import { type ContextManagementResult, manageContext, willManageContext } from "../context-management" +import { + computeContextPercent, + computeEffectiveCondenseThreshold, + type ContextManagementResult, + manageContext, + willManageContext, +} from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { @@ -286,6 +292,40 @@ export class PendingActionSettlementError extends Error { } } +/** + * Outcome of a condense run, surfaced so callers (e.g. the idle-condense + * timer) can re-evaluate context fullness without waiting for the next API + * request to refresh `getTokenUsage().contextTokens` (manual condense does + * not refresh it). + */ +type CondenseContextResult = { + condensed: boolean + error?: string + newContextTokens?: number + prevContextTokens: number +} + +/** Snapshot of the idle-condense gate inputs, re-evaluated when the timer fires. */ +type IdleCondenseGate = { + idlePercent: number + contextPercent: number + idleSeconds: number +} + +/** + * Result of an idle-condense arming pass. Only a throw (e.g. a transient + * provider `getState` rejection) is a `failed` outcome worth retrying; + * `declined` means a gate intentionally kept this task from condensing. + */ +type IdleCondenseArmingOutcome = "armed" | "declined" | "failed" + +/** + * Backoff schedule (ms) for retrying the idle-condense gate after the + * settings-save re-evaluation throws, e.g. because provider state was + * transiently unreadable. Bounded so a persistent failure cannot spin. + */ +const IDLE_CONDENSE_SETTINGS_RETRY_DELAYS_MS = [1_000, 5_000, 15_000] as const + export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string @@ -469,6 +509,73 @@ export class Task extends EventEmitter implements TaskLike { private blanketDeniedCommandThisTurn = false public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout + /** Timer for opportunistic idle condensing; armed only while the task waits on the user. */ + private idleCondenseTimeoutRef?: NodeJS.Timeout + /** + * Pending retry of a failed settings-save gate re-evaluation. Kept separate + * from `idleCondenseTimeoutRef` so a failed re-evaluation never leaves a + * countdown armed; cleared whenever the countdown is cancelled (a later + * settings save, a reset, or disposal supersedes the retry). + */ + private idleCondenseSettingsRetryRef?: NodeJS.Timeout + /** True while an idle-triggered condense is running, to prevent overlapping runs/re-arms. */ + private idleCondenseInFlight = false + /** + * True while the most recent task action was a condense of any kind (idle, + * manual, inline auto, or forced overflow). Idle condensing stays suppressed + * until the task takes another action (a new model request or a user prompt). + */ + private lastActionWasCondense = false + /** + * True when the idle condense timer expired while API work was in flight and + * was therefore discarded. Consumed when the last in-flight request finishes: + * the timer is re-armed only if a follow-up ask is still pending, so a + * follow-up presented during a response stream still gets an idle condense. + */ + private idleCondenseRearmAfterApiWork = false + /** + * Bumped by user activity (currently every webview ask response) so a fired + * idle-condense evaluation that is still awaiting its gate can detect that + * the user engaged while it ran. A stale evaluation is discarded instead of + * summarizing over and overwriting the conversation updates it missed. + */ + private idleCondenseActivitySeq = 0 + /** + * True while an expired idle-condense timer is awaiting its async + * re-checks. The countdown itself is already consumed, so + * `resetIdleCondenseTimer` needs this flag to know the task is still + * mid-evaluation: user activity then invalidates the evaluation (via the + * activity sequence) and restarts the countdown from the beginning. + */ + private idleCondenseExpiryEvaluating = false + /** + * Set while the idle-triggered summarization request is in flight. An idle + * condense runs without the request loop, so its summarization call has no + * request-scoped controller: this task-owned one lets `cancelCurrentRequest` + * (user cancel and disposal) abort the in-flight HTTP call. The idle run + * that created it clears it when the lease finishes. + */ + private idleCondenseAbortController?: AbortController + /** + * Set while a condense (idle or manual) replaces the API history. `ask()` + * treats a landing response as still pending and the queue drain stays + * blocked, so a resuming request cannot append turns the summary — computed + * from the pre-condense snapshot — would then overwrite. Settled in the + * lease's finally on both success and failure, so waiting responses never + * remain blocked. + */ + private condenseHistoryBarrier?: Promise + /** + * FIFO tail serializing condense leases per task. Every condense (manual + * or idle) chains behind this promise, so only one summary can be in + * flight at a time: a finishing lease cannot clear the barrier while + * another condense still holds a pre-condense snapshot that would + * overwrite turns appended in between. `undefined` means no condense is + * in flight: an uncontended lease then runs straight into claiming its + * API work synchronously, because yielding even a microtask first would + * let a profile switch dispose the handler the condense is about to use. + */ + private condenseLeaseTail?: Promise // Tool Use consecutiveMistakeCount: number = 0 @@ -1381,10 +1488,20 @@ export class Task extends EventEmitter implements TaskLike { * queue containing nothing but claims as available for a new consumer. * `isMessageQueued`/`isStatusMutable` keep `isEmpty()` semantics on purpose: * flipping those would re-enable interactive prompt timers whenever a claim - * is outstanding. + * is outstanding. The condense barrier blocks draining while a condense is + * replacing the API history, so the queued message's follow-up request cannot + * append turns the in-flight summary would then overwrite. A landed response + * held pending by the barrier must be delivered as-is: draining now would + * overwrite it with the queued message, so the drain waits and the message + * stays unclaimed for a later turn. */ private mayDrainQueuedMessageForAsk(): boolean { - return !this.blanketDeniedCommandThisTurn && this.messageQueueService.hasUnclaimed() + return ( + !this.blanketDeniedCommandThisTurn && + !this.condenseHistoryBarrier && + this.askResponse === undefined && + this.messageQueueService.hasUnclaimed() + ) } /** @@ -1747,6 +1864,14 @@ export class Task extends EventEmitter implements TaskLike { private hydrateApiConversationHistory(messages: ApiMessage[]) { this.apiConversationHistory = ensureMessageIdentifiers(messages) + + // The condense suppression flag tracks whether the conversation still + // ends in a condense summary. Reconcile it on every wholesale history + // replacement: a condense persists its summary as the last entry (so + // reopen and post-condense writes keep the flag set), while rewinds, + // deletes, and edits that truncate past the summary clear it so idle + // condensing can arm again without waiting for a new prompt. + this.lastActionWasCondense = messages.at(-1)?.isSummary === true } /** @@ -2200,6 +2325,12 @@ export class Task extends EventEmitter implements TaskLike { void provider?.postMessageToWebview({ type: "interactionRequired" }).catch((error) => { console.error("[Task#ask] postMessageToWebview interactionRequired failed:", error) }) + // A followup is the model's question finished and the task + // waiting on a new user prompt: eligible for idle condensing. + // Other interactive asks (tool/command/etc) are mid-response. + if (type === "followup") { + void this.maybeStartIdleCondenseTimer() + } } }, statusMutationTimeout), ) @@ -2214,6 +2345,7 @@ export class Task extends EventEmitter implements TaskLike { if (message) { this.resumableAsk = message this.emit(RooCodeEventName.TaskResumable, this.taskId) + void this.maybeStartIdleCondenseTimer() } }, statusMutationTimeout), ) @@ -2228,6 +2360,7 @@ export class Task extends EventEmitter implements TaskLike { if (message) { this.idleAsk = message this.emit(RooCodeEventName.TaskIdle, this.taskId) + void this.maybeStartIdleCondenseTimer() } }, statusMutationTimeout), ) @@ -2360,7 +2493,14 @@ export class Task extends EventEmitter implements TaskLike { try { await pWaitFor( () => { - if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { + // While a condense is still replacing the API history, hold a + // landed response pending: delivering it would let the follow-up + // request append turns the summary (computed from the pre-condense + // snapshot) overwrites. Aborts and supersession still win + // immediately; the next poll (100ms) picks the response up once + // the barrier settles. + const responseReady = this.askResponse !== undefined && !this.condenseHistoryBarrier + if (this.abort || responseReady || this.lastMessageTs !== askTs) { return true } @@ -2470,6 +2610,13 @@ export class Task extends EventEmitter implements TaskLike { handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { // Clear any pending auto-approval timeout when user responds this.cancelAutoApprovalTimeout() + // The user is engaging with the task: stop any pending idle condense, and + // let a future idle period condense again even if the last action condensed. + this.cancelIdleCondenseTimer() + // Invalidate any idle-condense evaluation already in flight: the user's + // update must not be summarized away by a condense started before it. + this.idleCondenseActivitySeq++ + this.lastActionWasCondense = false this.askResponse = askResponse this.askResponseText = text @@ -2529,6 +2676,412 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Cancel any pending idle-condense timeout (user sent a prompt, task was + * cancelled, or the task is being disposed). + */ + public cancelIdleCondenseTimer(): void { + if (this.idleCondenseTimeoutRef) { + clearTimeout(this.idleCondenseTimeoutRef) + this.idleCondenseTimeoutRef = undefined + } + // A pending settings-retry evaluates the gate against the settings that + // are current when it fires; any cancel means those settings (or the + // task itself) changed, so the retry must not proceed on its own. + if (this.idleCondenseSettingsRetryRef) { + clearTimeout(this.idleCondenseSettingsRetryRef) + this.idleCondenseSettingsRetryRef = undefined + } + } + + /** + * Restart the idle-condense countdown. Called when the user types in the + * prompt area. Only acts while the task is waiting with an armed countdown + * — or while an expired countdown is still awaiting its async re-checks, + * where the timer itself has already been consumed; a typing signal during + * active work must not start condensing. In the evaluating case the + * pending evaluation is invalidated and replaced by a fresh countdown, + * instead of typing silently no-opping while a stale evaluation proceeds + * to condense over the conversation being edited. + */ + public resetIdleCondenseTimer(): void { + if (!this.idleCondenseTimeoutRef && !this.idleCondenseExpiryEvaluating) { + return + } + // Invalidate any expiry evaluation already in flight before restarting: + // without this the evaluation would finish its stale gate check and + // condense over the conversation the user is currently editing. + this.idleCondenseActivitySeq++ + this.cancelIdleCondenseTimer() + void this.maybeStartIdleCondenseTimer() + } + + /** + * Re-check idle-condense eligibility after the relevant settings were + * saved. A task that has been waiting on the user since before idle + * condensing was enabled (or before its thresholds were loosened) has no + * countdown armed, and nothing else arms one for it; this gives it a fresh + * evaluation. A task with API work in flight stays ineligible, and every + * other existing gate (abort, active task, enabled flags, thresholds, + * in-flight condense, pending idle-eligible ask) is re-checked by the + * shared arming path. + * + * An already-armed countdown is cancelled first: otherwise the shared + * arming path would no-op and the countdown could fire on a stale deadline + * saved before the user increased the idle delay (or condense at a delay + * the user just shortened/disabled). Re-arming applies the newly saved + * wait from this point on. + */ + public reevaluateIdleCondenseAfterSettingsSave(): void { + if (this.activeApiWork > 0) { + return + } + // Any arming or expiry evaluation already in flight measured against + // the pre-save settings: invalidate it so it cannot condense under + // thresholds the user just replaced. The re-armed evaluation below + // captures the bumped sequence and proceeds normally. + this.idleCondenseActivitySeq++ + this.cancelIdleCondenseTimer() + void this.reevaluateIdleCondenseGate(0) + } + + /** + * Run the arming gate for a settings-save re-evaluation, retrying briefly + * when the gate itself throws. A transient provider-state read failure + * would otherwise leave a task that is idle above the newly saved + * threshold with no countdown — the cancel above already consumed the old + * one — and nothing else arms one until the next user activity. Retries + * are bounded, stop if any newer activity or save invalidates this pass + * (activity sequence), and respect abort/abandon/active-work at fire time. + */ + private async reevaluateIdleCondenseGate(attempt: number): Promise { + const activitySeq = this.idleCondenseActivitySeq + const outcome = await this.maybeStartIdleCondenseTimer() + if (outcome !== "failed" || attempt >= IDLE_CONDENSE_SETTINGS_RETRY_DELAYS_MS.length) { + return + } + // A newer save or user activity bumped the sequence: that pass owns + // the re-evaluation (and cleared this retry slot if it cancelled). + if (activitySeq !== this.idleCondenseActivitySeq || this.abort || this.abandoned) { + return + } + const delayMs = IDLE_CONDENSE_SETTINGS_RETRY_DELAYS_MS[attempt] + this.idleCondenseSettingsRetryRef = setTimeout(() => { + this.idleCondenseSettingsRetryRef = undefined + // New API work makes the task ineligible anyway; the teardown of + // that work re-arms through the shared path. + if (this.abort || this.abandoned || this.activeApiWork > 0) { + return + } + void this.reevaluateIdleCondenseGate(attempt + 1) + }, delayMs) + } + + /** + * Resolve the idle wait in seconds for the current model profile, mirroring + * the profileThresholds semantics: -1 inherits the global value, a value + * >= 0 overrides it, anything else falls back to global with a warning. + */ + private resolveIdleCondenseTimeInSeconds( + state: { idleCondenseTimeInSeconds?: number; profileIdleCondenseTimes?: Record }, + currentProfileId: string, + ): number { + const globalSeconds = state.idleCondenseTimeInSeconds ?? 180 + const profileSeconds = state.profileIdleCondenseTimes?.[currentProfileId] + if (profileSeconds === undefined || profileSeconds === -1) { + return Math.max(0, globalSeconds) + } + if (profileSeconds >= 0) { + return profileSeconds + } + console.warn( + `Invalid profile idle condense time ${profileSeconds} for profile "${currentProfileId}". Using global default of ${globalSeconds}s`, + ) + return Math.max(0, globalSeconds) + } + + /** + * Resolve the profile id for this task's own provider profile. Delegated or + * reopened tasks keep a task-local profile that may differ from the + * provider's current profile, so profile-scoped overrides must be selected + * for the task, not the global selection. Falls back to the provider's + * current profile, then to "default". + */ + private async getTaskProfileId( + state: + | { + currentApiConfigName?: string + listApiConfigMeta?: Array<{ id: string; name: string }> + } + | undefined, + ): Promise { + const apiConfigName = (await this.getTaskApiConfigName()) ?? state?.currentApiConfigName + return state?.listApiConfigMeta?.find((profile) => profile.name === apiConfigName)?.id ?? "default" + } + + /** + * Evaluate the idle-condense gate: enabled + active task + context above the + * effective idle threshold. The idle threshold never exceeds the main + * auto-condense threshold (belt-and-suspenders on top of settings + * validation). Returns undefined when condensing must not be considered. + */ + private async evaluateIdleCondense(): Promise { + // The last action was a condense (idle or otherwise): triggering another + // condense while the user is still idle would be redundant. Stay suppressed + // until the task takes another action. + if (this.lastActionWasCondense) { + return undefined + } + const provider = this.providerRef.deref() + if (!provider) { + return undefined + } + // Idle condensing only runs for the active task. + if (provider.getCurrentTask()?.taskId !== this.taskId) { + return undefined + } + + const state = await provider.getState() + // Idle condensing is a sub-feature of auto condensing: disabling auto + // condensing disables idle condensing too. + if (!state?.idleCondenseContext || !state?.autoCondenseContext) { + return undefined + } + + const { contextTokens } = this.getTokenUsage() + if (!contextTokens || contextTokens <= 0) { + return undefined + } + + const modelInfo = await this.safeEnsureModelFetched() + // Parity with attemptApiRequestWithActiveApi: vscode-lm condenses against + // its static-table maxInputTokens via getCondenseContextWindow. + const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow + if (!contextWindow) { + return undefined + } + const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" + const maxTokens = getModelMaxOutputTokens({ + modelId: this.api.getModel().id, + model: modelInfo, + settings: this.apiConfiguration, + }) + + // Profile overrides follow the task's own provider profile: a delegated + // child or reopened task may use a different profile than the provider's + // current one, and its thresholds/delay must come from that profile. + const currentProfileId = await this.getTaskProfileId(state) + const mainPercent = computeEffectiveCondenseThreshold({ + globalPercent: state?.autoCondenseContextPercent ?? 100, + profileThresholds: state?.profileThresholds ?? {}, + currentProfileId, + label: "condense threshold", + }) + const idlePercentSetting = computeEffectiveCondenseThreshold({ + globalPercent: state?.idleCondenseContextPercent ?? 50, + profileThresholds: state?.profileIdleThresholds ?? {}, + currentProfileId, + label: "idle condense threshold", + }) + const idlePercent = Math.min(idlePercentSetting, mainPercent) + const contextPercent = computeContextPercent({ + prevContextTokens: contextTokens, + contextWindow, + maxTokens, + useAvailableInputForContextPercent, + }) + const idleSeconds = this.resolveIdleCondenseTimeInSeconds(state, currentProfileId) + + return { idlePercent, contextPercent, idleSeconds } + } + + /** + * Arm the idle-condense timer if the gate allows. No-op when a timer is + * already armed or an idle condense is in flight. Returns a discriminable + * outcome so callers that need to distinguish a declined gate from a + * thrown failure (the settings-save retry) can; every other caller + * discards it. + */ + private async maybeStartIdleCondenseTimer(): Promise { + try { + if (this.abort || this.abandoned) { + return "declined" + } + if (this.idleCondenseTimeoutRef || this.idleCondenseInFlight) { + return "declined" + } + // Idle condensing only makes sense while the task genuinely waits + // on the user: never arm when nothing is pending, or while an + // approval ask that must not be condensed around is pending. + if (!this.hasPendingIdleEligibleAsk()) { + return "declined" + } + const activitySeq = this.idleCondenseActivitySeq + const gate = await this.evaluateIdleCondense() + if (!gate || gate.contextPercent < gate.idlePercent) { + return "declined" + } + // Re-check after the async gate evaluation: a prompt may have landed. + // The activity sequence catches the answer case specifically: the + // user answered the pending ask while the gate ran, so arming now + // would start a countdown for a task that is no longer waiting. + if ( + this.abort || + this.abandoned || + this.idleCondenseTimeoutRef || + this.idleCondenseActivitySeq !== activitySeq + ) { + return "declined" + } + this.armIdleCondenseTimeout(gate.idleSeconds) + return "armed" + } catch (error) { + console.error("[Task#maybeStartIdleCondenseTimer] error:", error) + return "failed" + } + } + + private armIdleCondenseTimeout(seconds: number): void { + this.cancelIdleCondenseTimer() + // Node clamps setTimeout delays above the int32 maximum to 1ms, so an + // idle wait of 2,147,484 s or more would fire almost immediately and + // condense a task that just went idle. Chain maximum-length slices so + // the callback still runs only after the full requested duration. + // idleCondenseTimeoutRef always points at the slice in flight, so + // cancelIdleCondenseTimer and resetIdleCondenseTimer keep working. + const maxDelayMs = 2_147_483_647 + let remainingMs = Math.max(0, seconds) * 1000 + const armSlice = (): void => { + if (remainingMs > maxDelayMs) { + remainingMs -= maxDelayMs + this.idleCondenseTimeoutRef = setTimeout(armSlice, maxDelayMs) + return + } + this.idleCondenseTimeoutRef = setTimeout(() => { + this.idleCondenseTimeoutRef = undefined + void this.onIdleCondenseTimerFired() + }, remainingMs) + } + armSlice() + } + + /** + * The idle wait elapsed: re-check the gate (settings or activity may have + * changed) and condense opportunistically. A successful condense marks the + * condense as the last task action, which keeps idle condensing suppressed + * until the user sends a prompt or the task runs another model request. + */ + private async onIdleCondenseTimerFired(): Promise { + if (this.idleCondenseInFlight) { + return + } + try { + if (this.abort || this.abandoned) { + return + } + // Something started (API request, tool, manual condense): the task is no + // longer idle. If the in-flight work ends while a follow-up ask is still + // pending, the completion of the last request re-arms the timer (see + // `beginApiWork`); otherwise the next idle transition re-arms. + if (this.activeApiWork > 0) { + this.idleCondenseRearmAfterApiWork = true + return + } + const activitySeq = this.idleCondenseActivitySeq + // Publish that an expiry evaluation is mid-flight: the countdown has + // already been consumed here, so without this flag a prompt or typing + // signal arriving during the awaits below would find no timer armed + // and silently do nothing (see `resetIdleCondenseTimer`). + this.idleCondenseExpiryEvaluating = true + try { + const gate = await this.evaluateIdleCondense() + if (!gate || gate.contextPercent < gate.idlePercent) { + return + } + // The gate is an async evaluation: the user may have answered (bumping + // the activity sequence) or a new request may have started (incrementing + // `activeApiWork`) while it ran. Condensing now would summarize and + // overwrite history without those updates, so discard the stale + // evaluation; the idle transition after the response re-arms the timer. + if ( + this.abort || + this.abandoned || + this.activeApiWork > 0 || + this.idleCondenseActivitySeq !== activitySeq + ) { + return + } + + this.idleCondenseInFlight = true + // The summarization request gets a task-owned controller: the idle + // path runs without the request loop, so without this the call could + // not be aborted by a cancel or disposal (see `cancelCurrentRequest`). + const abortController = new AbortController() + this.idleCondenseAbortController = abortController + let result: CondenseContextResult + try { + result = await this.condenseContextWithLease(async () => { + // Give the user the same in-progress cue a manual condense shows: + // the "Condensing context..." row with its busy icon. The chat box + // keeps the normal press-to-send state, like a manual condense. + // Posted under the installed history barrier: a response landing + // during the notification's await is held pending by `ask()` + // instead of resuming the task mid-condense. + await this.providerRef + .deref() + ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) + // The notification is an await: re-check that nothing engaged in + // the meantime before issuing the summarization request. + return !( + this.abort || + this.abandoned || + this.activeApiWork > 0 || + this.idleCondenseActivitySeq !== activitySeq + ) + }, abortController.signal) + } finally { + this.idleCondenseInFlight = false + this.idleCondenseAbortController = undefined + // Always clear the cue, even when the lease or the condense throws. + await this.providerRef + .deref() + ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) + } + + if (!result.condensed) { + return + } + // Do not re-arm: the condense is now the last task action + // (`condenseContextWithLease` set `lastActionWasCondense`), so another + // idle condense must not trigger until the task does something else. + } finally { + this.idleCondenseExpiryEvaluating = false + } + } catch (error) { + console.error("[Task#onIdleCondenseTimerFired] error:", error) + } + } + + /** + * True while the task waits on an ask idle condensing may act on: no + * response has arrived and the last ask message is a complete follow-up, + * idle, or resumable ask at the current `lastMessageTs`. Command, tool and + * MCP approval asks are excluded — they wait mid-request, and replacing + * the history around a pending approval could drop its context — as are + * partial asks and any ask the user has already answered. + */ + private hasPendingIdleEligibleAsk(): boolean { + if (this.askResponse !== undefined) { + return false + } + const lastAsk = findLast(this.clineMessages, (message) => message.type === "ask") + if (!lastAsk || lastAsk.ts !== this.lastMessageTs || lastAsk.partial === true) { + return false + } + const ask = lastAsk.ask + return ask !== undefined && (ask === "followup" || isIdleAsk(ask) || isResumableAsk(ask)) + } + public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) { this.handleWebviewAskResponse("yesButtonClicked", text, images) } @@ -2588,6 +3141,16 @@ export class Task extends EventEmitter implements TaskLike { for (const api of retired) { this.safeDisposeApi(api) } + // The idle condense timer may have expired and been discarded while this + // request was in flight (see `onIdleCondenseTimerFired`). Re-arm it now + // that the task is idle again, but only while an idle-eligible ask is + // still pending; any other idle transition re-arms the timer as usual. + if (this.idleCondenseRearmAfterApiWork) { + this.idleCondenseRearmAfterApiWork = false + if (this.hasPendingIdleEligibleAsk()) { + void this.maybeStartIdleCondenseTimer() + } + } } } @@ -2675,19 +3238,110 @@ export class Task extends EventEmitter implements TaskLike { } public async condenseContext(): Promise { - const endApiWork = this.beginApiWork() + await this.condenseContextWithLease() + } + + /** + * `summaryAbortSignal` is forwarded to the summarization call so the idle + * condense's task-owned controller can abort the in-flight request; a manual + * condense forwards none and keeps the current request's signal semantics. + */ + private async condenseContextWithLease( + onLeaseAcquired?: () => Promise, + summaryAbortSignal?: AbortSignal, + ): Promise { + // Serialize condense work per task: chain behind the previous lease so a + // manual and an idle condense can never overlap. Clearing the barrier at + // the end of the first lease while a second summary still ran would let a + // resumed request append turns the second summary then overwrites. When no + // condense is in flight (`condenseLeaseTail` undefined) the lease is + // uncontended, so skip the await: yielding even a microtask before + // `beginApiWork` would let a synchronous profile switch dispose the + // handler this condense is about to call. + const priorLease = this.condenseLeaseTail + let releaseLease: () => void = () => {} + const lease = new Promise((resolve) => { + releaseLease = resolve + }) + this.condenseLeaseTail = lease + + // Hold ask responses while the summary below replaces the API history, so + // a resuming request cannot append turns the summary would overwrite. The + // barrier settles in `finally`, releasing waiting responses on both the + // success and the failure path. `onLeaseAcquired` runs under the barrier + // so an idle condense's webview cue cannot open a window where a landed + // response resumes the task before history protection is installed. + let settleBarrier: () => void = () => {} + const installBarrier = (): void => { + this.condenseHistoryBarrier = new Promise((resolve) => { + settleBarrier = resolve + }) + } + // Install the barrier synchronously only when no other condense holds + // the lease: a queued condense must wait until the previous lease's + // `finally` clears the barrier it owns, or it would clobber that + // barrier and have it cleared out from under the running summary. + let endApiWork: () => void = () => {} + if (!priorLease) { + installBarrier() + // Uncontended manual condense (no staleness callback): also claim + // the API-work lease synchronously, before any await. Yielding even + // a microtask first would let a profile switch in the same turn + // dispose the handler this condense is about to call (see + // `retireApi`). An idle condense defers its claim until after the + // staleness re-check below, which must not see this lease's own + // count as engaging work and cancel itself. + if (!onLeaseAcquired) { + endApiWork = this.beginApiWork() + } + } try { - await this.condenseContextWithActiveApi() + if (priorLease) { + await priorLease + // The lease is actually held now: the previous condense's + // `finally` has released (and disposed) its handler and cleared + // the barrier it owned, so install this lease's guards now. + installBarrier() + } + if (onLeaseAcquired && !(await onLeaseAcquired())) { + // The caller invalidated this condense under the barrier (the user + // engaged or work started during the cue's await): release without + // touching history and without marking the condense as the last action. + return { condensed: false, prevContextTokens: this.getTokenUsage().contextTokens } + } + // Deferred claim for every path that did not install above: the + // staleness re-check has passed, so counting this condense as API + // work now cannot cancel it. + if (priorLease || onLeaseAcquired) { + endApiWork = this.beginApiWork() + } + const result = await this.condenseContextWithActiveApi(summaryAbortSignal) + if (result.condensed) { + // Covers the manual condense button and the idle-triggered condense. + this.lastActionWasCondense = true + } + return result } finally { + this.condenseHistoryBarrier = undefined + settleBarrier() endApiWork() + // Return the FIFO slot to "no condense in flight" when this was the + // last queued lease, so a later uncontended condense still takes the + // synchronous (no-await) lease path. + if (this.condenseLeaseTail === lease) { + this.condenseLeaseTail = undefined + } + releaseLease() } } - private async condenseContextWithActiveApi(): Promise { + private async condenseContextWithActiveApi(idleSummaryAbortSignal?: AbortSignal): Promise { // CRITICAL: Flush any pending tool results before condensing // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() + const { contextTokens: prevContextTokens } = this.getTokenUsage() + // Capture provider state and one model-info snapshot once and thread them into // getSystemPrompt so the prompt and the condensing tool array below resolve // from one snapshot. @@ -2697,7 +3351,7 @@ export class Task extends EventEmitter implements TaskLike { // A cancellation landing during the bounded metadata wait must stop // manual condensation before any prompt build or summarization request. if (this.abort || this.abandoned) { - return + return { condensed: false, prevContextTokens } } const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) @@ -2705,7 +3359,7 @@ export class Task extends EventEmitter implements TaskLike { // A cancellation landing during the prompt build's bounded MCP wait must // stop manual condensation before any summarization request is issued. if (this.abort || this.abandoned) { - return + return { condensed: false, prevContextTokens } } // Get condensing configuration @@ -2714,8 +3368,6 @@ export class Task extends EventEmitter implements TaskLike { const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration - const { contextTokens: prevContextTokens } = this.getTokenUsage() - // Build tools for condensing metadata (same tools used for normal API calls) const provider = this.providerRef.deref() let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] @@ -2734,13 +3386,17 @@ export class Task extends EventEmitter implements TaskLike { allTools = toolsResult.tools } - // Build metadata with tools and taskId for the condensing API call + // Build metadata with tools and taskId for the condensing API call. The + // idle condense forwards its own task-owned signal (no request loop runs + // while the task waits on the user, so the request controller is absent); + // a manual condense keeps using the current request's signal when present. + const abortSignal = idleSummaryAbortSignal ?? this.currentRequestAbortController?.signal const metadata: ApiHandlerCreateMessageMetadata = { mode, taskId: this.taskId, - ...(this.currentRequestAbortController?.signal + ...(abortSignal ? { - abortSignal: this.currentRequestAbortController.signal, + abortSignal, } : {}), ...(allTools.length > 0 @@ -2759,7 +3415,7 @@ export class Task extends EventEmitter implements TaskLike { // A cancellation landing while the summarization inputs are gathered must // stop manual condensation before any summarization request is issued. if (this.abort || this.abandoned) { - return + return { condensed: false, prevContextTokens } } const { @@ -2785,7 +3441,7 @@ export class Task extends EventEmitter implements TaskLike { // A cancellation landing during the summarization request must stop // manual condensation before it replaces and persists the history. if (this.abort || this.abandoned) { - return + return { condensed: false, prevContextTokens } } if (error) { await this.say( @@ -2797,7 +3453,12 @@ export class Task extends EventEmitter implements TaskLike { undefined /* progressStatus */, { isNonInteractive: true } /* options */, ) - return + // A failed summary must not strand messages queued while the + // condense ran: drain the queue like the success path below, or the + // user's queued prompt sits in the queue with nothing left to + // trigger a submit (the condense was the resumed action). + this.processQueuedMessages() + return { condensed: false, error, prevContextTokens } } await this.overwriteApiConversationHistory(messages) @@ -2821,6 +3482,7 @@ export class Task extends EventEmitter implements TaskLike { // Process any queued messages after condensing completes this.processQueuedMessages() + return { condensed: true, newContextTokens, prevContextTokens } } async say( @@ -3261,6 +3923,12 @@ export class Task extends EventEmitter implements TaskLike { // This is important in case the user deletes messages without resuming // the task first. this.hydrateApiConversationHistory(savedApiConversationHistory) + + // hydrateApiConversationHistory restored the in-memory condense + // suppression flag from the saved history before the resume ask arms + // the idle condense timer, so a task closed right after a condense + // does not arm another idle condense on reopen. + await this.settleInterruptedCreateSubtaskBeforeReplay() if ( this.pendingAction && @@ -3534,6 +4202,14 @@ export class Task extends EventEmitter implements TaskLike { this.metadataFetchAbortController.abort() this.metadataFetchAbortController = undefined } + // An idle condense's summarization call is an HTTP request too: abort it + // with the rest (user cancel via ClineProvider, or disposal) so a task + // that no longer serves requests cannot finish a summary that would then + // replace history from a stale snapshot. + if (this.idleCondenseAbortController) { + this.idleCondenseAbortController.abort() + this.idleCondenseAbortController = undefined + } } /** @@ -3698,6 +4374,7 @@ export class Task extends EventEmitter implements TaskLike { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() this.cancelPendingPartialMessageUpdate() + this.cancelIdleCondenseTimer() // Release handler-owned host resources (e.g. VS Code configuration listeners). // This is the fallback for leases that never settle; later lease releases @@ -3953,6 +4630,12 @@ export class Task extends EventEmitter implements TaskLike { throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } + // A new model request is starting, so a condense from earlier is no longer + // the last action. An inline condense during this same request sets the + // flag again (via reportContextManagementResult), correctly suppressing a + // follow-up idle condense when the request that condensed was the last one. + this.lastActionWasCondense = false + if (this.consecutiveMistakeLimit > 0 && this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { // Track consecutive mistake errors in telemetry via a named event. // The reason is "unknown" because many paths increment consecutiveMistakeCount @@ -5417,13 +6100,6 @@ export class Task extends EventEmitter implements TaskLike { })() } - private getCurrentProfileId(state: any): string { - return ( - state?.listApiConfigMeta?.find((profile: any) => profile.name === state?.currentApiConfigName)?.id ?? - "default" - ) - } - /** * Ensures router-provider model metadata is loaded before getModel() is used for * context management or streaming. Failures fall back to hardcoded defaults rather @@ -5497,6 +6173,9 @@ export class Task extends EventEmitter implements TaskLike { await this.say("condense_context_error", result.error) } if (result.summary) { + // A real condense ran (inline auto or forced overflow): suppress idle + // condensing until the task takes another action. + this.lastActionWasCondense = true const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = result const contextCondense: ContextCondense = { summary, @@ -5562,8 +6241,10 @@ export class Task extends EventEmitter implements TaskLike { const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" - // Get the current profile ID using the helper method - const currentProfileId = this.getCurrentProfileId(state) + // Profile overrides follow the task's own provider profile, matching the + // idle-condense gate: a delegated child or reopened task may condense + // under a different profile than the provider's current selection. + const currentProfileId = await this.getTaskProfileId(state) // Log the context window error for debugging console.warn( @@ -5787,8 +6468,10 @@ export class Task extends EventEmitter implements TaskLike { const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" - // Get the current profile ID using the helper method - const currentProfileId = this.getCurrentProfileId(state) + // Profile overrides follow the task's own provider profile, matching + // the idle-condense gate: a delegated child or reopened task may use + // a different profile than the provider's current selection. + const currentProfileId = await this.getTaskProfileId(state) // Check if context management will likely run (threshold check) // This allows us to show an in-progress indicator to the user // We use the centralized willManageContext helper to avoid duplicating threshold logic diff --git a/src/core/task/__tests__/Task.idle-condense.spec.ts b/src/core/task/__tests__/Task.idle-condense.spec.ts new file mode 100644 index 0000000000..3b475df611 --- /dev/null +++ b/src/core/task/__tests__/Task.idle-condense.spec.ts @@ -0,0 +1,1691 @@ +// pnpm --dir src exec vitest run core/task/__tests__/Task.idle-condense.spec.ts +import type { ClineAsk, ClineMessage, ModelInfo, TokenUsage } from "@roo-code/types" + +import type { ContextManagementResult } from "../../context-management" +import type { ApiMessage } from "../../task-persistence/apiMessages" + +import { Task } from "../Task" + +const MODEL_INFO: ModelInfo = { + contextWindow: 1_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, +} + +type StateStub = Partial<{ + idleCondenseContext: boolean + idleCondenseContextPercent: number + idleCondenseTimeInSeconds: number + autoCondenseContext: boolean + autoCondenseContextPercent: number + profileThresholds: Record + profileIdleThresholds: Record + profileIdleCondenseTimes: Record + currentApiConfigName: string + listApiConfigMeta: Array<{ id: string; name: string }> +}> + +const baseState = (overrides: StateStub = {}): StateStub => ({ + idleCondenseContext: true, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 30, + autoCondenseContext: true, + autoCondenseContextPercent: 100, + profileThresholds: {}, + profileIdleThresholds: {}, + profileIdleCondenseTimes: {}, + ...overrides, +}) + +const tokenUsage = (contextTokens: number): TokenUsage => ({ + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + contextTokens, +}) + +// Mirrors the private CondenseContextResult shape returned by condenseContextWithLease. +type CondenseStubResult = { + condensed: boolean + error?: string + newContextTokens?: number + prevContextTokens: number +} + +// Timestamp of the follow-up ask buildTask seeds so the task is idle-eligible. +const PENDING_ASK_TS = 1 + +function buildTask({ + state = baseState(), + contextTokens = 600, + modelInfo = MODEL_INFO, + isCurrentTask = true, + providerExists = true, +}: { + state?: StateStub + contextTokens?: number + modelInfo?: ModelInfo + isCurrentTask?: boolean + providerExists?: boolean +} = {}) { + const task = Object.create(Task.prototype) as Task + + // Context window is 1_000, so contextTokens 600 => 60% full. + // Mirrors condenseContextWithLease: the idle caller passes an + // onLeaseAcquired callback (it posts the in-progress cue and re-checks + // staleness), and a false verdict aborts before any summarization. + const condense = vi.fn(async (onLeaseAcquired?: () => Promise): Promise => { + if (onLeaseAcquired && !(await onLeaseAcquired())) { + return { condensed: false, prevContextTokens: contextTokens } + } + return { condensed: true, newContextTokens: 100, prevContextTokens: contextTokens } + }) + + const postMessageToWebview = vi.fn(async (_message: { type: string; text?: string }) => {}) + const getState = vi.fn(async () => state) + const getCurrentTask = vi.fn(() => (isCurrentTask ? { taskId: "task-1" } : undefined)) + const provider = { getState, getCurrentTask, postMessageToWebview } + + // `abort` may be a prototype accessor, so assign defines an own writable + // data property that shadows it instead of throwing on a getter-only set. + Object.defineProperty(task, "abort", { value: false, configurable: true, writable: true }) + task["abandoned"] = false + task["activeApiWork"] = 0 + // Object.create skips constructor field initialisers; mirror the defaults. + task["lastActionWasCondense"] = false + task["idleCondenseActivitySeq"] = 0 + task["idleCondenseExpiryEvaluating"] = false + // The shared arming path only arms while an idle-eligible ask is pending, + // so every task defaults to waiting on a complete follow-up. Tests that + // need a different waiting state replace clineMessages/askResponse. + task["lastMessageTs"] = PENDING_ASK_TS + task["askResponse"] = undefined + task["clineMessages"] = [ + { ts: PENDING_ASK_TS, type: "ask", ask: "followup", text: "What should I do next?" } as ClineMessage, + ] + // handleWebviewAskResponse marks the seeded follow-up answered and + // persists; the harness must never touch real storage. + task["saveClineMessages"] = vi.fn(async () => true) + task["updateClineMessage"] = vi.fn(async () => {}) + Object.defineProperty(task, "taskId", { value: "task-1" }) + // Double providing only the provider members the idle-condense gate touches + // (getCurrentTask and getState); the real ClineProvider surface is irrelevant here. + task["providerRef"] = { deref: () => (providerExists ? provider : undefined) } as unknown as Task["providerRef"] + task["getTokenUsage"] = vi.fn((): TokenUsage => tokenUsage(contextTokens)) + task["safeEnsureModelFetched"] = vi.fn(async (): Promise => modelInfo) + // No getCondenseContextWindow on the stub: the gate uses the model's raw + // contextWindow, so percent = 100 * contextTokens / 1000. + Object.defineProperty(task, "api", { + value: { getModel: () => ({ id: "test-model" }) }, + }) + task["apiConfiguration"] = {} + task["condenseContextWithLease"] = condense + + return { task, getState, getCurrentTask, condense, postMessageToWebview } +} + +// Ordered webview message types the idle condense emits for the in-progress cue. +const postedCueTypes = (postMessageToWebview: { + mock: { calls: Array<[{ type: string; text?: string }]> } +}): string[] => postMessageToWebview.mock.calls.map(([message]) => message.type) + +// Lease stub override: honors the onLeaseAcquired gate before returning `result`. +const leaseGated = + (result: CondenseStubResult) => + async (onLeaseAcquired?: () => Promise): Promise => { + if (onLeaseAcquired && !(await onLeaseAcquired())) { + return { condensed: false, prevContextTokens: 600 } + } + return result + } + +describe("Task idle condensing — evaluateIdleCondense", () => { + test("builds the gate and clamps the idle threshold to the main threshold", async () => { + const { task } = buildTask({ + state: baseState({ idleCondenseContextPercent: 90, autoCondenseContextPercent: 80 }), + }) + + const gate = await task["evaluateIdleCondense"]() + + expect(gate).toBeDefined() + expect(gate?.idlePercent).toBe(80) + expect(gate?.contextPercent).toBe(60) + expect(gate?.idleSeconds).toBe(30) + }) + + test("returns undefined when the last action was a condense", async () => { + const { task } = buildTask() + task["lastActionWasCondense"] = true + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) + + test("defaults the idle threshold to 50 when unset", async () => { + const { task } = buildTask({ state: baseState({ idleCondenseContextPercent: undefined }) }) + + const gate = await task["evaluateIdleCondense"]() + + expect(gate?.idlePercent).toBe(50) + }) + + test("uses the profile idle threshold override for the current profile", async () => { + // No listApiConfigMeta => the task profile id resolves to "default". + const { task } = buildTask({ state: baseState({ profileIdleThresholds: { default: 20 } }) }) + + const gate = await task["evaluateIdleCondense"]() + + expect(gate?.idlePercent).toBe(20) + }) + + // Two profiles whose idle threshold/delay overrides differ from each other + // and from the global values, so a gate built from the wrong one is visible. + const twoProfileState = () => + baseState({ + currentApiConfigName: "global-profile", + listApiConfigMeta: [ + { id: "profile-global", name: "global-profile" }, + { id: "profile-child", name: "child-profile" }, + ], + profileThresholds: { "profile-global": 30, "profile-child": 70 }, + profileIdleThresholds: { "profile-global": 20, "profile-child": 40 }, + profileIdleCondenseTimes: { "profile-global": 5, "profile-child": 15 }, + }) + + test("uses the task-local profile overrides when the task keeps another profile", async () => { + const { task } = buildTask({ state: twoProfileState() }) + // A delegated child keeps the profile it was created with even though the + // user has since switched the provider's current profile. + task["taskApiConfigReady"] = Promise.resolve() + task["_taskApiConfigName"] = "child-profile" + + const gate = await task["evaluateIdleCondense"]() + + // Threshold and delay come from profile-child (clamped by its own main + // threshold 70), not the global profile's values. + expect(gate?.idlePercent).toBe(40) + expect(gate?.idleSeconds).toBe(15) + }) + + test("falls back to the provider's current profile when the task has none", async () => { + const { task } = buildTask({ state: twoProfileState() }) + + const gate = await task["evaluateIdleCondense"]() + + // No task-local profile name: the provider's current selection wins. + expect(gate?.idlePercent).toBe(20) + expect(gate?.idleSeconds).toBe(5) + }) + + test("returns undefined when the provider is gone", async () => { + const { task } = buildTask({ providerExists: false }) + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) + + test("returns undefined when the task is not the active task", async () => { + const { task } = buildTask({ isCurrentTask: false }) + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) + + test("returns undefined when idle condensing is disabled", async () => { + const { task } = buildTask({ state: baseState({ idleCondenseContext: false }) }) + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) + + test("returns undefined when auto condensing is disabled", async () => { + // Idle condensing is a sub-feature of auto condensing: disabling auto + // condensing disables idle condensing even when idle is still checked. + const { task } = buildTask({ state: baseState({ autoCondenseContext: false }) }) + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) + + test("returns undefined when there are no context tokens yet", async () => { + const { task } = buildTask({ contextTokens: 0 }) + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) + + test("returns undefined when the model has no context window", async () => { + const noWindow: ModelInfo = { ...MODEL_INFO } + delete (noWindow as { contextWindow?: number }).contextWindow + const { task } = buildTask({ modelInfo: noWindow }) + + await expect(task["evaluateIdleCondense"]()).resolves.toBeUndefined() + }) +}) + +describe("Task idle condensing — resolveIdleCondenseTimeInSeconds", () => { + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + }) + + const resolve = (state: { + idleCondenseTimeInSeconds?: number + profileIdleCondenseTimes?: Record + }) => buildTask().task["resolveIdleCondenseTimeInSeconds"](state, "profile-1") + + test("uses the global value when the profile has no entry", () => { + expect(resolve({ idleCondenseTimeInSeconds: 45 })).toBe(45) + }) + + test("uses the default 180s when the global value is unset", () => { + expect(resolve({})).toBe(180) + }) + + test("profile -1 inherits the global value", () => { + expect(resolve({ idleCondenseTimeInSeconds: 45, profileIdleCondenseTimes: { "profile-1": -1 } })).toBe(45) + }) + + test("a non-negative profile value overrides the global value", () => { + expect(resolve({ idleCondenseTimeInSeconds: 45, profileIdleCondenseTimes: { "profile-1": 10 } })).toBe(10) + }) + + test("a zero profile value overrides the global value", () => { + expect(resolve({ idleCondenseTimeInSeconds: 45, profileIdleCondenseTimes: { "profile-1": 0 } })).toBe(0) + }) + + test("falls back to the global value with a warning for an invalid profile value", () => { + expect(resolve({ idleCondenseTimeInSeconds: 45, profileIdleCondenseTimes: { "profile-1": -7 } })).toBe(45) + expect(warn).toHaveBeenCalledTimes(1) + }) + + test("clamps a negative global value to zero", () => { + expect(resolve({ idleCondenseTimeInSeconds: -5 })).toBe(0) + }) +}) + +describe("Task idle condensing — arming the timer", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("arms the idle timer when the context is above the idle threshold", async () => { + const { task } = buildTask() + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(1) + }) + + test("does not arm when the context is below the idle threshold", async () => { + const { task } = buildTask({ contextTokens: 100 }) + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }) + + test("does not arm for an aborted task", async () => { + const { task } = buildTask() + // Redefine rather than assign: `abort` may be a getter-only accessor, + // and the shadowing own property from buildTask is writable at runtime. + Object.defineProperty(task, "abort", { value: true, configurable: true, writable: true }) + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }) + + test("does not arm when a timer is already armed", async () => { + const { task } = buildTask() + const evaluate = vi.fn(async () => undefined) + task["evaluateIdleCondense"] = evaluate + task["idleCondenseTimeoutRef"] = setTimeout(() => {}, 10_000_000) + + await task["maybeStartIdleCondenseTimer"]() + + expect(evaluate).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + + test("does not arm while an idle condense is in flight", async () => { + const { task } = buildTask() + task["idleCondenseInFlight"] = true + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }) + + test("does not arm when the last action was a condense", async () => { + const { task } = buildTask() + task["lastActionWasCondense"] = true + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }) + + // Idle condensing only makes sense while the task genuinely waits on the + // user: the shared arming path rejects tasks with nothing pending, tasks + // mid-approval (command/tool/MCP asks), and answered asks. + test("does not arm when no ask is pending", async () => { + const { task } = buildTask() + task["clineMessages"] = [] + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }) + + test.each(["command", "tool", "use_mcp_server"] as const)( + "does not arm while a %s approval ask is pending", + async (ask) => { + const { task } = buildTask() + task["clineMessages"] = [{ ts: PENDING_ASK_TS, type: "ask", ask } as ClineMessage] + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }, + ) + + test("does not arm once the pending ask has been answered", async () => { + const { task } = buildTask() + task["askResponse"] = "messageResponse" + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(0) + }) + + // The eligible set mirrors the ask categories that arm on a status + // transition: follow-up plus the idle and resumable asks. + test.each(["followup", "completion_result", "resume_task"] as const)( + "arms while a %s ask is pending", + async (ask) => { + const { task } = buildTask() + task["clineMessages"] = [{ ts: PENDING_ASK_TS, type: "ask", ask } as ClineMessage] + + await task["maybeStartIdleCondenseTimer"]() + + expect(vi.getTimerCount()).toBe(1) + }, + ) + + // The tests above call the scheduler directly; these drive the real ask() + // so the two-second status-transition hooks — the only places a waiting + // ask schedules idle condensing — are covered end to end. + function buildAskTask() { + const built = buildTask() + const { task } = built + + task["emit"] = vi.fn() + task["saveClineMessages"] = vi.fn(async () => true) + // The status-timer callbacks resolve the pending ask via + // findMessageByTimestamp, so the stub has to land the message. + task["addToClineMessages"] = vi.fn(async (message: ClineMessage) => { + task["clineMessages"].push(message) + }) + // Empty, never-claimable queue: the ask stays pending for the user. + // defineProperty because messageQueueService is readonly on the class. + Object.defineProperty(task, "messageQueueService", { + value: { + isEmpty: () => true, + hasUnclaimed: () => false, + claimNextMessage: () => undefined, + releaseMessage: () => {}, + }, + }) + + return built + } + + // Starts a blocking ask and advances the fake clock just past the + // two-second status transition, asserting nothing is scheduled before it. + async function startAskAcrossStatusTransition(askType: ClineAsk) { + const { task } = buildAskTask() + const askPromise = task.ask(askType, "waiting on the user", false) + + await vi.advanceTimersByTimeAsync(1_999) + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + // Cross the boundary by 11ms — short enough to skip a pWaitFor poll tick. + await vi.advanceTimersByTimeAsync(11) + + return { task, askPromise } + } + + async function resolveAsk(task: Task, askPromise: ReturnType) { + // Deliberately not handleWebviewAskResponse: it cancels the idle timer, + // which is the very thing the assertions above depend on. + task["askResponse"] = "yesButtonClicked" + await vi.advanceTimersByTimeAsync(100) + await expect(askPromise).resolves.toMatchObject({ response: "yesButtonClicked" }) + } + + test("a pending followup ask schedules idle condensing on the interactive transition", async () => { + const { task, askPromise } = await startAskAcrossStatusTransition("followup") + + expect(task["interactiveAsk"]).toBeDefined() + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + + await resolveAsk(task, askPromise) + }) + + test("a pending resume_task ask schedules idle condensing on the resumable transition", async () => { + const { task, askPromise } = await startAskAcrossStatusTransition("resume_task") + + expect(task["resumableAsk"]).toBeDefined() + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + + await resolveAsk(task, askPromise) + }) + + test("a pending idle ask schedules idle condensing on the idle transition", async () => { + const { task, askPromise } = await startAskAcrossStatusTransition("completion_result") + + expect(task["idleAsk"]).toBeDefined() + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + + await resolveAsk(task, askPromise) + }) + + // Tool and command asks are interactive, so they take the same two-second + // transition — but they are mid-response, so only the status change fires + // and idle condensing stays unscheduled. + test.each(["tool", "command"] as const)( + "a pending %s ask crosses the interactive transition without scheduling idle condensing", + async (askType) => { + const { task, askPromise } = await startAskAcrossStatusTransition(askType) + + expect(task["interactiveAsk"]).toBeDefined() + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + + await resolveAsk(task, askPromise) + }, + ) +}) + +describe("Task idle condensing — reset and cancel", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("reset is a no-op while no timer is armed", () => { + const { task } = buildTask() + // "declined" keeps the stub compatible with the arming-outcome + // signature without arming anything. + const maybeStart = vi.fn(async (): Promise<"declined"> => "declined") + task["maybeStartIdleCondenseTimer"] = maybeStart + + task.resetIdleCondenseTimer() + + expect(maybeStart).not.toHaveBeenCalled() + }) + + test("reset re-arms the countdown only while a timer is armed", async () => { + const { task } = buildTask() + task["idleCondenseTimeoutRef"] = setTimeout(() => {}, 30_000) + const maybeStart = vi.fn(async (): Promise<"declined"> => "declined") + task["maybeStartIdleCondenseTimer"] = maybeStart + + task.resetIdleCondenseTimer() + await vi.runOnlyPendingTimersAsync().catch(() => {}) + + expect(maybeStart).toHaveBeenCalledTimes(1) + // The old countdown was cleared and the stub does not re-arm. + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + }) + + test("cancel clears the armed countdown", () => { + const { task } = buildTask() + task["idleCondenseTimeoutRef"] = setTimeout(() => {}, 30_000) + + task.cancelIdleCondenseTimer() + + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + + test("sending a prompt from the webview cancels the countdown", () => { + const { task } = buildTask() + task["checkpointSave"] = vi.fn(async () => {}) + task["idleCondenseTimeoutRef"] = setTimeout(() => {}, 30_000) + task["lastActionWasCondense"] = true + + task.handleWebviewAskResponse("messageResponse", "new prompt") + + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + // The user sent a prompt: the condense is no longer the last action. + expect(task["lastActionWasCondense"]).toBe(false) + }) +}) + +describe("Task idle condensing — timer expiry", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("condenses once the idle wait elapses and then stops", async () => { + const { task, condense, postMessageToWebview } = buildTask() + + await task["maybeStartIdleCondenseTimer"]() + + await vi.advanceTimersByTimeAsync(29_999) + expect(condense).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + // A condense was the last action: the timer is never re-armed. + expect(condense).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + // Same in-progress cue as a manual condense, in order, for this task. + expect(postedCueTypes(postMessageToWebview)).toEqual([ + "condenseTaskContextStarted", + "condenseTaskContextResponse", + ]) + expect(postMessageToWebview.mock.calls.every(([message]) => message.text === "task-1")).toBe(true) + }) + + test("does not re-arm after a condense even when still above the idle threshold", async () => { + const { task, condense } = buildTask() + condense.mockResolvedValue({ condensed: true, newContextTokens: 600, prevContextTokens: 600 }) + + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(60_000) + expect(condense).toHaveBeenCalledTimes(1) + }) + + test("does not condense when work started while the countdown ran", async () => { + const { task, condense, postMessageToWebview } = buildTask() + + await task["maybeStartIdleCondenseTimer"]() + task["activeApiWork"] = 1 + await vi.advanceTimersByTimeAsync(30_000) + + expect(condense).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(postMessageToWebview).not.toHaveBeenCalled() + }) + + test("re-checks the gate at expiry and skips condensing when disabled meanwhile", async () => { + const { task, condense, getState, postMessageToWebview } = buildTask() + + await task["maybeStartIdleCondenseTimer"]() + getState.mockResolvedValue(baseState({ idleCondenseContext: false })) + await vi.advanceTimersByTimeAsync(30_000) + + expect(condense).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + }) + + test("a zero idle wait fires immediately but never spins a second condense", async () => { + const { task, condense } = buildTask({ state: baseState({ idleCondenseTimeInSeconds: 0 }) }) + condense.mockResolvedValue({ condensed: true, newContextTokens: 600, prevContextTokens: 600 }) + + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(0) + + expect(condense).toHaveBeenCalledTimes(1) + // No re-arm at all after a condense, so a zero-wait setting cannot spin. + await vi.advanceTimersByTimeAsync(60_000) + expect(condense).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + test("a condense that did not shrink the context does not re-arm", async () => { + const { task, condense, postMessageToWebview } = buildTask() + condense.mockImplementation(leaseGated({ condensed: false, prevContextTokens: 600, error: "summarize failed" })) + + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + + expect(condense).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + // The cue still opens and closes even when the condense failed. + expect(postedCueTypes(postMessageToWebview)).toEqual([ + "condenseTaskContextStarted", + "condenseTaskContextResponse", + ]) + }) + + test("a condense that throws still closes the in-progress cue", async () => { + const { task, condense, postMessageToWebview } = buildTask() + condense.mockImplementation(async (onLeaseAcquired?: () => Promise) => { + if (onLeaseAcquired && !(await onLeaseAcquired())) { + return { condensed: false, prevContextTokens: 600 } + } + throw new Error("summarize exploded") + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + try { + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + + expect(condense).toHaveBeenCalledTimes(1) + expect(postedCueTypes(postMessageToWebview)).toEqual([ + "condenseTaskContextStarted", + "condenseTaskContextResponse", + ]) + expect(consoleError).toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + test("an in-flight condense blocks a second expiry pass", async () => { + const { task, condense, postMessageToWebview } = buildTask() + let release: (result: CondenseStubResult) => void = () => {} + condense.mockImplementation(async (onLeaseAcquired?: () => Promise) => { + if (onLeaseAcquired && !(await onLeaseAcquired())) { + return { condensed: false, prevContextTokens: 600 } + } + return new Promise((resolve) => { + release = resolve + }) + }) + + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).toHaveBeenCalledTimes(1) + expect(task["idleCondenseInFlight"]).toBe(true) + + // A manual expiry attempt while the condense is still running must bail. + await task["onIdleCondenseTimerFired"]() + expect(condense).toHaveBeenCalledTimes(1) + // Only the first pass has opened the cue so far. + expect(postedCueTypes(postMessageToWebview)).toEqual(["condenseTaskContextStarted"]) + + release({ condensed: true, newContextTokens: 100, prevContextTokens: 600 }) + await vi.advanceTimersByTimeAsync(0) + expect(task["idleCondenseInFlight"]).toBe(false) + expect(postedCueTypes(postMessageToWebview)).toEqual([ + "condenseTaskContextStarted", + "condenseTaskContextResponse", + ]) + }) + + test("a cancel aborts the in-flight idle summarization request", async () => { + // The idle path runs without the request loop, so its summarization call + // has no request-scoped controller: the expiry run hands the lease a + // task-owned signal, and `cancelCurrentRequest` (user cancel or disposal) + // aborts the in-flight HTTP call through it. + const { task } = buildTask() + let summarySignal: AbortSignal | undefined + let release: (result: CondenseStubResult) => void = () => {} + task["condenseContextWithLease"] = vi.fn( + async (onLeaseAcquired?: () => Promise, signal?: AbortSignal): Promise => { + summarySignal = signal + if (onLeaseAcquired && !(await onLeaseAcquired())) { + return { condensed: false, prevContextTokens: 600 } + } + return new Promise((resolve) => { + release = resolve + }) + }, + ) + + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + expect(task["idleCondenseInFlight"]).toBe(true) + expect(summarySignal).toBeDefined() + expect(summarySignal?.aborted).toBe(false) + + task.cancelCurrentRequest() + expect(summarySignal?.aborted).toBe(true) + // The cancel owns the abort; the finishing run just finds the field clear. + expect(task["idleCondenseAbortController"]).toBeUndefined() + + release({ condensed: false, prevContextTokens: 600 }) + await vi.advanceTimersByTimeAsync(0) + expect(task["idleCondenseInFlight"]).toBe(false) + expect(task["idleCondenseAbortController"]).toBeUndefined() + }) + + test("an idle wait beyond the maximum timer delay waits the full duration", async () => { + const { task, condense } = buildTask({ + state: baseState({ idleCondenseTimeInSeconds: 2_147_484 }), + }) + // Node silently clamps setTimeout delays above 2,147,483,647 ms to + // 1 ms, so a raw delay for 2,147,484 s would condense a task that just + // went idle. Fake timers do not emulate that clamp, so pin the exact + // scheduled delays as well as the elapsed behavior. + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + const scheduledDelays = () => + setTimeoutSpy.mock.calls + .map((call) => call[1]) + .filter((delay): delay is number => typeof delay === "number") + + try { + await task["maybeStartIdleCondenseTimer"]() + // The first slice never exceeds the maximum Node supports. + expect(scheduledDelays()).toEqual([2_147_483_647]) + + await vi.advanceTimersByTimeAsync(2_147_483_647) + expect(condense).not.toHaveBeenCalled() + // The remainder is 2,147,484,000 - 2,147,483,647 = 353 ms. + expect(scheduledDelays()).toEqual([2_147_483_647, 353]) + + await vi.advanceTimersByTimeAsync(353) + expect(condense).toHaveBeenCalledTimes(1) + } finally { + setTimeoutSpy.mockRestore() + } + }) +}) + +describe("Task idle condensing — activity invalidates a pending expiry evaluation", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // Arms the timer with the real gate (first getState call), then defers the + // next one so the expiry's re-evaluation stalls mid-flight, modelling + // activity landing while the async gate evaluation is still running. + function buildPendingGateTask() { + const built = buildTask() + // Object.create skips constructor field initialisers; the discarded + // evaluation must never touch this flag, so mirror the default. + built.task["idleCondenseInFlight"] = false + let resolveState: (state: StateStub) => void = () => {} + const pendingGate = new Promise((resolve) => { + resolveState = resolve + }) + let gateCalls = 0 + built.getState.mockImplementation(() => { + gateCalls++ + return gateCalls === 1 ? Promise.resolve(baseState()) : pendingGate + }) + const settleGate = () => { + resolveState(baseState()) + } + return { ...built, settleGate } + } + + // Arm and expire the timer so onIdleCondenseTimerFired is now awaiting the + // deferred gate. + async function expireIntoPendingGate(task: Task) { + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + } + + test("an answer landing during the gate evaluation cancels the condense", async () => { + const { task, condense, settleGate, postMessageToWebview } = buildPendingGateTask() + task["checkpointSave"] = vi.fn(async () => {}) + + await expireIntoPendingGate(task) + + // The user answers the follow-up while the expiry is still evaluating its gate. + task.handleWebviewAskResponse("messageResponse", "new prompt") + settleGate() + await vi.advanceTimersByTimeAsync(0) + + // The stale evaluation was discarded: no condense, no in-progress cue. + expect(condense).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + expect(task["idleCondenseInFlight"]).toBe(false) + }) + + test("work starting during the gate evaluation cancels the condense", async () => { + const { task, condense, settleGate, postMessageToWebview } = buildPendingGateTask() + + await expireIntoPendingGate(task) + + // A new request starts while the expiry is still evaluating its gate. + task["activeApiWork"] = 1 + settleGate() + await vi.advanceTimersByTimeAsync(0) + + expect(condense).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + expect(task["idleCondenseInFlight"]).toBe(false) + }) + + test("condenses normally when no activity lands during the gate evaluation", async () => { + const { task, condense, settleGate } = buildPendingGateTask() + + await expireIntoPendingGate(task) + + // The gate resolves while the task is still idle: the condense proceeds. + settleGate() + await vi.advanceTimersByTimeAsync(0) + + expect(condense).toHaveBeenCalledTimes(1) + }) + + // A typing signal arrives after the countdown expired but while its + // evaluation still awaits: no timer is armed, so without the + // expiry-evaluating flag the reset would silently no-op and the stale + // evaluation would condense over the conversation being edited. + test("typing during the gate evaluation discards it and restarts the countdown", async () => { + const { task, condense, settleGate, postMessageToWebview } = buildPendingGateTask() + + await expireIntoPendingGate(task) + expect(task["idleCondenseExpiryEvaluating"]).toBe(true) + + // The user types in the prompt area while the expiry is evaluating. + task.resetIdleCondenseTimer() + settleGate() + await vi.advanceTimersByTimeAsync(0) + + // The stale evaluation was discarded: no condense, no in-progress cue. + expect(condense).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + // A fresh countdown replaced it, and the evaluation flag was cleared. + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + expect(task["idleCondenseExpiryEvaluating"]).toBe(false) + + // The restarted countdown runs the condense normally. + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).toHaveBeenCalledTimes(1) + }) + + // A settings save during the expiry evaluation means the evaluation + // measured against pre-save settings: it must be discarded, and the + // re-armed evaluation must apply the newly saved thresholds instead. + test("a settings save during the gate evaluation discards it and re-arms", async () => { + const { task, condense, settleGate, postMessageToWebview } = buildPendingGateTask() + + await expireIntoPendingGate(task) + + task.reevaluateIdleCondenseAfterSettingsSave() + settleGate() + await vi.advanceTimersByTimeAsync(0) + + // The pre-save evaluation was discarded: no condense, no cue. + expect(condense).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + // The post-save evaluation armed a fresh countdown. + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + expect(task["idleCondenseExpiryEvaluating"]).toBe(false) + task["cancelIdleCondenseTimer"]() + }) +}) + +describe("Task idle condensing — activity invalidates a pending arming evaluation", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // Defers the arming gate's provider state read so activity can land while + // maybeStartIdleCondenseTimer is still awaiting evaluateIdleCondense. + function buildPendingArmTask() { + const built = buildTask() + let resolveState: (state: StateStub) => void = () => {} + const pendingGate = new Promise((resolve) => { + resolveState = resolve + }) + built.getState.mockImplementation(() => pendingGate) + return { ...built, settleGate: () => resolveState(baseState()) } + } + + test("an answer landing during the arming evaluation discards the pending arm", async () => { + const { task, settleGate } = buildPendingArmTask() + task["checkpointSave"] = vi.fn(async () => {}) + + const arming = task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(0) + + // The user answers the pending ask while the arming gate still runs. + task.handleWebviewAskResponse("messageResponse", "new prompt") + settleGate() + await arming + + // The stale evaluation was discarded: no countdown for a task that is + // no longer waiting on the user. + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + }) + + test("arming proceeds when no activity lands during the gate evaluation", async () => { + const { task, settleGate } = buildPendingArmTask() + + const arming = task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(0) + + // The gate resolves while the task is still idle: the countdown arms. + settleGate() + await arming + + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + task["cancelIdleCondenseTimer"]() + }) +}) + +describe("Task idle condensing — last-action-was-a-condense suppression", () => { + // Uses the real condenseContextWithLease (the harness stub replaces it) by + // restoring the prototype method on the instance and doubling the lease's + // two collaborators. + function buildLeaseTask(condensed: boolean) { + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + const endApiWork = vi.fn() + task["beginApiWork"] = vi.fn(() => endApiWork) + task["condenseContextWithActiveApi"] = vi.fn(async () => ({ + condensed, + newContextTokens: condensed ? 100 : undefined, + prevContextTokens: 600, + })) + return { task, endApiWork } + } + + test("a successful lease condense marks the condense as the last action", async () => { + const { task, endApiWork } = buildLeaseTask(true) + + await task["condenseContextWithLease"]() + + expect(task["lastActionWasCondense"]).toBe(true) + expect(endApiWork).toHaveBeenCalledTimes(1) + }) + + test("a failed lease condense does not mark the last action", async () => { + const { task } = buildLeaseTask(false) + + await task["condenseContextWithLease"]() + + expect(task["lastActionWasCondense"]).toBe(false) + }) + + test("reporting a condense summary marks the condense as the last action", async () => { + const { task } = buildTask() + const say = vi.fn(async (): Promise => {}) + task["say"] = say + const result: ContextManagementResult = { + messages: [], + summary: "a summary", + cost: 0, + newContextTokens: 100, + prevContextTokens: 600, + condenseId: "condense-1", + } + + await task["reportContextManagementResult"](result) + + expect(task["lastActionWasCondense"]).toBe(true) + }) + + test("reporting sliding-window truncation does not mark the last action", async () => { + const { task } = buildTask() + const say = vi.fn(async (): Promise => {}) + task["say"] = say + const result: ContextManagementResult = { + messages: [], + summary: "", + cost: 0, + prevContextTokens: 600, + truncationId: "truncation-1", + } + + await task["reportContextManagementResult"](result) + + expect(task["lastActionWasCondense"]).toBe(false) + }) + + test("a successful expiry condense suppresses arming until a prompt clears the flag", async () => { + vi.useFakeTimers() + try { + const { task, condense } = buildTask() + // The harness stubs the lease, so mirror the flag the real lease sets. + condense.mockImplementation(async () => { + task["lastActionWasCondense"] = true + return { condensed: true, newContextTokens: 600, prevContextTokens: 600 } + }) + + await task["maybeStartIdleCondenseTimer"]() + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + + // Idle again after the condense: the gate stays closed. + await task["maybeStartIdleCondenseTimer"]() + expect(vi.getTimerCount()).toBe(0) + + // The user sends a prompt: idle condensing becomes eligible again. + task["checkpointSave"] = vi.fn(async () => {}) + task.handleWebviewAskResponse("messageResponse", "new prompt") + // The prompt is consumed by a new request cycle that ends with a + // fresh follow-up ask, so the task is waiting on the user again. + task["askResponse"] = undefined + task["lastMessageTs"] = PENDING_ASK_TS + 1 + task["clineMessages"].push({ + ts: PENDING_ASK_TS + 1, + type: "ask", + ask: "followup", + } as ClineMessage) + await task["maybeStartIdleCondenseTimer"]() + expect(vi.getTimerCount()).toBe(1) + } finally { + vi.useRealTimers() + } + }) +}) + +describe("Task idle condensing — restoring last-action-was-a-condense on reopen", () => { + // Drives the real resumeTaskFromHistory through to the resume ask. Persistence + // reads, the webview ask (which arms the idle timer in production), the + // initialization bookkeeping (run-state transitions are covered elsewhere), + // and the task-loop handoff are stubbed; hydration stays real. + function buildResumeTask(savedApiConversationHistory: ApiMessage[]) { + const { task } = buildTask() + // Object.create skips constructor field initialisers; message hydration needs this Set. + task["cloudSyncedMessageTimestamps"] = new Set() + + task["getSavedClineMessages"] = vi.fn(async () => []) + task["getSavedApiConversationHistory"] = vi.fn(async () => savedApiConversationHistory) + task["markInitialized"] = vi.fn() + + let lastActionWasCondenseAtAsk: boolean | undefined + // Capturing the flag inside the ask stub proves the restore lands before the + // resume ask arms the idle condense timer. + task["ask"] = vi.fn(async (_askType: ClineAsk) => { + lastActionWasCondenseAtAsk = task["lastActionWasCondense"] + return { response: "yesButtonClicked" as const } + }) + task["overwriteApiConversationHistory"] = vi.fn(async (_history: ApiMessage[]) => {}) + const initiateTaskLoop = vi.fn(async (_content: unknown[]) => {}) + task["initiateTaskLoop"] = initiateTaskLoop + + return { task, initiateTaskLoop, flagAtAsk: () => lastActionWasCondenseAtAsk } + } + + test("reopening a task whose history ends in a condense summary marks the last action", async () => { + const { task, initiateTaskLoop, flagAtAsk } = buildResumeTask([ + { role: "user", content: [{ type: "text", text: "do the thing" }] }, + { role: "assistant", content: [{ type: "text", text: "all set" }] }, + { + role: "user", + content: [{ type: "text", text: "Summary of the condensed conversation..." }], + isSummary: true, + condenseId: "condense-1", + }, + ]) + + await task["resumeTaskFromHistory"]() + + expect(flagAtAsk()).toBe(true) + expect(task["lastActionWasCondense"]).toBe(true) + expect(initiateTaskLoop).toHaveBeenCalledTimes(1) + }) + + test("reopening a task with a plain history leaves the flag cleared", async () => { + const { task, initiateTaskLoop, flagAtAsk } = buildResumeTask([ + { role: "user", content: [{ type: "text", text: "do the thing" }] }, + { role: "assistant", content: [{ type: "text", text: "still working on it" }] }, + ]) + + await task["resumeTaskFromHistory"]() + + expect(flagAtAsk()).toBe(false) + expect(task["lastActionWasCondense"]).toBe(false) + expect(initiateTaskLoop).toHaveBeenCalledTimes(1) + }) +}) + +describe("Task idle condensing — reconciling last-action-was-a-condense on history replacement", () => { + // Rewinds, checkpoint restores, and message deletes/edits replace the API + // history wholesale via overwriteApiConversationHistory. The suppression + // flag must follow the new history: without reconciliation a rewind that + // truncates past the condense summary would keep suppressing idle + // condensing until the next prompt, even though the last action is gone. + function buildFlaggedTask() { + const { task } = buildTask() + const history: ApiMessage[] = [ + { role: "user", content: [{ type: "text", text: "do the thing" }] }, + { role: "assistant", content: [{ type: "text", text: "all set" }] }, + { + role: "user", + content: [{ type: "text", text: "Summary of the condensed conversation..." }], + isSummary: true, + condenseId: "condense-1", + }, + ] + task["apiConversationHistory"] = history + task["lastActionWasCondense"] = true + return { task, history } + } + + test("a rewind that truncates past the summary clears the flag", async () => { + const { task, history } = buildFlaggedTask() + + // Checkpoint restore keeps only the messages before the rewind point, + // dropping the trailing condense summary. + await task.overwriteApiConversationHistory(history.slice(0, 2), false) + + expect(task["lastActionWasCondense"]).toBe(false) + }) + + test("a replacement that keeps the trailing summary keeps the flag", async () => { + const { task, history } = buildFlaggedTask() + + await task.overwriteApiConversationHistory(history, false) + + expect(task["lastActionWasCondense"]).toBe(true) + }) + + test("replacing history with an empty list clears the flag", async () => { + const { task } = buildFlaggedTask() + + await task.overwriteApiConversationHistory([], false) + + expect(task["lastActionWasCondense"]).toBe(false) + }) +}) + +describe("Task idle condensing — re-arm after API work discards the timer", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const FOLLOWUP_TS = 1_000 + + // A task waiting on a follow-up ask while a response stream is still active. + function buildApiWorkTask(ask: ClineAsk = "followup") { + const built = buildTask() + const { task } = built + // Object.create skips constructor field initialisers; beginApiWork + // flushes the retired-handler set when the last request finishes. + task["retiredApis"] = new Set() + task["lastMessageTs"] = FOLLOWUP_TS + task["askResponse"] = undefined + const pendingAsk: ClineMessage = { ts: FOLLOWUP_TS, type: "ask", ask, text: "What should I do next?" } + task["clineMessages"] = [pendingAsk] + return built + } + + // Arm the countdown directly, then let it expire while the task still has + // a request in flight, so the expiry discards the timer. Direct arming + // (bypassing the eligibility-gated shared path, covered by its own tests) + // keeps the helper usable for asks that must not arm on their own. + async function discardTimerDuringApiWork(task: Task) { + const endApiWork = task["beginApiWork"]() + task["armIdleCondenseTimeout"](30) + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + expect(task["idleCondenseRearmAfterApiWork"]).toBe(true) + return endApiWork + } + + test("re-arms the timer when the last request finishes with the follow-up still pending", async () => { + const { task, condense } = buildApiWorkTask() + const endApiWork = await discardTimerDuringApiWork(task) + expect(condense).not.toHaveBeenCalled() + + endApiWork() + // Flush the microtasks of the re-arm kicked off by the work completion. + await vi.advanceTimersByTimeAsync(0) + + // The timer was re-armed once, and the flag was consumed. + expect(task["idleCondenseRearmAfterApiWork"]).toBe(false) + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).toHaveBeenCalledTimes(1) + // A successful condense is now the last action: no further re-arm. + expect(vi.getTimerCount()).toBe(0) + }) + + test("does not re-arm once the follow-up has been answered", async () => { + const { task, condense } = buildApiWorkTask() + const endApiWork = await discardTimerDuringApiWork(task) + + task["askResponse"] = "messageResponse" + endApiWork() + await vi.advanceTimersByTimeAsync(0) + + expect(vi.getTimerCount()).toBe(0) + expect(task["idleCondenseRearmAfterApiWork"]).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(condense).not.toHaveBeenCalled() + }) + + test.each([ + ["an approval ask", () => "command" as ClineAsk], + ["a partial follow-up ask", () => "followup" as ClineAsk], + ])("does not re-arm when the stream ends with %s", async (_label, pickAsk) => { + const ask = pickAsk() + const { task, condense } = buildApiWorkTask(ask) + if (ask === "followup") { + // Only a complete follow-up counts as pending. + ;(task["clineMessages"][0] as ClineMessage).partial = true + } + const endApiWork = await discardTimerDuringApiWork(task) + + endApiWork() + await vi.advanceTimersByTimeAsync(0) + + expect(vi.getTimerCount()).toBe(0) + expect(task["idleCondenseRearmAfterApiWork"]).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(condense).not.toHaveBeenCalled() + }) + + // The idle-eligible set (idle and resumable asks) re-arms too: they wait + // on the user just like a follow-up, so a discarded timer must return. + test.each(["completion_result", "resume_task"] as const)( + "re-arms the timer when the last request finishes with a %s ask still pending", + async (ask) => { + const { task, condense } = buildApiWorkTask(ask) + const endApiWork = await discardTimerDuringApiWork(task) + expect(condense).not.toHaveBeenCalled() + + endApiWork() + // Flush the microtasks of the re-arm kicked off by the completion. + await vi.advanceTimersByTimeAsync(0) + + expect(task["idleCondenseRearmAfterApiWork"]).toBe(false) + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).toHaveBeenCalledTimes(1) + }, + ) +}) + +describe("Task idle condensing — history barrier serializes ask responses", () => { + // The lease sets condenseHistoryBarrier so a pending ask holds a landing + // response and the queue drain stays blocked while the summary replaces the + // API history. The barrier must settle on both the success and failure + // paths so waiting responses never remain blocked. + function buildBarrierTask(summarize: () => Promise) { + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + task["beginApiWork"] = vi.fn(() => vi.fn()) + task["condenseContextWithActiveApi"] = vi.fn(summarize) + return task + } + + test("the barrier is held while the summary runs and released once it succeeds", async () => { + let barrierDuringSummarize: Promise | undefined + const task = buildBarrierTask(async () => { + barrierDuringSummarize = task["condenseHistoryBarrier"] + return { condensed: true, newContextTokens: 100, prevContextTokens: 600 } + }) + expect(task["condenseHistoryBarrier"]).toBeUndefined() + + await task["condenseContextWithLease"]() + + expect(barrierDuringSummarize).toBeInstanceOf(Promise) + expect(task["condenseHistoryBarrier"]).toBeUndefined() + }) + + test("a condense that throws releases the barrier so responses are not blocked", async () => { + let barrierDuringSummarize: Promise | undefined + const task = buildBarrierTask(async () => { + barrierDuringSummarize = task["condenseHistoryBarrier"] + throw new Error("summarize failed") + }) + + await expect(task["condenseContextWithLease"]()).rejects.toThrow("summarize failed") + + expect(barrierDuringSummarize).toBeInstanceOf(Promise) + expect(task["condenseHistoryBarrier"]).toBeUndefined() + }) + + test("concurrent leases serialize: the second summary waits for the first", async () => { + // A manual condense and an idle condense must never overlap: the second + // lease chains behind the first via condenseLeaseTail, so it cannot + // install its barrier or summarize while the first still holds history. + let releaseFirst!: () => void + const firstSummaryDone = new Promise((resolve) => { + releaseFirst = resolve + }) + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + task["beginApiWork"] = vi.fn(() => vi.fn()) + const seenBarriers: Array | undefined> = [] + let calls = 0 + const summarize = vi.fn(async () => { + calls += 1 + seenBarriers.push(task["condenseHistoryBarrier"]) + if (calls === 1) { + await firstSummaryDone + } + return { condensed: true, newContextTokens: 100, prevContextTokens: 600 } + }) + task["condenseContextWithActiveApi"] = summarize + + const first = task["condenseContextWithLease"]() + const second = task["condenseContextWithLease"]() + + // The first summary is in flight while the second must still be chained + // behind it: no second summarize, and the field still holds the first barrier. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(summarize).toHaveBeenCalledTimes(1) + expect(task["condenseHistoryBarrier"]).toBe(seenBarriers[0]) + + releaseFirst() + await Promise.all([first, second]) + + expect(summarize).toHaveBeenCalledTimes(2) + // Each lease ran under its own barrier, and both settled. + expect(seenBarriers[0]).toBeInstanceOf(Promise) + expect(seenBarriers[1]).toBeInstanceOf(Promise) + expect(seenBarriers[1]).not.toBe(seenBarriers[0]) + expect(task["condenseHistoryBarrier"]).toBeUndefined() + }) + + test("the idle callback runs under the barrier before the condense counts as API work", async () => { + // The in-progress cue and the staleness re-check run inside the lease's + // barrier: a response landing while the cue is posted must stay held. + // beginApiWork runs only after the callback passes, so the lease's own + // API-work count cannot make the callback see activeApiWork > 0 and + // cancel every idle condense. + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + const beginApiWork = vi.fn(() => vi.fn()) + task["beginApiWork"] = beginApiWork + task["condenseContextWithActiveApi"] = vi.fn(async () => ({ + condensed: true, + newContextTokens: 100, + prevContextTokens: 600, + })) + + let barrierDuringCue: Promise | undefined + let apiWorkDuringCue = -1 + const result = await task["condenseContextWithLease"](async () => { + barrierDuringCue = task["condenseHistoryBarrier"] + apiWorkDuringCue = task["activeApiWork"] + return true + }) + + expect(result.condensed).toBe(true) + expect(barrierDuringCue).toBeInstanceOf(Promise) + expect(apiWorkDuringCue).toBe(0) + expect(beginApiWork).toHaveBeenCalledTimes(1) + expect(task["lastActionWasCondense"]).toBe(true) + expect(task["condenseHistoryBarrier"]).toBeUndefined() + }) + + test("the lease forwards the summary signal to the summarization call", async () => { + // The idle run's task-owned controller signal must reach the summarize + // call, which threads it into the API metadata as `abortSignal`. + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + task["beginApiWork"] = vi.fn(() => vi.fn()) + let summarySignal: AbortSignal | undefined + task["condenseContextWithActiveApi"] = vi.fn(async (signal?: AbortSignal) => { + summarySignal = signal + return { condensed: true, newContextTokens: 100, prevContextTokens: 600 } + }) + const controller = new AbortController() + + const result = await task["condenseContextWithLease"](async () => true, controller.signal) + + expect(result.condensed).toBe(true) + expect(summarySignal).toBe(controller.signal) + }) + + test("a manual condense forwards no summary signal", async () => { + // Manual condensing keeps the current request's signal semantics inside + // condenseContextWithActiveApi; the lease passes no idle signal through. + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + task["beginApiWork"] = vi.fn(() => vi.fn()) + let summarySignal: AbortSignal | undefined + task["condenseContextWithActiveApi"] = vi.fn(async (signal?: AbortSignal) => { + summarySignal = signal + return { condensed: true, newContextTokens: 100, prevContextTokens: 600 } + }) + + await task["condenseContextWithLease"]() + + expect(summarySignal).toBeUndefined() + }) + + test("a false onLeaseAcquired verdict aborts without touching history", async () => { + // The idle caller aborts when the user engaged during the cue's await: + // no summarize call, no API-work count, and the last action is not + // marked as a condense, so the next idle wait can re-arm normally. + const { task } = buildTask() + task["condenseContextWithLease"] = Task.prototype["condenseContextWithLease"].bind(task) + const beginApiWork = vi.fn(() => vi.fn()) + task["beginApiWork"] = beginApiWork + const summarize = vi.fn() + task["condenseContextWithActiveApi"] = summarize + + const result = await task["condenseContextWithLease"](async () => false) + + expect(result).toEqual({ condensed: false, prevContextTokens: 600 }) + expect(summarize).not.toHaveBeenCalled() + expect(beginApiWork).not.toHaveBeenCalled() + expect(task["lastActionWasCondense"]).toBe(false) + expect(task["condenseHistoryBarrier"]).toBeUndefined() + }) +}) + +describe("Task idle condensing — re-evaluation after settings save", () => { + // Saving idle-condense settings re-evaluates the waiting task: enabling + // the feature (or loosening a threshold) while the task is already idle + // above the threshold must start a countdown that nothing else armed, and + // an already-armed countdown must be replaced so it honours the newly + // saved wait instead of firing on the stale deadline. + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("arms a countdown for a task already idle above a newly enabled threshold", async () => { + // Idle condensing off: no countdown was ever armed for this waiting task. + const state = baseState({ idleCondenseContext: false }) + const { task } = buildTask({ state }) + expect(vi.getTimerCount()).toBe(0) + + // The save lands, then the handler re-evaluates the waiting task. + state.idleCondenseContext = true + task.reevaluateIdleCondenseAfterSettingsSave() + // Flush the eligibility check's resolved-promise chain without moving + // the fake clock, so the freshly armed countdown is not fired. + await vi.advanceTimersByTimeAsync(0) + + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + expect(vi.getTimerCount()).toBe(1) + }) + + test("keeps a task with API work in flight ineligible", async () => { + const { task } = buildTask() + task["activeApiWork"] = 1 + + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + + test("re-arms an already-armed countdown with the newly saved longer delay", async () => { + const state = baseState() + const { task, condense } = buildTask({ state }) + await task["maybeStartIdleCondenseTimer"]() + const originalRef = task["idleCondenseTimeoutRef"] + expect(originalRef).toBeDefined() + + // The user saves a longer idle delay while the countdown is armed. + state.idleCondenseTimeInSeconds = 300 + task.reevaluateIdleCondenseAfterSettingsSave() + // Flush the re-evaluation's promise chain so the new countdown is armed. + await vi.advanceTimersByTimeAsync(0) + + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + expect(task["idleCondenseTimeoutRef"]).not.toBe(originalRef) + expect(vi.getTimerCount()).toBe(1) + + // The stale 30s deadline no longer fires. + await vi.advanceTimersByTimeAsync(30_000) + expect(condense).not.toHaveBeenCalled() + // The full newly saved 300s wait elapses. + await vi.advanceTimersByTimeAsync(270_000) + expect(condense).toHaveBeenCalledTimes(1) + }) + + test("re-arms an already-armed countdown with the newly saved shorter delay", async () => { + const state = baseState({ idleCondenseTimeInSeconds: 300 }) + const { task, condense } = buildTask({ state }) + await task["maybeStartIdleCondenseTimer"]() + + // The user saves a shorter idle delay while the countdown is armed. + state.idleCondenseTimeInSeconds = 30 + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(29_999) + expect(condense).not.toHaveBeenCalled() + // The shortened saved wait fires well before the stale 300s deadline. + await vi.advanceTimersByTimeAsync(1) + expect(condense).toHaveBeenCalledTimes(1) + }) + + test("a save that disables the feature cancels an already-armed countdown", async () => { + const state = baseState() + const { task, condense } = buildTask({ state }) + await task["maybeStartIdleCondenseTimer"]() + expect(vi.getTimerCount()).toBe(1) + + state.idleCondenseContext = false + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(300_000) + expect(condense).not.toHaveBeenCalled() + }) + + test("a save that disables the feature does not arm a countdown", async () => { + const { task } = buildTask({ state: baseState({ idleCondenseContext: false }) }) + + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + + test("a transient state-read failure arms no countdown, then retries and condenses", async () => { + // The settings-save re-evaluation races a provider whose state is + // transiently unreadable. The failed pass must leave no countdown + // armed (the save's cancel already consumed the old one), but it must + // not strand an eligible task forever either: the gate is retried + // briefly, and once the read recovers the full saved delay still + // applies before condensing. + const { task, getState, condense } = buildTask() + getState.mockRejectedValueOnce(new Error("storage unavailable")) + + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + + // No countdown armed; only the bounded retry is pending. + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(task["idleCondenseSettingsRetryRef"]).toBeDefined() + + // The first retry fires once the state read works again and arms the + // countdown from scratch. + await vi.advanceTimersByTimeAsync(1_000) + expect(task["idleCondenseSettingsRetryRef"]).toBeUndefined() + expect(task["idleCondenseTimeoutRef"]).toBeDefined() + + // Condensing still waits out the full saved idle delay. + await vi.advanceTimersByTimeAsync(29_999) + expect(condense).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(condense).toHaveBeenCalledTimes(1) + }) + + test("a persisting state-read failure retries a bounded number of times", async () => { + const { task, getState, condense } = buildTask() + getState.mockRejectedValue(new Error("storage unavailable")) + + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + expect(task["idleCondenseSettingsRetryRef"]).toBeDefined() + + // 1s + 5s + 15s of backoff; the fourth attempt is out of retries and + // the chain stops instead of spinning forever. + await vi.advanceTimersByTimeAsync(1_000 + 5_000 + 15_000) + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(task["idleCondenseSettingsRetryRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(600_000) + expect(condense).not.toHaveBeenCalled() + }) + + test("a cancel while a retry is pending stops the retry", async () => { + const { task, getState, condense } = buildTask() + getState.mockRejectedValueOnce(new Error("storage unavailable")) + + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + expect(task["idleCondenseSettingsRetryRef"]).toBeDefined() + + // The task is cancelled before the retry fires: the retry must not + // resurrect a countdown for a dead task. + task.cancelIdleCondenseTimer() + await vi.advanceTimersByTimeAsync(60_000) + + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + expect(condense).not.toHaveBeenCalled() + }) + + test("a later settings save supersedes a pending retry", async () => { + const state = baseState() + const { task, getState, condense } = buildTask({ state }) + getState.mockRejectedValueOnce(new Error("storage unavailable")) + + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + expect(task["idleCondenseSettingsRetryRef"]).toBeDefined() + + // A second save lands while the first pass' retry is pending, this + // one disabling the feature. It clears the stale retry and runs its + // own (now declined) evaluation; the superseded pass must never arm. + state.idleCondenseContext = false + task.reevaluateIdleCondenseAfterSettingsSave() + await vi.advanceTimersByTimeAsync(0) + expect(task["idleCondenseSettingsRetryRef"]).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(600_000) + expect(task["idleCondenseTimeoutRef"]).toBeUndefined() + expect(condense).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c56d5291f6..6dabcca8f0 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -65,7 +65,7 @@ type TaskTestAccess = { requestModelInfo: ModelInfo, ) => Array<{ role: string; content: unknown } | { type: "reasoning"; encrypted_content: string }> attemptApiRequestWithActiveApi: () => AsyncGenerator - condenseContextWithActiveApi: () => Promise + condenseContextWithActiveApi: () => Promise<{ condensed: boolean; prevContextTokens: number }> retiredApis: Set } @@ -2019,7 +2019,10 @@ describe("Cline", () => { const oldDispose = disposableApi(task) let release!: () => void vi.spyOn(getTaskTestAccess(task), "condenseContextWithActiveApi").mockImplementation( - () => new Promise((resolve) => (release = resolve)), + () => + new Promise<{ condensed: boolean; prevContextTokens: number }>((resolve) => { + release = () => resolve({ condensed: false, prevContextTokens: 0 }) + }), ) const condensing = task.condenseContext() @@ -2686,6 +2689,90 @@ describe("Cline", () => { expect(metadata?.mode).toBe("ask") }) + // Regression: a delegated task that keeps a provider profile different from + // the provider's current selection must resolve profile-scoped condense + // thresholds for the task, not for the global selection. A 55k/128k context + // sits above the child profile's 40% threshold and below the global + // profile's 90% threshold, so whether the normal request path condenses + // reveals which profile's threshold the threshold check used. + async function createProfileSplitCondenseTask() { + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ + mode: "code", + autoApprovalEnabled: true, + requestDelaySeconds: 0, + autoCondenseContext: true, + autoCondenseContextPercent: 100, + profileThresholds: { "profile-global": 90, "profile-child": 40 }, + currentApiConfigName: "global-profile", + listApiConfigMeta: [ + { id: "profile-global", name: "global-profile" }, + { id: "profile-child", name: "child-profile" }, + ], + }), + ) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 55_000, + }) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 128_000, + maxTokens: 4096, + inputPrice: 0.3, + outputPrice: 1.5, + } as ModelInfo, + }) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + const stream = (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })() + vi.spyOn(task.api, "createMessage").mockReturnValue(stream) + // The summarizeConversation module mock is never cleared, so pin the + // call count this request starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + return { task, summarizeCallsBefore } + } + + it("condenses the normal request path with the task's own profile threshold when it keeps another profile", async () => { + const { task, summarizeCallsBefore } = await createProfileSplitCondenseTask() + // A delegated child kept a different provider profile than the + // provider's current selection: its 40% threshold must win. + task["taskApiConfigReady"] = Promise.resolve() + task["_taskApiConfigName"] = "child-profile" + + await task.attemptApiRequest(0).next() + + expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) + }) + + it("keeps the provider's current profile threshold for the normal request path when the task has no own profile", async () => { + const { task, summarizeCallsBefore } = await createProfileSplitCondenseTask() + + // Accept path for the fallback: the task initializes to the provider's + // current profile ("profile-global", 90%), so the ~43% context stays + // below the threshold and no condense runs. + await task.attemptApiRequest(0).next() + + expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore) + }) + it("condenses with an undefined state snapshot when the provider is gone", async () => { const task = new Task({ provider: mockProvider, @@ -8090,6 +8177,41 @@ describe("Queued message processing after condense", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("drains the queue after a condense whose summarization failed", async () => { + const provider = createProvider() + const task = new Task({ + provider, + apiConfiguration: apiConfig, + task: "initial task", + startTask: false, + }) + + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("system") + const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + // The summarize request fails, so the condense reports the error and + // returns without replacing the history. + vi.mocked(summarizeConversation).mockResolvedValueOnce({ + messages: [{ role: "user", content: [{ type: "text", text: "unchanged" }], ts: Date.now() }], + summary: "", + cost: 0, + error: "Condensing failed: rate limited", + }) + + // Queue a message while the (failing) condense runs. + task.messageQueueService.addMessage("queued after failure") + + vi.useFakeTimers() + await task.condenseContext() + + vi.runAllTimers() + vi.useRealTimers() + + // The failure path must still deliver the queued message: nothing else + // will trigger a submit, so skipping the drain strands the user's prompt. + expect(submitSpy).toHaveBeenCalledWith("queued after failure", undefined) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + it("does not cross-drain queues between separate tasks", async () => { const providerA = createProvider() const providerB = createProvider() diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 16fbb1c93e..8db9c938b1 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -53,6 +53,102 @@ describe("Task.ask queued message drain", () => { expect(result.text).toBe("picked answer") }) + // Mirrors the release protocol in condenseContextWithLease's finally: clear + // the field, then settle the promise it handed to waiters. + function installCondenseBarrier(task: Task): () => void { + let settle!: () => void + task["condenseHistoryBarrier"] = new Promise((resolve) => { + settle = resolve + }) + return () => { + task["condenseHistoryBarrier"] = undefined + settle() + } + } + + it("holds the queued-message drain while a condense holds the history", async () => { + const task = await createTask() + + // Stand in for condenseContextWithLease summarizing: it replaces the API + // history from a pre-condense snapshot, so the drain must not claim a + // queued message whose follow-up request would append turns the summary + // overwrites. + const releaseBarrier = installCondenseBarrier(task) + task.messageQueueService.addMessage("picked answer") + + const askPromise = task.ask("followup", "Q?", false) + + const premature = await Promise.race([ + askPromise, + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)), + ]) + expect(premature).toBe("pending") + // Not even claimed: the message is intact for the post-settle drain. + expect(task.messageQueueService.messages[0]?.text).toBe("picked answer") + expect(task.messageQueueService.hasUnclaimed()).toBe(true) + + releaseBarrier() + const result = await askPromise + expect(result).toMatchObject({ response: "messageResponse", text: "picked answer" }) + }) + + it("holds a direct ask response while a condense holds the history", async () => { + const task = await createTask() + + const releaseBarrier = installCondenseBarrier(task) + + const askPromise = task.ask("followup", "Q?", false) + + // The user resumes mid-summarize once the ask is waiting (the same + // deferred-delivery timing the other tests use). The response lands but + // stays pending: delivering it would let the follow-up request append + // turns the in-flight summary then overwrites. + await new Promise((resolve) => setTimeout(resolve, 50)) + task.handleWebviewAskResponse("messageResponse", "answered mid-condense") + + const premature = await Promise.race([ + askPromise, + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)), + ]) + expect(premature).toBe("pending") + // The response did land — only its delivery is held. + expect(task["askResponse"]).toBe("messageResponse") + + releaseBarrier() + const result = await askPromise + expect(result).toMatchObject({ response: "messageResponse", text: "answered mid-condense" }) + }) + + it("delivers a held direct response instead of draining a queued message", async () => { + const task = await createTask() + + const releaseBarrier = installCondenseBarrier(task) + // A queued message lands while the condense holds the history. + task.messageQueueService.addMessage("queued while condensing") + + const askPromise = task.ask("followup", "Q?", false) + + // The user resumes directly mid-summarize. The drain gate must prefer + // the landed response: claiming the queued message now would overwrite + // the pending direct response the user is waiting on. + await new Promise((resolve) => setTimeout(resolve, 50)) + task.handleWebviewAskResponse("messageResponse", "answered mid-condense") + + const premature = await Promise.race([ + askPromise, + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)), + ]) + expect(premature).toBe("pending") + expect(task["askResponse"]).toBe("messageResponse") + + releaseBarrier() + const result = await askPromise + // The direct response wins the ask... + expect(result).toMatchObject({ response: "messageResponse", text: "answered mid-condense" }) + // ...and the queued message survives unclaimed for a later turn. + expect(task.messageQueueService.claimNextMessage()?.text).toBe("queued while condensing") + }) + it("does not consume queued messages for command_output asks", async () => { const task = await createTask() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6cec3bdbe7..e1257966ea 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3123,6 +3123,9 @@ export class ClineProvider allowedMaxCost, autoCondenseContext, autoCondenseContextPercent, + idleCondenseContext, + idleCondenseContextPercent, + idleCondenseTimeInSeconds, soundEnabled, ttsEnabled, ttsSpeed, @@ -3178,6 +3181,8 @@ export class ClineProvider codebaseIndexConfig, codebaseIndexModels, profileThresholds, + profileIdleThresholds, + profileIdleCondenseTimes, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, includeDiagnosticMessages, @@ -3286,6 +3291,9 @@ export class ClineProvider allowedMaxCost, autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, + idleCondenseContext: idleCondenseContext ?? false, + idleCondenseContextPercent: idleCondenseContextPercent ?? 50, + idleCondenseTimeInSeconds: idleCondenseTimeInSeconds ?? 180, uriScheme: vscode.env.uriScheme, currentTaskId: currentTask?.taskId, currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, @@ -3380,6 +3388,8 @@ export class ClineProvider // Phase 1 cloud removal: do not let Cloud-auth MDM enforcement force login-only UI flows. mdmCompliant: undefined, profileThresholds: profileThresholds ?? {}, + profileIdleThresholds: profileIdleThresholds ?? {}, + profileIdleCondenseTimes: profileIdleCondenseTimes ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, lockApiConfigAcrossModes: lockApiConfigAcrossModes ?? false, @@ -3536,6 +3546,9 @@ export class ClineProvider allowedMaxCost: stateValues.allowedMaxCost, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + idleCondenseContext: stateValues.idleCondenseContext ?? false, + idleCondenseContextPercent: stateValues.idleCondenseContextPercent ?? 50, + idleCondenseTimeInSeconds: stateValues.idleCondenseTimeInSeconds ?? 180, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], allowedCommands: stateValues.allowedCommands, deniedCommands: stateValues.deniedCommands, @@ -3616,6 +3629,8 @@ export class ClineProvider stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: stateValues.profileThresholds ?? {}, + profileIdleThresholds: stateValues.profileIdleThresholds ?? {}, + profileIdleCondenseTimes: stateValues.profileIdleCondenseTimes ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b85a7ff69c..408f3d4132 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3199,6 +3199,11 @@ describe("ClineProvider", () => { organizationAllowList: ORGANIZATION_ALLOW_ALL, autoCondenseContext: true, autoCondenseContextPercent: 100, + idleCondenseContext: false, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 180, + profileIdleThresholds: {}, + profileIdleCondenseTimes: {}, cloudIsAuthenticated: false, sharingEnabled: false, publicSharingEnabled: false, @@ -4542,6 +4547,367 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) + describe("idle condensing settings", () => { + const getMessageHandler = () => { + const onDidReceive = mockWebviewView.webview.onDidReceiveMessage as ReturnType + return onDidReceive.mock.calls[0][0] as (message: WebviewMessage) => Promise + } + + test("getState defaults idle condensing to disabled with 50% threshold and 180s idle time", async () => { + const idleKeys = [ + "idleCondenseContext", + "idleCondenseContextPercent", + "idleCondenseTimeInSeconds", + "profileIdleThresholds", + "profileIdleCondenseTimes", + ] + ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => + idleKeys.includes(key) ? undefined : null, + ) + + const state = await provider.getState() + + expect(state.idleCondenseContext).toBe(false) + expect(state.idleCondenseContextPercent).toBe(50) + expect(state.idleCondenseTimeInSeconds).toBe(180) + expect(state.profileIdleThresholds).toEqual({}) + expect(state.profileIdleCondenseTimes).toEqual({}) + }) + + test("getState returns saved idle condensing values", async () => { + await provider.contextProxy.setValue("idleCondenseContext", true) + await provider.contextProxy.setValue("idleCondenseContextPercent", 40) + await provider.contextProxy.setValue("idleCondenseTimeInSeconds", 60) + await provider.contextProxy.setValue("profileIdleThresholds", { "model-a": 30 }) + await provider.contextProxy.setValue("profileIdleCondenseTimes", { "model-a": 90 }) + + const state = await provider.getState() + + expect(state.idleCondenseContext).toBe(true) + expect(state.idleCondenseContextPercent).toBe(40) + expect(state.idleCondenseTimeInSeconds).toBe(60) + expect(state.profileIdleThresholds).toEqual({ "model-a": 30 }) + expect(state.profileIdleCondenseTimes).toEqual({ "model-a": 90 }) + }) + + test("getStateToPostToWebview defaults idle condensing fields", async () => { + await provider.resolveWebviewView(mockWebviewView) + ;(mockContext.globalState.get as ReturnType).mockImplementation(() => undefined) + + const state = await provider.getStateToPostToWebview() + + expect(state.idleCondenseContext).toBe(false) + expect(state.idleCondenseContextPercent).toBe(50) + expect(state.idleCondenseTimeInSeconds).toBe(180) + expect(state.profileIdleThresholds).toEqual({}) + expect(state.profileIdleCondenseTimes).toEqual({}) + }) + + test("getStateToPostToWebview returns saved idle condensing values", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.contextProxy.setValue("idleCondenseContext", true) + await provider.contextProxy.setValue("idleCondenseContextPercent", 42) + await provider.contextProxy.setValue("idleCondenseTimeInSeconds", 15) + await provider.contextProxy.setValue("profileIdleThresholds", { "model-a": 25 }) + await provider.contextProxy.setValue("profileIdleCondenseTimes", { "model-a": -1 }) + + const state = await provider.getStateToPostToWebview() + + expect(state.idleCondenseContext).toBe(true) + expect(state.idleCondenseContextPercent).toBe(42) + expect(state.idleCondenseTimeInSeconds).toBe(15) + expect(state.profileIdleThresholds).toEqual({ "model-a": 25 }) + expect(state.profileIdleCondenseTimes).toEqual({ "model-a": -1 }) + }) + + test("clamps an idleCondenseContextPercent message to the maximum threshold", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseContextPercent: 150 } }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContextPercent", 100) + }) + + test("clamps an idleCondenseContextPercent message to the minimum threshold", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseContextPercent: 1 } }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContextPercent", 5) + }) + + test("clamps an idleCondenseContextPercent message to the main threshold saved in the same message", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { idleCondenseContextPercent: 90, autoCondenseContextPercent: 80 }, + }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContextPercent", 80) + }) + + test("keeps a valid idleCondenseContextPercent message unchanged", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseContextPercent: 40 } }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContextPercent", 40) + }) + + test("clamps profileIdleThresholds entries but keeps -1 inheritance", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { + profileIdleThresholds: { inherited: -1, capped: 90, belowMin: 1, aboveMax: 150 }, + autoCondenseContextPercent: 80, + }, + }) + + // 90 clamps to the global main threshold; out-of-bounds values keep global + // inheritance instead of becoming valid overrides through clamping. + expect(updateGlobalStateSpy).toHaveBeenCalledWith("profileIdleThresholds", { + inherited: -1, + capped: 80, + belowMin: -1, + aboveMax: -1, + }) + }) + + test("clamps profileIdleThresholds against each profile's own main threshold", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { + // Global main is 40; "raised" opts into 90 and "inherited" inherits via -1. + autoCondenseContextPercent: 40, + profileThresholds: { raised: 90, lowered: 60, inherited: -1 }, + profileIdleThresholds: { + raised: 70, + lowered: 70, + inherited: 70, + fallback: 70, + idleInherited: -1, + }, + }, + }) + + // "raised" is clamped against its profile threshold (90) and stays 70 instead of + // being lowered to the global 40; "lowered" is capped at its profile threshold (60); + // "inherited" has a -1 profile threshold so it falls back to the global 40, as does + // "fallback" which has no profile threshold entry at all; an idle -1 entry is kept. + expect(updateGlobalStateSpy).toHaveBeenCalledWith("profileIdleThresholds", { + raised: 70, + lowered: 60, + inherited: 40, + fallback: 40, + idleInherited: -1, + }) + }) + + test("clamps idle thresholds against main percentages persisted in global state", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Neither the main percent nor the profile thresholds are in the message, so the + // handler must resolve them from global state instead of the message or the default. + await provider.contextProxy.setValue("autoCondenseContextPercent", 70) + await provider.contextProxy.setValue("profileThresholds", { raised: 90 }) + + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { idleCondenseContextPercent: 90, profileIdleThresholds: { raised: 80 } }, + }) + + // The global idle percent is capped at the stored main percent (70), while the + // profile idle override is clamped against that profile's own stored threshold (90). + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContextPercent", 70) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("profileIdleThresholds", { raised: 80 }) + }) + + test("ignores a non-numeric autoCondenseContextPercent message when clamping idle thresholds", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // The stored main percent is the clamp bound... + await provider.contextProxy.setValue("autoCondenseContextPercent", 70) + + // ...but a hand-crafted message rides a garbage main percent alongside + // otherwise valid idle settings. Feeding it to the clamp unchanged + // poisons every bound (NaN comparisons silently pass), so only a + // finite number from the message may count; here the stored 70 wins. + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { + autoCondenseContextPercent: "oops" as unknown as number, + idleCondenseContextPercent: 90, + profileIdleThresholds: { lowered: 80 }, + }, + }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContextPercent", 70) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("profileIdleThresholds", { lowered: 70 }) + }) + + test("rejects invalid idle-condense payloads instead of persisting them", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // A hand-crafted message can carry payloads the webview controls never produce; + // storing them unchanged would surface as NaN at the runtime (e.g. idle + // condensing below the configured threshold), so the handler must reject them + // without persisting, while valid values in the same save still persist. + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { + idleCondenseContextPercent: "40" as unknown as number, + profileIdleThresholds: null as unknown as Record, + idleCondenseTimeInSeconds: "180" as unknown as number, + profileIdleCondenseTimes: "180" as unknown as Record, + idleCondenseContext: true, + }, + }) + + const persistedKeys: unknown[] = updateGlobalStateSpy.mock.calls.map(([key]: [unknown]) => key) + expect(persistedKeys).not.toContain("idleCondenseContextPercent") + expect(persistedKeys).not.toContain("profileIdleThresholds") + expect(persistedKeys).not.toContain("idleCondenseTimeInSeconds") + expect(persistedKeys).not.toContain("profileIdleCondenseTimes") + // A valid sibling still persists. + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseContext", true) + }) + + test("clamps a negative idleCondenseTimeInSeconds message to zero", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseTimeInSeconds: -5 } }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 0) + }) + + test("rounds a fractional idleCondenseTimeInSeconds message", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseTimeInSeconds: 2.6 } }) + + expect(updateGlobalStateSpy).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 3) + }) + + test("clamps profileIdleCondenseTimes entries but keeps -1 inheritance", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await getMessageHandler()({ + type: "updateSettings", + updatedSettings: { + profileIdleCondenseTimes: { inherited: -1, negative: -3.4, zero: 0, rounded: 10.4 }, + }, + }) + + // A negative delay keeps global inheritance instead of clamping into a + // valid (immediate) 0s override; 0 remains a legitimate explicit override. + expect(updateGlobalStateSpy).toHaveBeenCalledWith("profileIdleCondenseTimes", { + inherited: -1, + negative: -1, + zero: 0, + rounded: 10, + }) + }) + + test("idleCondenseReset message restarts the active task idle timer", async () => { + await provider.resolveWebviewView(mockWebviewView) + const resetIdleCondenseTimer = vi.fn() + // Double only provides the member the idleCondenseReset handler touches. + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ resetIdleCondenseTimer } as unknown as Task) + + await getMessageHandler()({ type: "idleCondenseReset" }) + + expect(resetIdleCondenseTimer).toHaveBeenCalledTimes(1) + }) + + test("idleCondenseReset message is a no-op without an active task", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + await expect(getMessageHandler()({ type: "idleCondenseReset" })).resolves.toBeUndefined() + }) + + test("saving an idle-condense setting re-evaluates the active task", async () => { + await provider.resolveWebviewView(mockWebviewView) + const reevaluateIdleCondenseAfterSettingsSave = vi.fn() + // Double only provides the member the re-evaluation call touches. + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ + reevaluateIdleCondenseAfterSettingsSave, + } as unknown as Task) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseContext: true } }) + + expect(reevaluateIdleCondenseAfterSettingsSave).toHaveBeenCalledTimes(1) + }) + + test("re-evaluates the active task when a later write fails after an idle setting was stored", async () => { + await provider.resolveWebviewView(mockWebviewView) + const reevaluateIdleCondenseAfterSettingsSave = vi.fn() + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ + reevaluateIdleCondenseAfterSettingsSave, + } as unknown as Task) + // The writes are not a transaction: idleCondenseContext stores, then + // the next key's write fails. The stored key still changed the task's + // idle-condense eligibility, so the re-evaluation must still run. + updateGlobalStateSpy.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("write failed")) + + await expect( + getMessageHandler()({ + type: "updateSettings", + updatedSettings: { idleCondenseContext: true, soundEnabled: true }, + }), + ).rejects.toThrow("write failed") + + expect(reevaluateIdleCondenseAfterSettingsSave).toHaveBeenCalledTimes(1) + }) + + test.each([ + ["idleCondenseContextPercent", 60], + ["idleCondenseTimeInSeconds", 60], + ["profileIdleThresholds", { a: 60 }], + ["profileIdleCondenseTimes", { a: 60 }], + ["autoCondenseContext", true], + ["autoCondenseContextPercent", 90], + ["profileThresholds", { a: 90 }], + ])("saving the related key %s also re-evaluates the active task", async (key, value) => { + await provider.resolveWebviewView(mockWebviewView) + const reevaluateIdleCondenseAfterSettingsSave = vi.fn() + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ + reevaluateIdleCondenseAfterSettingsSave, + } as unknown as Task) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { [key]: value } }) + + expect(reevaluateIdleCondenseAfterSettingsSave).toHaveBeenCalledTimes(1) + }) + + test("saving an unrelated setting does not re-evaluate the active task", async () => { + await provider.resolveWebviewView(mockWebviewView) + const reevaluateIdleCondenseAfterSettingsSave = vi.fn() + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ + reevaluateIdleCondenseAfterSettingsSave, + } as unknown as Task) + + await getMessageHandler()({ type: "updateSettings", updatedSettings: { soundEnabled: true } }) + + expect(reevaluateIdleCondenseAfterSettingsSave).not.toHaveBeenCalled() + }) + + test("saving an idle-condense setting without an active task does not throw", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + await expect( + getMessageHandler()({ type: "updateSettings", updatedSettings: { idleCondenseContext: true } }), + ).resolves.toBeUndefined() + }) + }) + describe("auto-close settings are included in posted state", () => { it("getStateToPostToWebview returns saved autoCloseZooOpenedFiles value", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4adb29264b..76d98e79fa 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -102,9 +102,55 @@ import { generateSystemPrompt } from "./generateSystemPrompt" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" +import { MIN_CONDENSE_THRESHOLD, MAX_CONDENSE_THRESHOLD } from "../condense" +import { computeEffectiveCondenseThreshold } from "../context-management" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +/** + * Resolve the main auto-condense threshold that idle thresholds clamp + * against. A hand-crafted message can carry a non-numeric + * autoCondenseContextPercent alongside an otherwise valid idle setting; using + * it unchanged would poison every clamp computed against it (NaN bounds make + * comparisons silently pass). Only finite numbers count — the message value + * first, then the stored value (which the generic persist loop may have just + * written from this same garbage message), and finally the default. + */ +function resolveMainCondensePercent( + updatedSettings: { autoCondenseContextPercent?: unknown } | undefined, + storedPercent: number | undefined, +): number { + const finiteNumber = (candidate: unknown): number | undefined => + typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined + return finiteNumber(updatedSettings?.autoCondenseContextPercent) ?? finiteNumber(storedPercent) ?? 100 +} + +/** + * Clamp an idle-condense trigger threshold: within the shared condense + * threshold bounds and never above the main auto-condense threshold (the idle + * condense is a strictly-earlier trigger of the same condense). + */ +function clampIdleCondensePercent(value: number, mainPercent: number): number { + const upper = Math.min(mainPercent, MAX_CONDENSE_THRESHOLD) + return Math.min(Math.max(value, MIN_CONDENSE_THRESHOLD), upper) +} + +/** + * Setting keys whose save can change a waiting task's idle-condense + * eligibility: enabling the feature or any threshold/time the idle gate + * reads. Saving any other key does not trigger a re-evaluation. + */ +const IDLE_CONDENSE_SETTING_KEYS = new Set([ + "idleCondenseContext", + "idleCondenseContextPercent", + "idleCondenseTimeInSeconds", + "profileIdleThresholds", + "profileIdleCondenseTimes", + "autoCondenseContext", + "autoCondenseContextPercent", + "profileThresholds", +]) + // Serializes handling of "telemetrySetting" messages. Each invocation reads the previous // setting, awaits a persistence write, then applies the new live telemetry state -- with no // serialization, two rapid messages (e.g. a fast toggle) can interleave across those awaits: @@ -751,6 +797,7 @@ export const webviewMessageHandler = async ( // settings page keeps the user's edits as unsaved after a failure. The error is then thrown, as it always was. let failed = false let failure: unknown + let idleCondenseSettingsChanged = false try { if (!message.updatedSettings) break @@ -887,13 +934,122 @@ export const webviewMessageHandler = async ( if (!value) { continue } + } else if (key === "autoCondenseContextPercent") { + // Idle thresholds clamp against this value (from the message or + // from storage), and the runtime does the same. Persisting a + // non-numeric one would poison every clamp computed against the + // stored value, so reject it instead of storing it unchanged. + if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) { + provider.log(`[updateSettings] Rejected an invalid value for ${key}`) + continue + } + } else if (key === "idleCondenseContextPercent") { + // A hand-crafted message can carry payloads the webview controls + // never produce; storing them unchanged surfaces as NaN at the + // runtime (e.g. idle condensing below the configured threshold), + // so invalid values are rejected instead of persisted. + if (typeof value !== "number" || !Number.isFinite(value)) { + provider.log(`[updateSettings] Rejected an invalid value for ${key}`) + continue + } + const mainPercent = resolveMainCondensePercent( + message.updatedSettings, + getGlobalState("autoCondenseContextPercent"), + ) + newValue = clampIdleCondensePercent(value, mainPercent) + } else if (key === "profileIdleThresholds") { + if (!value || typeof value !== "object") { + provider.log(`[updateSettings] Rejected an invalid value for ${key}`) + continue + } + const globalMainPercent = resolveMainCondensePercent( + message.updatedSettings, + getGlobalState("autoCondenseContextPercent"), + ) + // Each profile's idle override may only be clamped against that + // profile's own effective main threshold, since that is what the + // runtime resolves per profile; clamping against the global + // threshold could silently lower a profile's idle trigger below + // what the user selected. + const profileMainThresholds = + (message.updatedSettings.profileThresholds as Record | undefined) ?? + getGlobalState("profileThresholds") ?? + {} + newValue = Object.fromEntries( + Object.entries(value as Record).map(([id, percent]) => { + // Mirror computeEffectiveCondenseThreshold's resolve semantics: + // -1 inherits and a value within the shared bounds is a valid + // override; anything else (e.g. 1%) must preserve global + // inheritance instead of becoming a valid override through + // clamping (which would silently condense below the global + // idle threshold). + if ( + percent === -1 || + !Number.isFinite(percent) || + percent < MIN_CONDENSE_THRESHOLD || + percent > MAX_CONDENSE_THRESHOLD + ) { + return [id, -1] + } + return [ + id, + clampIdleCondensePercent( + percent, + computeEffectiveCondenseThreshold({ + globalPercent: globalMainPercent, + profileThresholds: profileMainThresholds, + currentProfileId: id, + }), + ), + ] + }), + ) + } else if (key === "idleCondenseTimeInSeconds") { + if (typeof value !== "number" || !Number.isFinite(value)) { + provider.log(`[updateSettings] Rejected an invalid value for ${key}`) + continue + } + newValue = Math.max(0, Math.round(value)) + } else if (key === "profileIdleCondenseTimes") { + if (!value || typeof value !== "object") { + provider.log(`[updateSettings] Rejected an invalid value for ${key}`) + continue + } + newValue = Object.fromEntries( + Object.entries(value as Record).map(([id, seconds]) => [ + id, + // Mirror resolveIdleCondenseTimeInSeconds' resolve semantics: + // -1 inherits the global wait and any other non-negative number + // is a valid override; anything else (e.g. -7s) must preserve + // global inheritance instead of clamping into a valid override + // (which would silently condense on an immediate 0s trigger). + seconds === -1 || !Number.isFinite(seconds) || seconds < 0 ? -1 : Math.round(seconds), + ]), + ) } await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + + if (IDLE_CONDENSE_SETTING_KEYS.has(key)) { + idleCondenseSettingsChanged = true + } } } catch (error) { failed = true failure = error + } finally { + // Saving these settings can change idle-condense eligibility: a + // task waiting on the user above the idle threshold has no + // countdown armed (and nothing else arms one) when the feature is + // only just enabled or its thresholds loosened. Let the active + // task re-run the full eligibility check; tasks with API work in + // flight stay ineligible and an already-armed countdown is kept. + // The writes are not a transaction, so re-run even when a later + // write failed after an idle key was already stored: that stored + // key still changed the task's eligibility. + if (idleCondenseSettingsChanged) { + provider.getCurrentTask()?.reevaluateIdleCondenseAfterSettingsSave() + } } // A failed refresh must neither undo a stored save nor keep the webview from getting the result. @@ -919,6 +1075,12 @@ export const webviewMessageHandler = async ( break } + case "idleCondenseReset": + // The user typed in the prompt area: restart the idle-condense + // countdown for the active task (no-op unless a timer is armed). + provider.getCurrentTask()?.resetIdleCondenseTimer() + break + case "terminalOperation": if (message.terminalOperation) { await provider.getCurrentTask()?.handleTerminalOperation(message.terminalOperation) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index ae3d504003..7fe9d439eb 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -686,7 +686,7 @@ }, "core/task/Task.ts": { "@typescript-eslint/no-explicit-any": { - "count": 17 + "count": 15 } }, "core/task/__tests__/Task.dispose.test.ts": { diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index b95e59b733..d43165a358 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -155,6 +155,11 @@ export const stories: Record = { const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") return }, + "context-management-settings": async () => { + const { ContextManagementSettingsStory } = + await import("@/components/settings/__tests__/ContextManagementSettings.visual.fixture") + return + }, "history-empty": async () => { const [{ AppProviders }, { default: HistoryView }] = await Promise.all([ import("../AppProviders"), diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8ba0df7ab2..90e07b0711 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -110,6 +110,17 @@ export const ChatTextArea = forwardRef( const normalizedInputValue = typeof inputValue === "string" ? inputValue : "" const { t } = useAppTranslation() + + // Every user-driven prompt mutation must restart the idle-condense + // countdown. Programmatic setInputValue calls (URL paste, dropped + // paths, menu selections, history recall) never fire the textarea's + // onChange, so each mutation site signals the reset itself. The host + // ignores the signal unless a timer is armed, so extra signals are + // harmless. + const signalIdleCondenseReset = useCallback(() => { + vscode.postMessage({ type: "idleCondenseReset" }) + }, []) + const { filePaths, openedTabs, @@ -180,6 +191,11 @@ export const ChatTextArea = forwardRef( } catch { setInputValue(message.text) } + + // execCommand fires a native input event (and thus the + // textarea onChange) only in real browsers, and the + // setInputValue fallback never does; signal unconditionally. + signalIdleCondenseReset() } setIsEnhancingPrompt(false) @@ -203,6 +219,7 @@ export const ChatTextArea = forwardRef( " " + currentValue.slice(cursorPos) setInputValue(newValue) + signalIdleCondenseReset() // Set cursor position after the inserted text const newCursorPos = cursorPos + prefix.length + message.text.length + 1 @@ -233,7 +250,7 @@ export const ChatTextArea = forwardRef( window.addEventListener("message", messageHandler) return () => window.removeEventListener("message", messageHandler) - }, [setInputValue, searchRequestId, normalizedInputValue]) + }, [setInputValue, signalIdleCondenseReset, searchRequestId, normalizedInputValue]) const [isDraggingOver, setIsDraggingOver] = useState(false) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) @@ -350,6 +367,7 @@ export const ChatTextArea = forwardRef( // Handle mode selection. setMode(value) setInputValue("") + signalIdleCondenseReset() setShowContextMenu(false) vscode.postMessage({ type: "mode", text: value }) return @@ -364,6 +382,7 @@ export const ChatTextArea = forwardRef( // Insert the command mention into the textarea const commandMention = `/${value}` setInputValue(commandMention + " ") + signalIdleCondenseReset() setCursorPosition(commandMention.length + 1) setIntendedCursorPosition(commandMention.length + 1) @@ -420,6 +439,7 @@ export const ChatTextArea = forwardRef( ) setInputValue(newValue) + signalIdleCondenseReset() const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1 setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) @@ -434,7 +454,7 @@ export const ChatTextArea = forwardRef( } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [setInputValue, cursorPosition], + [setInputValue, cursorPosition, signalIdleCondenseReset], ) const handleKeyDown = useCallback( @@ -512,6 +532,8 @@ export const ChatTextArea = forwardRef( // Handle prompt history navigation using custom hook if (handleHistoryNavigation(event, showContextMenu, isComposing)) { + // Recalling a prompt rewrites the input without an onChange. + signalIdleCondenseReset() return } @@ -569,6 +591,7 @@ export const ChatTextArea = forwardRef( if (newText !== normalizedInputValue) { event.preventDefault() setInputValue(newText) + signalIdleCondenseReset() setIntendedCursorPosition(newPosition) // Store the new cursor position in state } @@ -597,6 +620,7 @@ export const ChatTextArea = forwardRef( resetHistoryNavigation, commands, enterBehavior, + signalIdleCondenseReset, ], ) @@ -618,6 +642,11 @@ export const ChatTextArea = forwardRef( // Reset history navigation when user types resetOnInputChange() + // Typing in the prompt area resets the idle-condense timer. + // Signal on every edit: a throttled signal can let condensing + // start less than one idle window after a suppressed keystroke. + signalIdleCondenseReset() + const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) @@ -675,7 +704,14 @@ export const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], + [ + setInputValue, + setSearchRequestId, + setFileSearchResults, + setSearchLoading, + resetOnInputChange, + signalIdleCondenseReset, + ], ) useEffect(() => { @@ -710,6 +746,7 @@ export const ChatTextArea = forwardRef( " " + normalizedInputValue.slice(cursorPosition) setInputValue(newValue) + signalIdleCondenseReset() const newCursorPosition = cursorPosition + trimmedUrl.length + 1 setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) @@ -771,7 +808,15 @@ export const ChatTextArea = forwardRef( } } }, - [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, normalizedInputValue, t], + [ + shouldDisableImages, + setSelectedImages, + cursorPosition, + setInputValue, + normalizedInputValue, + t, + signalIdleCondenseReset, + ], ) const handleMenuMouseDown = useCallback(() => { @@ -879,6 +924,7 @@ export const ChatTextArea = forwardRef( const newCursorPosition = cursorPosition + totalLength setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) + signalIdleCondenseReset() } return @@ -940,6 +986,7 @@ export const ChatTextArea = forwardRef( shouldDisableImages, setSelectedImages, t, + signalIdleCondenseReset, ], ) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 820e30849d..5d77510b88 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -654,16 +654,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 || clineAskRef.current === "command_output" ) { try { - console.log("queueMessage", text, images) + // Diagnostic only: never log message text or image data. + console.log("queueMessage", { textLength: text.length, imageCount: images.length }) vscode.postMessage({ type: "queueMessage", text, images }) setInputValue("") setSelectedImages([]) @@ -720,6 +724,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { return currentValue !== "" ? `${currentValue} \n${answer}` : answer }) + // "Copy to input" rewrites the prompt without a textarea onChange, + // so the armed idle-condense countdown must be restarted here. + vscode.postMessage({ type: "idleCondenseReset" }) } else { // Don't clear the input value when sending a follow-up choice // The message should be sent but the text area should preserve what the user typed diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 7cacd4034b..44f691de76 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -5,6 +5,7 @@ import { render, fireEvent, screen, act } from "@src/utils/test-utils" import { useExtensionState } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import * as pathMentions from "@src/utils/path-mentions" +import { ContextMenuOptionType } from "@src/utils/context-mentions" import { ChatTextArea } from "../ChatTextArea" @@ -16,6 +17,20 @@ vi.mock("@src/utils/vscode", () => ({ vi.mock("@src/components/common/CodeBlock") vi.mock("@src/components/common/MarkdownBlock") + +// Capture the ContextMenu's onSelect so tests can invoke menu selections +// directly. The real ContextMenu only renders option rows; stubbing it keeps +// the ChatTextArea signal tests focused on what a selection does. +const contextMenuHarness = vi.hoisted(() => ({ + onSelect: undefined as ((type: string, value?: string) => void) | undefined, +})) + +vi.mock("../ContextMenu", () => ({ + default: (props: { onSelect: (type: string, value?: string) => void }) => { + contextMenuHarness.onSelect = props.onSelect + return null + }, +})) vi.mock("@src/utils/path-mentions", () => ({ convertToMentionPath: vi.fn((path, cwd) => { // Simple mock implementation that mimics the real function's behavior @@ -1607,4 +1622,170 @@ describe("ChatTextArea", () => { expect(defaultProps.setInputValue).toHaveBeenCalledWith("abc/some/path def") }) }) + + describe("idle condense reset signal", () => { + const getTextarea = (container: HTMLElement) => { + const textarea = container.querySelector("textarea") + if (!textarea) { + throw new Error("expected the chat textarea to be rendered") + } + return textarea + } + + const idleSignals = () => + mockPostMessage.mock.calls.filter(([message]) => message?.type === "idleCondenseReset") + + it("posts idleCondenseReset when the user types", () => { + const { container } = render() + const textarea = getTextarea(container) + + fireEvent.change(textarea, { target: { value: "h", selectionStart: 1 } }) + + expect(idleSignals()).toHaveLength(1) + }) + + it("signals on every edit, including edits within the former throttle window", () => { + const { container } = render() + const textarea = getTextarea(container) + + fireEvent.change(textarea, { target: { value: "h", selectionStart: 1 } }) + expect(idleSignals()).toHaveLength(1) + + // Every keystroke must restart the idle countdown. A keystroke + // suppressed by the old one-second throttle could let condensing + // start less than one idle window after the user last typed. + fireEvent.change(textarea, { target: { value: "he", selectionStart: 2 } }) + fireEvent.change(textarea, { target: { value: "hel", selectionStart: 3 } }) + expect(idleSignals()).toHaveLength(3) + }) + + it("posts idleCondenseReset when a URL paste rewrites the prompt", () => { + // A URL paste preventDefaults the native paste and sets the input + // programmatically, so no onChange fires; the mutation itself must + // restart the armed idle countdown. + const { container } = render() + const textarea = getTextarea(container) + + const pasteEvent = new window.Event("paste", { bubbles: true, cancelable: true }) + Object.defineProperty(pasteEvent, "clipboardData", { + value: { items: [], getData: () => "https://example.com" }, + }) + + act(() => { + textarea.dispatchEvent(pasteEvent) + }) + + expect(defaultProps.setInputValue).toHaveBeenCalledWith("https://example.com visit ") + expect(idleSignals()).toHaveLength(1) + }) + + it("posts idleCondenseReset when a dropped path rewrites the prompt", () => { + const { container } = render() + const textarea = getTextarea(container) + textarea.setSelectionRange(3, 3) + fireEvent.mouseUp(textarea) + + fireEvent.drop(container.querySelector(".chat-text-area") as HTMLElement, { + dataTransfer: { getData: () => "/some/path", files: [] }, + preventDefault: vi.fn(), + }) + + expect(defaultProps.setInputValue).toHaveBeenCalledWith("abc/some/path def") + expect(idleSignals()).toHaveLength(1) + }) + + it("posts idleCondenseReset when history navigation recalls a prompt", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + taskHistory: [], + clineMessages: [{ type: "say", say: "user_feedback", text: "First prompt", ts: 1000 }], + cwd: "/test/workspace", + }) + + const { container } = render() + const textarea = getTextarea(container) + textarea.setSelectionRange(0, 0) + + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + + // Recalling a prompt replaces the input without an onChange event. + expect(defaultProps.setInputValue).toHaveBeenCalledWith("First prompt") + expect(idleSignals()).toHaveLength(1) + }) + + it("posts idleCondenseReset when an enhanced prompt replaces the input", () => { + // The enhancedPrompt response rewrites the input programmatically; + // in jsdom there is no execCommand, so the setInputValue fallback + // runs and never fires the textarea onChange. The handler must + // signal unconditionally. + const { container } = render() + const textarea = getTextarea(container) + // The typing signal proves the baseline before the enhancement lands. + fireEvent.change(textarea, { target: { value: "write unit tests", selectionStart: 16 } }) + expect(idleSignals()).toHaveLength(1) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { type: "enhancedPrompt", text: "improved prompt" } }), + ) + }) + + expect(defaultProps.setInputValue).toHaveBeenCalledWith("improved prompt") + expect(idleSignals()).toHaveLength(2) + }) + + it("posts idleCondenseReset when a command is selected from the context menu", () => { + const { container } = render() + const textarea = getTextarea(container) + + // Typing "/" opens the slash-command context menu and signals once. + fireEvent.change(textarea, { target: { value: "/", selectionStart: 1 } }) + expect(contextMenuHarness.onSelect).toBeDefined() + + // Selecting a command rewrites the input programmatically (no + // onChange), so the selection itself must restart the countdown. + act(() => { + contextMenuHarness.onSelect!(ContextMenuOptionType.Command, "test-command") + }) + + expect(defaultProps.setInputValue).toHaveBeenCalledWith("/test-command ") + expect(idleSignals()).toHaveLength(2) + }) + + it("posts idleCondenseReset when a mode is selected from the context menu", () => { + const { container } = render() + const textarea = getTextarea(container) + + fireEvent.change(textarea, { target: { value: "/", selectionStart: 1 } }) + expect(idleSignals()).toHaveLength(1) + + act(() => { + contextMenuHarness.onSelect!(ContextMenuOptionType.Mode, "architect") + }) + + // A mode selection clears the input and switches modes without an + // onChange event. + expect(defaultProps.setMode).toHaveBeenCalledWith("architect") + expect(idleSignals()).toHaveLength(2) + }) + + it("posts idleCondenseReset when a mention is inserted from the context menu", () => { + const { container } = render() + const textarea = getTextarea(container) + + // Typing "@" opens the mention context menu and signals once. + fireEvent.change(textarea, { target: { value: "@", selectionStart: 1 } }) + expect(contextMenuHarness.onSelect).toBeDefined() + + act(() => { + contextMenuHarness.onSelect!(ContextMenuOptionType.Problems) + }) + + // The generic insertMention path rewrites the input programmatically. + expect(defaultProps.setInputValue).toHaveBeenCalled() + expect(idleSignals()).toHaveLength(2) + }) + }) }) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 42edc94b9f..fc68d12bab 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1704,6 +1704,8 @@ describe("ChatView - Follow-up Suggestions", () => { // so the appended "\n" is absent from the DOM value. await waitFor(() => expect(input.value).toBe("Draft text Copy me")) expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "askResponse" })) + // The draft changed programmatically, so the idle countdown must restart. + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "idleCondenseReset" }) // With an empty draft the answer is set as-is (no dangling "\n" prefix). fireEvent.change(input, { target: { value: "" } }) @@ -1752,6 +1754,33 @@ describe("ChatView - Follow-up Suggestions", () => { await waitFor(() => expect(input.value).toBe("Padded")) expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "askResponse" })) }) + + it("posts idleCondenseReset when a setChatBoxMessage invoke rewrites the input", async () => { + const { getByTestId } = renderChatView() + + mockPostMessage({ mode: "ask", customModes: [] }) + + const input = getByTestId("chat-textarea").querySelector("input") + if (!input) { + throw new Error("expected the chat input to be rendered") + } + // Pre-fill a draft so the invoke appends to it, then clear the calls + // made while setting up so only the invoke path is asserted on. + fireEvent.change(input, { target: { value: "Draft" } }) + vscodePostMessageMock.cleanup() + + await dispatchExtensionMessage({ + type: "invoke", + invoke: "setChatBoxMessage", + text: "from invoke", + images: [], + }) + + await waitFor(() => expect(input.value).toBe("Draft from invoke")) + // The invoke path changes the prompt programmatically without a + // textarea onChange, so the armed idle countdown must restart. + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "idleCondenseReset" }) + }) }) describe("ChatView - Context Condensing Indicator Tests", () => { @@ -1767,6 +1796,7 @@ describe("ChatView - Context Condensing Indicator Tests", () => { // First hydrate state with an active task mockPostMessage({ + currentTaskId: "test-task-id", clineMessages: [ { type: "say", @@ -1822,4 +1852,126 @@ describe("ChatView - Context Condensing Indicator Tests", () => { { timeout: 2000 }, ) }) + + it("queues messages sent while a condense is in progress", async () => { + // While isCondensing is true, handleSendMessage must queue the message + // (queueMessage) instead of sending askResponse, so it drains via + // processQueuedMessages once the condensation finishes. + const { getByTestId } = renderChatView() + + // Hydrate with a completed API request so sending is enabled and not streaming + mockPostMessage({ + currentTaskId: "test-task-id", + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + }, + { + type: "say", + say: "api_req_started", + ts: Date.now() - 1000, + text: JSON.stringify({ apiProtocol: "anthropic", cost: 0.05, tokensIn: 100, tokensOut: 50 }), + }, + { + type: "say", + say: "api_req_finished", + ts: Date.now() - 500, + text: JSON.stringify({ cost: 0.05, tokensIn: 100, tokensOut: 50 }), + }, + ], + }) + + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Pin the precondition: sending is enabled and not streaming, so the + // only reason for queueing below is isCondensing. + await waitFor(() => { + const input = getByTestId("chat-textarea").querySelector("input")! + expect(input.getAttribute("data-sending-disabled")).toBe("false") + }) + + // Put ChatView into the condensing state the way the extension does + await act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "condenseTaskContextStarted", text: "test-task-id" }, + }), + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + vscodePostMessageMock.cleanup() + + const chatTextArea = getByTestId("chat-textarea") + const input = chatTextArea.querySelector("input")! as HTMLInputElement + + await act(async () => { + fireEvent.change(input, { target: { value: "queued during condense" } }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) + }) + + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "queueMessage", + text: "queued during condense", + images: [], + }) + }) + + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "askResponse", + askResponse: "messageResponse", + }), + ) + }) + + it("ignores condense cues tagged with another task's id", async () => { + // The started/response cues carry the task they belong to. A cue from + // a different (e.g. previously condensing) task must not toggle the + // current view's indicator; the current task's cue still must. + const { container, getByTestId } = renderChatView() + + await dispatchTaskState("task-a", 5_000) + await waitFor(() => { + expect(getByTestId("task-header")).toHaveAttribute("data-task-id", "task-a") + }) + + const findCondensingRow = () => + Array.from(container.querySelectorAll('[data-testid="chat-row"]')).find((row) => { + const text = row.textContent || "" + return text.includes('"say":"condense_context"') && text.includes('"partial":true') + }) + + // Reject path: a stale started cue for another task stays ignored. + await dispatchExtensionMessage({ type: "condenseTaskContextStarted", text: "task-b" }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(findCondensingRow()).toBeUndefined() + + // Accept path: the current task's cue shows the indicator. + await dispatchExtensionMessage({ type: "condenseTaskContextStarted", text: "task-a" }) + await waitFor(() => { + expect(findCondensingRow()).toBeTruthy() + }) + + // Reject path: a stale response for another task does not clear it. + await dispatchExtensionMessage({ type: "condenseTaskContextResponse", text: "task-b" }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(findCondensingRow()).toBeTruthy() + + // Accept path: the current task's response clears it. + await dispatchExtensionMessage({ type: "condenseTaskContextResponse", text: "task-a" }) + await waitFor(() => { + expect(findCondensingRow()).toBeUndefined() + }) + }) }) diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index c3af315d99..3dad68fc3e 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from "react" +import { FormEvent, HTMLAttributes, useEffect } from "react" import React from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { VSCodeCheckbox, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" @@ -24,10 +24,14 @@ import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +import { useSearchIndexContext } from "./useSettingsSearch" type ContextManagementSettingsProps = HTMLAttributes & { autoCondenseContext: boolean autoCondenseContextPercent: number + idleCondenseContext: boolean + idleCondenseContextPercent: number + idleCondenseTimeInSeconds: number listApiConfigMeta: any[] maxOpenTabsContext: number maxWorkspaceFiles: number @@ -36,6 +40,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxImageFileSize?: number maxTotalImageSize?: number profileThresholds?: Record + profileIdleThresholds?: Record + profileIdleCondenseTimes?: Record includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number writeDelayMs: number @@ -48,6 +54,11 @@ type ContextManagementSettingsProps = HTMLAttributes & { setCachedStateField: SetCachedStateField< | "autoCondenseContext" | "autoCondenseContextPercent" + | "idleCondenseContext" + | "idleCondenseContextPercent" + | "idleCondenseTimeInSeconds" + | "profileIdleThresholds" + | "profileIdleCondenseTimes" | "maxOpenTabsContext" | "maxWorkspaceFiles" | "showRooIgnoredFiles" @@ -65,9 +76,19 @@ type ContextManagementSettingsProps = HTMLAttributes & { > } +// The toolkit checkbox delivers change events retargeted to its custom-element host, which mirrors +// `checked` without being an HTMLInputElement; a directly rendered input is one. Match on the +// property so both target shapes update the setting, and decline targets that carry no boolean +// `checked` rather than writing `undefined` through the guard. +const isCheckboxTarget = (target: EventTarget | null): target is EventTarget & { checked: boolean } => + target !== null && "checked" in target && typeof target.checked === "boolean" + export const ContextManagementSettings = ({ autoCondenseContext, autoCondenseContextPercent, + idleCondenseContext, + idleCondenseContextPercent, + idleCondenseTimeInSeconds, listApiConfigMeta, maxOpenTabsContext, maxWorkspaceFiles, @@ -77,6 +98,8 @@ export const ContextManagementSettings = ({ maxImageFileSize, maxTotalImageSize, profileThresholds = {}, + profileIdleThresholds = {}, + profileIdleCondenseTimes = {}, includeDiagnosticMessages, maxDiagnosticMessages, writeDelayMs, @@ -91,6 +114,28 @@ export const ContextManagementSettings = ({ }: ContextManagementSettingsProps) => { const { t } = useAppTranslation() const [selectedThresholdProfile, setSelectedThresholdProfile] = React.useState("default") + const [selectedIdleProfile, setSelectedIdleProfile] = React.useState("default") + + const idleTimeLabel = t("settings:contextManagement.idleCondensing.idleTime.label") + + // Settings search indexes each tab once during an initial cycling phase, + // before the user has a chance to enable idle condensing. The idle time + // control only renders when idle condensing is enabled, so registering it + // only through the rendered SearchableSetting would leave "Idle time" + // unsearchable whenever the feature starts out disabled. Register the + // label unconditionally here (like SearchableSetting, we never unregister) + // so the result is always in the index; the rendered control below still + // carries the matching data-setting-id for navigation when it is visible. + const searchContext = useSearchIndexContext() + useEffect(() => { + if (searchContext) { + searchContext.registerSetting({ + settingId: "context-idle-time", + section: "contextManagement", + label: idleTimeLabel, + }) + } + }, [searchContext, idleTimeLabel]) // Helper function to get the CONDENSE prompt value const getCondensePromptValue = (): string => { @@ -115,22 +160,64 @@ export const ContextManagementSettings = ({ setCustomSupportPrompts(updatedPrompts) } + // Mirrors computeEffectiveCondenseThreshold in src/core/context-management + // (and the MIN/MAX_CONDENSE_THRESHOLD constants in src/core/condense, which + // the webview cannot import): -1/undefined inherit the global value, a value + // inside [5, 100] overrides it, and anything else (e.g. an invalid imported + // override) falls back to the global value, exactly as the runtime does. + const MIN_CONDENSE_THRESHOLD = 5 + const MAX_CONDENSE_THRESHOLD = 100 + const resolveCondensePercent = (globalPercent: number, profileValue: number | undefined): number => { + if (profileValue === undefined || profileValue === -1) { + return globalPercent + } + if (profileValue >= MIN_CONDENSE_THRESHOLD && profileValue <= MAX_CONDENSE_THRESHOLD) { + return profileValue + } + return globalPercent + } + // Helper function to get the current threshold value based on selected profile const getCurrentThresholdValue = () => { if (selectedThresholdProfile === "default") { return autoCondenseContextPercent } - const profileThreshold = profileThresholds[selectedThresholdProfile] - if (profileThreshold === undefined || profileThreshold === -1) { - return autoCondenseContextPercent // Use default if profile not configured or set to -1 + return resolveCondensePercent(autoCondenseContextPercent, profileThresholds[selectedThresholdProfile]) + } + + // Lowering the condensing trigger threshold lowers the current idle thresholds + // that inherit from it, so the idle threshold never exceeds the trigger + // threshold. The idle slider's max stays at 100%. + const clampIdleToTrigger = (newValue: number) => { + if (idleCondenseContextPercent > newValue) { + setCachedStateField("idleCondenseContextPercent", newValue) + } + const clampedProfileIdleThresholds = { ...profileIdleThresholds } + let changed = false + for (const [id, idleValue] of Object.entries(profileIdleThresholds)) { + if (idleValue === -1) { + continue + } + const profileTrigger = profileThresholds[id] + // Only profile idle overrides that inherit the default trigger threshold follow this change. + if (profileTrigger !== undefined && profileTrigger !== -1) { + continue + } + if (idleValue > newValue) { + clampedProfileIdleThresholds[id] = newValue + changed = true + } + } + if (changed) { + setCachedStateField("profileIdleThresholds", clampedProfileIdleThresholds) } - return profileThreshold } // Helper function to handle threshold changes const handleThresholdChange = (value: number) => { if (selectedThresholdProfile === "default") { setCachedStateField("autoCondenseContextPercent", value) + clampIdleToTrigger(value) } else { const newThresholds = { ...profileThresholds, @@ -138,8 +225,95 @@ export const ContextManagementSettings = ({ } setCachedStateField("profileThresholds", newThresholds) + + const profileIdle = profileIdleThresholds[selectedThresholdProfile] + if (profileIdle !== undefined && profileIdle !== -1 && profileIdle > value) { + setCachedStateField("profileIdleThresholds", { + ...profileIdleThresholds, + [selectedThresholdProfile]: value, + }) + } } } + + // Effective main condense threshold for the profile currently selected in + // the idle-threshold UI: the idle threshold value may never exceed it, + // though the slider's max stays at 100%. Invalid profile overrides resolve + // to the global threshold, matching the runtime fallback. + const getMainThresholdForIdleProfile = () => { + if (selectedIdleProfile === "default") { + return autoCondenseContextPercent + } + return resolveCondensePercent(autoCondenseContextPercent, profileThresholds[selectedIdleProfile]) + } + + // Helper function to get the current idle threshold value based on selected profile. + // The displayed value is capped at the profile's effective main threshold to match + // the runtime behavior (Task.ts resolves idlePercent = Math.min(idlePercentSetting, + // mainPercent)); the inherited setting itself is left unchanged. Invalid profile + // overrides display the global idle threshold they fall back to at runtime. + const getCurrentIdleThresholdValue = () => { + if (selectedIdleProfile === "default") { + return Math.min(idleCondenseContextPercent, getMainThresholdForIdleProfile()) + } + return Math.min( + resolveCondensePercent(idleCondenseContextPercent, profileIdleThresholds[selectedIdleProfile]), + getMainThresholdForIdleProfile(), + ) + } + + // Helper function to handle idle threshold changes (value clamped to the main threshold) + const handleIdleThresholdChange = (value: number) => { + const clamped = Math.min(value, getMainThresholdForIdleProfile()) + if (selectedIdleProfile === "default") { + setCachedStateField("idleCondenseContextPercent", clamped) + } else { + setCachedStateField("profileIdleThresholds", { + ...profileIdleThresholds, + [selectedIdleProfile]: clamped, + }) + } + } + + // Helper function to get the current idle time value based on selected profile. + // Mirrors resolveIdleCondenseTimeInSeconds in Task.ts: only -1/undefined inherit + // and values >= 0 override; anything else falls back to the global value. + const getCurrentIdleTimeValue = () => { + if (selectedIdleProfile === "default") { + return idleCondenseTimeInSeconds + } + const profileSeconds = profileIdleCondenseTimes[selectedIdleProfile] + if (profileSeconds === undefined || profileSeconds === -1) { + return idleCondenseTimeInSeconds + } + if (profileSeconds >= 0) { + return profileSeconds + } + return idleCondenseTimeInSeconds + } + + // Helper function to handle idle time changes + const handleIdleTimeChange = (value: number) => { + const clamped = Math.max(0, Math.round(value || 0)) + if (selectedIdleProfile === "default") { + setCachedStateField("idleCondenseTimeInSeconds", clamped) + } else { + setCachedStateField("profileIdleCondenseTimes", { + ...profileIdleCondenseTimes, + [selectedIdleProfile]: clamped, + }) + } + } + + // Reset the selected profile's idle overrides back to inheriting the global values. + const handleIdleProfileUseGlobal = () => { + if (selectedIdleProfile === "default") { + return + } + setCachedStateField("profileIdleThresholds", { ...profileIdleThresholds, [selectedIdleProfile]: -1 }) + setCachedStateField("profileIdleCondenseTimes", { ...profileIdleCondenseTimes, [selectedIdleProfile]: -1 }) + } + return (
@@ -524,7 +698,11 @@ export const ContextManagementSettings = ({ setSelectedThresholdProfile(value) }} data-testid="threshold-profile-select"> - + {/* At narrow editor widths the selected-profile text must wrap + instead of one-line clamping, so it is never clipped. Radix + drops SelectValue classNames, so the override has to target + the clamping child rule from the trigger itself. */} +
+ + {/* Idle Condensing */} + + ) => { + if (!isCheckboxTarget(e.target)) { + return + } + setCachedStateField("idleCondenseContext", e.target.checked) + }} + data-testid="idle-condense-context-checkbox"> + + {t("settings:contextManagement.idleCondensing.name")} + + +
+ {t("settings:contextManagement.idleCondensing.description")} +
+
+ {idleCondenseContext && ( +
+
+ +
+ + {/* Idle Threshold Slider */} +
+
+ handleIdleThresholdChange(value)} + data-testid="idle-condense-threshold-slider" + aria-label={t("settings:contextManagement.idleCondensing.name")} + /> + {getCurrentIdleThresholdValue()}% +
+
+ {selectedIdleProfile === "default" + ? t("settings:contextManagement.idleCondensing.thresholdDescription") + : t( + "settings:contextManagement.idleCondensing.thresholdProfileDescription", + )} +
+
+ + {/* Idle Time Input */} + +
+ {idleTimeLabel} + + // Number (not parseInt) so a pasted value like "1e3" + // is read as 1000 instead of stopping at "1". + handleIdleTimeChange(Number((e.target as HTMLInputElement).value)) + } + className="w-24" + data-testid="idle-condense-time-input" + /> + {t("settings:contextManagement.idleCondensing.idleTime.seconds")} +
+
+ {t("settings:contextManagement.idleCondensing.idleTime.description")} +
+
+ + {selectedIdleProfile !== "default" && ( +
+ +
+ )} +
+ )} )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 26955663ee..22d837db9b 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -188,6 +188,9 @@ const SettingsView = forwardRef(({ onDone, t allowedWriteFiles, autoCondenseContext, autoCondenseContextPercent, + idleCondenseContext, + idleCondenseContextPercent, + idleCondenseTimeInSeconds, enableCheckpoints, checkpointTimeout, experiments, @@ -218,6 +221,8 @@ const SettingsView = forwardRef(({ onDone, t maxTotalImageSize, customSupportPrompts, profileThresholds, + profileIdleThresholds, + profileIdleCondenseTimes, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, includeDiagnosticMessages, @@ -438,6 +443,9 @@ const SettingsView = forwardRef(({ onDone, t allowedMaxCost: allowedMaxCost ?? null, autoCondenseContext, autoCondenseContextPercent, + idleCondenseContext, + idleCondenseContextPercent, + idleCondenseTimeInSeconds, soundEnabled: soundEnabled ?? true, soundVolume: soundVolume ?? 0.5, ttsEnabled, @@ -485,6 +493,8 @@ const SettingsView = forwardRef(({ onDone, t autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, autoCloseZooOpenedNewFiles: autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, profileThresholds, + profileIdleThresholds, + profileIdleCondenseTimes, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, @@ -931,6 +941,9 @@ const SettingsView = forwardRef(({ onDone, t (({ onDone, t maxImageFileSize={maxImageFileSize} maxTotalImageSize={maxTotalImageSize} profileThresholds={profileThresholds} + profileIdleThresholds={profileIdleThresholds} + profileIdleCondenseTimes={profileIdleCondenseTimes} includeDiagnosticMessages={includeDiagnosticMessages} maxDiagnosticMessages={maxDiagnosticMessages} writeDelayMs={writeDelayMs} diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index 10d5d0fe4f..9b8b5ead6b 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -1,7 +1,8 @@ // npx vitest src/components/settings/__tests__/ContextManagementSettings.spec.tsx -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" +import { render, screen, fireEvent, waitFor, within } from "@/utils/test-utils" import { ContextManagementSettings } from "../ContextManagementSettings" +import { SearchIndexProvider } from "../useSettingsSearch" import { vscode } from "@/utils/vscode" // Mock the translation hook @@ -20,7 +21,7 @@ vi.mock("@/hooks/useAppTranslation", () => ({ // Mock the UI components vi.mock("@/components/ui", () => ({ ...vi.importActual("@/components/ui"), - Slider: ({ value, onValueChange, "data-testid": dataTestId, disabled, min, max }: any) => ( + Slider: ({ value, onValueChange, "data-testid": dataTestId, disabled, min, max, "aria-label": ariaLabel }: any) => ( ({ data-testid={dataTestId} disabled={disabled} role="slider" + aria-label={ariaLabel} /> ), Input: ({ value, onChange, "data-testid": dataTestId, ...props }: any) => ( @@ -76,6 +78,10 @@ vi.mock("@/utils/vscode", () => ({ }, })) +// Records every event the mocked toolkit forwards to a checkbox onChange, keyed by +// data-testid, so tests can assert the component's handler actually received the event. +const forwardedCheckboxChanges = vi.hoisted(() => vi.fn()) + // Mock VSCode components to behave like standard HTML elements vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeCheckbox: ({ checked, onChange, children, "data-testid": dataTestId, ...props }: any) => ( @@ -85,7 +91,11 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ role="checkbox" checked={checked || false} aria-checked={checked || false} - onChange={(e: any) => onChange?.({ target: { checked: e.target.checked } })} + onChange={(e: any) => { + const event = { target: { checked: e.target.checked } } + forwardedCheckboxChanges(dataTestId, event) + onChange?.(event) + }} /> {children} @@ -97,11 +107,16 @@ describe("ContextManagementSettings", () => { const defaultProps = { autoCondenseContext: false, autoCondenseContextPercent: 80, + idleCondenseContext: false, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 180, listApiConfigMeta: [], maxOpenTabsContext: 20, maxWorkspaceFiles: 200, showRooIgnoredFiles: false, profileThresholds: {}, + profileIdleThresholds: {}, + profileIdleCondenseTimes: {}, includeDiagnosticMessages: true, maxDiagnosticMessages: 50, writeDelayMs: 1000, @@ -470,6 +485,28 @@ describe("ContextManagementSettings", () => { // When auto condense is false, threshold slider should not be visible expect(screen.queryByTestId("condense-threshold-slider")).not.toBeInTheDocument() }) + + it("does not render idle condensing controls when autoCondenseContext is false", () => { + render( + , + ) + + // Idle condensing is nested under auto condense: everything idle + // should be hidden while auto condense is unchecked. + expect(screen.queryByTestId("idle-condense-context-checkbox")).not.toBeInTheDocument() + expect(screen.queryByTestId("idle-condense-threshold-slider")).not.toBeInTheDocument() + expect(screen.queryByTestId("idle-condense-time-input")).not.toBeInTheDocument() + }) + + it("renders idle condensing controls when autoCondenseContext is enabled", () => { + render( + , + ) + + expect(screen.getByTestId("idle-condense-context-checkbox")).toBeInTheDocument() + expect(screen.getByTestId("idle-condense-threshold-slider")).toBeInTheDocument() + expect(screen.getByTestId("idle-condense-time-input")).toBeInTheDocument() + }) }) describe("Accessibility", () => { @@ -529,4 +566,461 @@ describe("ContextManagementSettings", () => { }) }) }) + + describe("Idle Condensing", () => { + const idleProps = { + ...defaultProps, + autoCondenseContext: true, + autoCondenseContextPercent: 100, + idleCondenseContext: true, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 180, + listApiConfigMeta: [ + { id: "config-1", name: "Config 1" }, + { id: "config-2", name: "Config 2" }, + ], + } + + const selectIdleProfile = (profileId: string) => { + const idleSelect = screen.getByTestId("idle-threshold-profile-select") + const trigger = idleSelect.querySelector('button[data-testid="threshold-profile-change"]') + if (!trigger) { + throw new Error("idle profile trigger not found") + } + trigger.setAttribute("data-next-value", profileId) + fireEvent.click(trigger) + } + + it("does not render idle controls when idle condensing is disabled", () => { + render() + + expect(screen.queryByTestId("idle-condense-threshold-slider")).not.toBeInTheDocument() + expect(screen.queryByTestId("idle-condense-time-input")).not.toBeInTheDocument() + }) + + it("registers the idle time label for settings search even when idle condensing is disabled", () => { + // Settings search indexes each tab once during the initial cycling + // phase, when idle condensing is still off. The idle time label must + // already be searchable at that point even though its control is + // hidden until the feature is enabled. + const registerSetting = vi.fn() + render( + + + , + ) + + expect(registerSetting).toHaveBeenCalledWith({ + settingId: "context-idle-time", + section: "contextManagement", + label: "settings:contextManagement.idleCondensing.idleTime.label", + }) + // The control itself stays hidden while the feature is off. + expect(screen.queryByTestId("idle-condense-time-input")).not.toBeInTheDocument() + }) + + it("marks the rendered idle time control with the registered search id", () => { + // Search navigation finds the control via [data-setting-id], so the + // rendered control must carry the same id the label was registered with. + render() + + const setting = document.querySelector('[data-setting-id="context-idle-time"]') + expect(setting).not.toBeNull() + expect(within(setting as HTMLElement).getByTestId("idle-condense-time-input")).toBeInTheDocument() + }) + + it("toggles idle condensing setting", () => { + const setCachedStateField = vi.fn() + render() + + const checkbox = screen.getByTestId("idle-condense-context-checkbox") + expect(checkbox.querySelector('input[type="checkbox"]')).toBeChecked() + + fireEvent.click(checkbox) + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseContext", false) + }) + + it("renders idle threshold slider and time input with current values", () => { + render() + + expect(screen.getByTestId("idle-threshold-profile-select")).toBeInTheDocument() + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("50") + expect(screen.getByTestId("idle-condense-time-input")).toHaveValue(180) + }) + + it("updates the global idle threshold", () => { + const setCachedStateField = vi.fn() + render() + + const slider = screen.getByTestId("idle-condense-threshold-slider") + fireEvent.change(slider, { target: { value: "70" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseContextPercent", 70) + }) + + it("keeps the idle slider max at 100 regardless of the main threshold", () => { + render() + + const slider = screen.getByTestId("idle-condense-threshold-slider") + expect(slider).toHaveAttribute("max", "100") + }) + + it("shows the effective clamped idle threshold on initial render", () => { + // A 50% idle threshold under a 40% main threshold runs at 40%, so the + // initial display must agree with the runtime instead of showing 50%. + // Read-only: no interaction, and nothing may be written to cached state. + const setCachedStateField = vi.fn() + render( + , + ) + + const slider = screen.getByTestId("idle-condense-threshold-slider") + expect(slider).toHaveValue("40") + // The percentage shown beside the idle slider (scoped so the main + // threshold slider's own 40% label cannot satisfy the assertion). + expect(within(slider.parentElement as HTMLElement).getByText("40%")).toBeInTheDocument() + expect(setCachedStateField).not.toHaveBeenCalled() + }) + + it("allows idle thresholds down to the shared minimum threshold", () => { + render() + + // The runtime accepts idle overrides as low as MIN_CONDENSE_THRESHOLD (5), + // so the slider must not floor the user at 10. + const slider = screen.getByTestId("idle-condense-threshold-slider") + expect(slider).toHaveAttribute("min", "5") + }) + + it("gives the idle slider an accessible name", () => { + render() + + // The mocked t() returns the key itself, so this asserts the translated + // idle-condensing label is wired to the slider as its aria-label. + const slider = screen.getByTestId("idle-condense-threshold-slider") + expect(slider).toHaveAttribute("aria-label", "settings:contextManagement.idleCondensing.name") + }) + + it("parses an exponent idle time pasted into the idle time input as a full number", () => { + const setCachedStateField = vi.fn() + render() + + // parseInt stops at "e", turning a pasted "1e3" into 1 second; Number + // reads it as 1000. + fireEvent.change(screen.getByTestId("idle-condense-time-input"), { target: { value: "1e3" } }) + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 1000) + + setCachedStateField.mockClear() + fireEvent.change(screen.getByTestId("idle-condense-time-input"), { target: { value: "90.4" } }) + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 90) + + // Empty input still falls back to zero rather than NaN. + setCachedStateField.mockClear() + fireEvent.change(screen.getByTestId("idle-condense-time-input"), { target: { value: "" } }) + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 0) + }) + + it("clamps the idle threshold value to the main condense threshold", () => { + const setCachedStateField = vi.fn() + render( + , + ) + + const slider = screen.getByTestId("idle-condense-threshold-slider") + // Nudging above the main threshold writes the clamped value. + slider.focus() + fireEvent.keyDown(slider, { key: "ArrowRight" }) + + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseContextPercent", 40) + }) + + it("lowers the current idle threshold when the trigger threshold is reduced", () => { + const setCachedStateField = vi.fn() + render() + + const triggerSlider = screen.getByTestId("condense-threshold-slider") + fireEvent.change(triggerSlider, { target: { value: "40" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("autoCondenseContextPercent", 40) + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseContextPercent", 40) + }) + + it("leaves the idle threshold alone when the trigger threshold change does not exceed it", () => { + const setCachedStateField = vi.fn() + render() + + const triggerSlider = screen.getByTestId("condense-threshold-slider") + fireEvent.change(triggerSlider, { target: { value: "80" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("autoCondenseContextPercent", 80) + expect(setCachedStateField).not.toHaveBeenCalledWith("idleCondenseContextPercent", expect.anything()) + }) + + it("lowers a profile idle override when its trigger threshold is reduced", () => { + const setCachedStateField = vi.fn() + render( + , + ) + + // Select config-1 in the trigger-threshold profile picker. + const triggerSelect = screen.getByTestId("threshold-profile-select") + const trigger = triggerSelect.querySelector('button[data-testid="threshold-profile-change"]') + if (!trigger) { + throw new Error("trigger profile trigger not found") + } + trigger.setAttribute("data-next-value", "config-1") + fireEvent.click(trigger) + + const triggerSlider = screen.getByTestId("condense-threshold-slider") + fireEvent.change(triggerSlider, { target: { value: "60" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("profileThresholds", { "config-1": 60 }) + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleThresholds", { "config-1": 60 }) + }) + + it("updates the global idle time", () => { + const setCachedStateField = vi.fn() + render() + + const input = screen.getByTestId("idle-condense-time-input") + fireEvent.change(input, { target: { value: "60" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 60) + }) + + it("clamps a negative idle time to zero", () => { + const setCachedStateField = vi.fn() + render() + + const input = screen.getByTestId("idle-condense-time-input") + fireEvent.change(input, { target: { value: "-5" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("idleCondenseTimeInSeconds", 0) + }) + + it("writes profile-scoped idle threshold overrides", () => { + const setCachedStateField = vi.fn() + render() + + selectIdleProfile("config-1") + + const slider = screen.getByTestId("idle-condense-threshold-slider") + fireEvent.change(slider, { target: { value: "55" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleThresholds", { "config-1": 55 }) + expect(setCachedStateField).not.toHaveBeenCalledWith("idleCondenseContextPercent", expect.anything()) + }) + + it("writes profile-scoped idle time overrides", () => { + const setCachedStateField = vi.fn() + render() + + selectIdleProfile("config-1") + + const input = screen.getByTestId("idle-condense-time-input") + fireEvent.change(input, { target: { value: "90" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleCondenseTimes", { "config-1": 90 }) + expect(setCachedStateField).not.toHaveBeenCalledWith("idleCondenseTimeInSeconds", expect.anything()) + }) + + it("resets profile idle overrides to global via the use-global button", () => { + const setCachedStateField = vi.fn() + render() + + selectIdleProfile("config-1") + + const button = screen.getByTestId("idle-condense-use-global-button") + fireEvent.click(button) + + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleThresholds", { "config-1": -1 }) + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleCondenseTimes", { "config-1": -1 }) + }) + + it("buffers profile idle changes without persisting before Save", () => { + const setCachedStateField = vi.fn() + render() + + selectIdleProfile("config-1") + + const slider = screen.getByTestId("idle-condense-threshold-slider") + slider.focus() + fireEvent.keyDown(slider, { key: "ArrowRight" }) + + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleThresholds", { "config-1": 51 }) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "updateSettings" })) + }) + + it("displays an inherited idle threshold capped at the profile main threshold", () => { + // Runtime resolves idlePercent = Math.min(idlePercentSetting, mainPercent) + // per profile, so a profile inheriting the 50% global idle threshold + // while capping at a 30% main threshold must display 30%, not 50%. + render( + , + ) + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("30") + expect(screen.getByText("30%")).toBeInTheDocument() + }) + + it("displays a profile idle override above its main threshold as capped", () => { + render( + , + ) + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("40") + expect(screen.getByText("40%")).toBeInTheDocument() + }) + + it("leaves a profile idle override below its main threshold uncapped", () => { + render( + , + ) + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("20") + expect(screen.getByText("20%")).toBeInTheDocument() + }) + + it("caps the displayed global idle threshold at the main condense threshold", () => { + // Runtime resolves idlePercent = Math.min(idlePercent, mainPercent), so a + // saved 50% idle threshold against a 40% main threshold must display 40% + // the moment idle condensing is shown, not only after a save. + render() + + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("40") + }) + + it("displays the global main threshold for an invalid profile main override", () => { + // An imported profile main override outside [5, 100] is ignored at + // runtime, which falls back to the global threshold. Displaying the raw + // value would both misreport the threshold and cap idle edits to it. + render( + , + ) + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("50") + expect(screen.getByTestId("condense-threshold-slider")).toHaveValue("100") + }) + + it("displays the global idle threshold for an invalid profile idle override", () => { + render( + , + ) + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-threshold-slider")).toHaveValue("50") + }) + + it("displays the global idle time for an invalid profile idle time override", () => { + render() + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-time-input")).toHaveValue(180) + }) + + it("clamps inheriting profile idle overrides when the default trigger threshold is reduced", () => { + const setCachedStateField = vi.fn() + render( + , + ) + + const triggerSlider = screen.getByTestId("condense-threshold-slider") + fireEvent.change(triggerSlider, { target: { value: "40" } }) + + // config-1 keeps its -1 inheritance, config-2 follows its own profile trigger + // (90) and stays 70, and only config-3, which inherits the default trigger, + // follows the lowered threshold down to 40. + expect(setCachedStateField).toHaveBeenCalledWith("profileIdleThresholds", { + "config-1": -1, + "config-2": 70, + "config-3": 40, + }) + }) + + it("displays a profile idle time override in the idle time input", () => { + render() + + selectIdleProfile("config-1") + + expect(screen.getByTestId("idle-condense-time-input")).toHaveValue(42) + }) + + it("shows each profile's idle threshold override as a suffix in the profile select", () => { + render() + + const idleSelect = screen.getByTestId("idle-threshold-profile-select") + expect(within(idleSelect).getByText("Config 1 (55%)")).toBeInTheDocument() + // A profile without an override shows no suffix. + expect(within(idleSelect).getByText("Config 2")).toBeInTheDocument() + }) + + it("ignores an idle condense checkbox change whose target has no boolean checked value", () => { + const setCachedStateField = vi.fn() + render() + + const input = screen.getByTestId("idle-condense-context-checkbox").querySelector('input[type="checkbox"]')! + // Simulate an event whose target lacks a boolean `checked`: the mocked toolkit + // forwards { target: { checked: undefined } }, so the component's guard must + // reject it. React fires the checkbox onChange on click, and the no-op setter + // keeps jsdom's activation behavior from throwing on the getter-only override. + Object.defineProperty(input, "checked", { get: () => undefined, set: () => {}, configurable: true }) + fireEvent.click(input) + + // expect.anything() would not match an `undefined` value, so also verify the + // handler did receive the malformed event before asserting nothing was written. + expect(forwardedCheckboxChanges).toHaveBeenCalledWith("idle-condense-context-checkbox", { + target: { checked: undefined }, + }) + expect(setCachedStateField).not.toHaveBeenCalled() + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.visual.fixture.tsx new file mode 100644 index 0000000000..f537fcf1a1 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.visual.fixture.tsx @@ -0,0 +1,64 @@ +import React, { useState } from "react" + +import type { ExtensionStateContextType } from "@/context/ExtensionStateContext" +import type { SetCachedStateField } from "../types" +import { ContextManagementSettings } from "../ContextManagementSettings" +import { AppProviders } from "../../../../playwright/AppProviders" + +interface ContextManagementState { + autoCondenseContext: boolean + autoCondenseContextPercent: number + idleCondenseContext: boolean + idleCondenseContextPercent: number + idleCondenseTimeInSeconds: number +} + +export function ContextManagementSettingsStory() { + // The idle condensing panel is pinned in its enabled state, nested inside the + // auto-condense panel, so the snapshot captures the idle branch: profile + // select, threshold slider, and idle time input with deterministic values. + const [state, setState] = useState({ + autoCondenseContext: true, + autoCondenseContextPercent: 80, + idleCondenseContext: true, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 300, + }) + const setCachedStateField: SetCachedStateField = (field, value) => { + setState((current) => { + switch (field) { + case "autoCondenseContext": + case "idleCondenseContext": + return { ...current, [field]: Boolean(value) } + case "autoCondenseContextPercent": + case "idleCondenseContextPercent": + case "idleCondenseTimeInSeconds": + return { ...current, [field]: typeof value === "number" ? value : current[field] } + default: + return current + } + }) + } + + return ( + +
+ undefined} + setCachedStateField={setCachedStateField} + /> +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.visual.tsx new file mode 100644 index 0000000000..656f24ce30 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.visual.tsx @@ -0,0 +1,34 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { collectBoundedLayoutFailures, REFLOW_VIEWPORT_WIDTH } from "../../../../playwright/layout-contracts" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +// Declared in webview-ui/src/i18n/locales/en/settings.json under +// settings:contextManagement.idleCondensing.name; the checkbox takes its accessible +// name from that label text, so the two must stay in sync. +const idleCondenseLabel = "Idle condensing" + +for (const theme of visualThemes) { + test(`renders the enabled idle condensing panel in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + const component = mountedStory(await mount("context-management-settings")) + await applyVisualTheme(page, theme) + const story = component.getByTestId("context-management-settings-story") + const checkbox = story.getByRole("checkbox", { name: idleCondenseLabel }) + await expect(checkbox).toBeVisible() + await expect(checkbox).toBeChecked() + await expect(story.getByTestId("idle-condense-threshold-slider")).toBeVisible() + await expect(story.getByTestId("idle-condense-time-input")).toBeVisible() + await expect(story).toHaveScreenshot(`context-management-settings-${theme.name}.png`) + }) +} + +test("keeps the enabled idle panel readable at narrow editor widths", async ({ mount, page }) => { + await page.setViewportSize({ width: REFLOW_VIEWPORT_WIDTH, height: 640 }) + const component = mountedStory(await mount("context-management-settings")) + const story = component.getByTestId("context-management-settings-story") + + await expect(story.getByTestId("idle-condense-threshold-slider")).toBeVisible() + await expect(story.getByTestId("idle-condense-time-input")).toBeVisible() + await expect(story).toHaveScreenshot("context-management-settings-320.png") + await expect.poll(() => story.evaluate(collectBoundedLayoutFailures)).toEqual([]) +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-320.png b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-320.png new file mode 100644 index 0000000000..ee60fa0707 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-320.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-dark.png new file mode 100644 index 0000000000..4a47361b8e Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-high-contrast-light.png new file mode 100644 index 0000000000..6d91cfc951 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-high-contrast.png new file mode 100644 index 0000000000..da1bf8c6ca Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-light.png new file mode 100644 index 0000000000..d9b5193fc4 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/context-management-settings-light.png differ diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 770f051dc5..97c9d55ebe 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -72,6 +72,10 @@ export interface ExtensionStateContextType extends ExtensionState { marketplaceInstalledMetadata?: MarketplaceInstalledMetadata profileThresholds: Record setProfileThresholds: (value: Record) => void + profileIdleThresholds: Record + setProfileIdleThresholds: (value: Record) => void + profileIdleCondenseTimes: Record + setProfileIdleCondenseTimes: (value: Record) => void setApiConfiguration: (config: ProviderSettings) => void setCustomInstructions: (value?: string) => void setAlwaysAllowReadOnly: (value: boolean) => void @@ -149,6 +153,12 @@ export interface ExtensionStateContextType extends ExtensionState { setAutoCondenseContext: (value: boolean) => void autoCondenseContextPercent: number setAutoCondenseContextPercent: (value: number) => void + idleCondenseContext: boolean + setIdleCondenseContext: (value: boolean) => void + idleCondenseContextPercent: number + setIdleCondenseContextPercent: (value: number) => void + idleCondenseTimeInSeconds: number + setIdleCondenseTimeInSeconds: (value: number) => void routerModels?: RouterModels includeDiagnosticMessages?: boolean setIncludeDiagnosticMessages: (value: boolean) => void @@ -303,6 +313,11 @@ export const createInitialExtensionState = (): ExtensionState => ({ autoCondenseContext: true, autoCondenseContextPercent: 100, profileThresholds: {}, + idleCondenseContext: false, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 180, + profileIdleThresholds: {}, + profileIdleCondenseTimes: {}, codebaseIndexConfig: { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://localhost:6333", @@ -673,6 +688,11 @@ export const ExtensionStateContextProvider: React.FC<{ marketplaceItems, marketplaceInstalledMetadata, profileThresholds: state.profileThresholds ?? {}, + profileIdleThresholds: state.profileIdleThresholds ?? {}, + profileIdleCondenseTimes: state.profileIdleCondenseTimes ?? {}, + idleCondenseContext: state.idleCondenseContext ?? false, + idleCondenseContextPercent: state.idleCondenseContextPercent ?? 50, + idleCondenseTimeInSeconds: state.idleCondenseTimeInSeconds ?? 180, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, taskSyncEnabled: state.taskSyncEnabled, @@ -759,6 +779,14 @@ export const ExtensionStateContextProvider: React.FC<{ setAutoCondenseContextPercent: (value) => setState((prevState) => ({ ...prevState, autoCondenseContextPercent: value })), setProfileThresholds: (value) => setState((prevState) => ({ ...prevState, profileThresholds: value })), + setProfileIdleThresholds: (value) => setState((prevState) => ({ ...prevState, profileIdleThresholds: value })), + setProfileIdleCondenseTimes: (value) => + setState((prevState) => ({ ...prevState, profileIdleCondenseTimes: value })), + setIdleCondenseContext: (value) => setState((prevState) => ({ ...prevState, idleCondenseContext: value })), + setIdleCondenseContextPercent: (value) => + setState((prevState) => ({ ...prevState, idleCondenseContextPercent: value })), + setIdleCondenseTimeInSeconds: (value) => + setState((prevState) => ({ ...prevState, idleCondenseTimeInSeconds: value })), includeDiagnosticMessages: state.includeDiagnosticMessages, setIncludeDiagnosticMessages: (value) => { setState((prevState) => ({ ...prevState, includeDiagnosticMessages: value })) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 424255245a..edc13cb4ae 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -112,6 +112,50 @@ const ApiConfigTestComponent = () => { ) } +const IdleCondenseTestComponent = () => { + const { + idleCondenseContext, + setIdleCondenseContext, + idleCondenseContextPercent, + setIdleCondenseContextPercent, + idleCondenseTimeInSeconds, + setIdleCondenseTimeInSeconds, + profileIdleThresholds, + setProfileIdleThresholds, + profileIdleCondenseTimes, + setProfileIdleCondenseTimes, + } = useExtensionState() + + return ( +
+
{JSON.stringify(idleCondenseContext)}
+
{JSON.stringify(idleCondenseContextPercent)}
+
{JSON.stringify(idleCondenseTimeInSeconds)}
+
{JSON.stringify(profileIdleThresholds)}
+
{JSON.stringify(profileIdleCondenseTimes)}
+ + + + + +
+ ) +} + const InitialStateTestComponent = () => { const { alwaysAllowFollowupQuestions, @@ -689,6 +733,77 @@ describe("ExtensionStateContext", () => { expect(JSON.parse(screen.getByTestId("show-rooignored-files").textContent!)).toBe(true) }) + it("initializes idle condensing fields to their defaults", () => { + const state = createInitialExtensionState() + + expect(state.idleCondenseContext).toBe(false) + expect(state.idleCondenseContextPercent).toBe(50) + expect(state.idleCondenseTimeInSeconds).toBe(180) + expect(state.profileIdleThresholds).toEqual({}) + expect(state.profileIdleCondenseTimes).toEqual({}) + }) + + it("updates idle condensing fields through their setters", () => { + render( + + + , + ) + + expect(JSON.parse(screen.getByTestId("idle-condense").textContent!)).toBe(false) + expect(JSON.parse(screen.getByTestId("idle-condense-percent").textContent!)).toBe(50) + expect(JSON.parse(screen.getByTestId("idle-condense-time").textContent!)).toBe(180) + expect(JSON.parse(screen.getByTestId("profile-idle-thresholds").textContent!)).toEqual({}) + expect(JSON.parse(screen.getByTestId("profile-idle-condense-times").textContent!)).toEqual({}) + + act(() => { + screen.getByTestId("set-idle-condense-button").click() + }) + act(() => { + screen.getByTestId("set-idle-condense-percent-button").click() + }) + act(() => { + screen.getByTestId("set-idle-condense-time-button").click() + }) + act(() => { + screen.getByTestId("set-profile-idle-thresholds-button").click() + }) + act(() => { + screen.getByTestId("set-profile-idle-condense-times-button").click() + }) + + expect(JSON.parse(screen.getByTestId("idle-condense").textContent!)).toBe(true) + expect(JSON.parse(screen.getByTestId("idle-condense-percent").textContent!)).toBe(75) + expect(JSON.parse(screen.getByTestId("idle-condense-time").textContent!)).toBe(60) + expect(JSON.parse(screen.getByTestId("profile-idle-thresholds").textContent!)).toEqual({ architect: 40 }) + expect(JSON.parse(screen.getByTestId("profile-idle-condense-times").textContent!)).toEqual({ code: 90 }) + }) + + it("falls back to the idle condensing defaults when initialState carries explicit undefined values", () => { + // A hand-written state payload can carry explicit undefined values, which the plain + // merge spread lets overwrite the initialized defaults; the provider's fallbacks must + // still expose the documented defaults rather than undefined. + render( + + + , + ) + + expect(JSON.parse(screen.getByTestId("idle-condense").textContent!)).toBe(false) + expect(JSON.parse(screen.getByTestId("idle-condense-percent").textContent!)).toBe(50) + expect(JSON.parse(screen.getByTestId("idle-condense-time").textContent!)).toBe(180) + expect(JSON.parse(screen.getByTestId("profile-idle-thresholds").textContent!)).toEqual({}) + expect(JSON.parse(screen.getByTestId("profile-idle-condense-times").textContent!)).toEqual({}) + }) + it("initializes the write delay to the pre-hydration default", () => { // The initializer itself (not a merge fixture) must carry the shared // default: a regression that dropped it from createInitialExtensionState @@ -911,6 +1026,9 @@ describe("mergeExtensionState", () => { organizationAllowList: { allowAll: true, providers: {} }, autoCondenseContext: true, autoCondenseContextPercent: 100, + idleCondenseContext: false, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 180, cloudIsAuthenticated: false, sharingEnabled: false, publicSharingEnabled: false, @@ -983,6 +1101,9 @@ describe("mergeExtensionState", () => { organizationAllowList: { allowAll: true, providers: {} }, autoCondenseContext: true, autoCondenseContextPercent: 100, + idleCondenseContext: false, + idleCondenseContextPercent: 50, + idleCondenseTimeInSeconds: 180, cloudIsAuthenticated: false, sharingEnabled: false, publicSharingEnabled: false, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 41bbcc4b0f..b7c8964f02 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Llindar personalitzat només per a aquest perfil (substitueix el per defecte global)", "usesGlobal": "(utilitza global {{threshold}}%)" }, + "idleCondensing": { + "name": "Condensació en repòs", + "description": "Un llindar més baix per condensar el context durant els moments de repòs, així sempre tens un context net quan tornes al teclat.", + "thresholdDescription": "Quan el context arriba a aquest percentatge en repòs, s'activa el temporitzador de condensació en repòs. Ha de ser igual o inferior al llindar d'activació de condensació.", + "thresholdProfileDescription": "Llindar de repòs personalitzat només per a aquest perfil (substitueix el per defecte global, limitat al llindar de condensació del perfil)", + "idleTime": { + "label": "Temps de repòs", + "seconds": "segons", + "description": "Quant de temps ha d'estar el model inactiu abans que s'activi la condensació. Posa'l a 0 per condensar tan aviat com el model quedi inactiu." + }, + "useGlobal": "Usa la configuració global" + }, "maxImageFileSize": { "label": "Mida màxima d'arxiu d'imatge", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index a4a8b8c606..15d188fa10 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Benutzerdefinierter Schwellenwert nur für dieses Profil (überschreibt globalen Standard)", "usesGlobal": "(verwendet global {{threshold}}%)" }, + "idleCondensing": { + "name": "Komprimierung im Leerlauf", + "description": "Ein niedrigerer Schwellenwert zum Komprimieren des Kontexts im Leerlauf, damit du immer einen sauberen Kontext hast, wenn du zurück an die Tastatur kommst.", + "thresholdDescription": "Wenn der Kontext im Leerlauf diesen Prozentsatz erreicht, läuft der Leerlauf-Komprimierungs-Timer. Muss gleich oder niedriger als der Schwellenwert für Kontextkomprimierung sein.", + "thresholdProfileDescription": "Benutzerdefinierter Leerlauf-Schwellenwert nur für dieses Profil (überschreibt globalen Standard, begrenzt auf den Komprimierungsschwellenwert des Profils)", + "idleTime": { + "label": "Leerlaufzeit", + "seconds": "Sekunden", + "description": "Wie lange das Modell im Leerlauf sein muss, bevor die Komprimierung ausgelöst wird. Auf 0 setzen, um sofort zu komprimieren, wenn das Modell in den Leerlauf geht." + }, + "useGlobal": "Globale Einstellungen verwenden" + }, "maxImageFileSize": { "label": "Maximale Bilddateigröße", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5cd604aed..c0bf272a3c 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -839,6 +839,18 @@ "profileDescription": "Custom threshold for this profile only (overrides global default)", "usesGlobal": "(uses global {{threshold}}%)" }, + "idleCondensing": { + "name": "Idle condensing", + "description": "A lower threshold for condensing the context during idle moments, so you always have a clean context when you return to the keyboard.", + "thresholdDescription": "When the context reaches this percentage while idle, the idle condensing timer runs. Must be at or below the Condensing Trigger Threshold.", + "thresholdProfileDescription": "Custom idle threshold for this profile only (overrides global default, capped at the profile's condensing threshold)", + "idleTime": { + "label": "Idle time", + "seconds": "seconds", + "description": "How long the model must be idle before condensing is triggered. Set to 0 to condense as soon as the model goes idle." + }, + "useGlobal": "Use global settings" + }, "includeCurrentTime": { "label": "Include current time in context", "description": "When enabled, the current time and timezone information will be included in the system prompt. Disable this if models are stopping work due to time concerns." diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index a100b767f9..aa59792da0 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -766,6 +766,18 @@ "profileDescription": "Umbral personalizado solo para este perfil (anula el predeterminado global)", "usesGlobal": "(usa global {{threshold}}%)" }, + "idleCondensing": { + "name": "Condensación en reposo", + "description": "Un umbral más bajo para condensar el contexto durante los momentos de reposo, para que siempre tengas un contexto limpio cuando vuelvas al teclado.", + "thresholdDescription": "Cuando el contexto alcanza este porcentaje durante el reposo, se activa el temporizador de condensación en reposo. Debe ser igual o inferior al umbral de condensación de contexto.", + "thresholdProfileDescription": "Umbral de reposo personalizado solo para este perfil (anula el predeterminado global, limitado al umbral de condensación del perfil)", + "idleTime": { + "label": "Tiempo de reposo", + "seconds": "segundos", + "description": "Cuánto tiempo debe estar el modelo inactivo antes de que se active la condensación. Establécelo en 0 para condensar en cuanto el modelo quede inactivo." + }, + "useGlobal": "Usar ajustes globales" + }, "includeCurrentTime": { "label": "Incluir hora actual en el contexto", "description": "Cuando está habilitado, la hora actual y la información de la zona horaria se incluirán en el prompt del sistema. Deshabilítelo si los modelos dejan de funcionar por problemas de tiempo." diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index aba006610b..e0e57cbd95 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -766,6 +766,18 @@ "profileDescription": "Seuil personnalisé pour ce profil uniquement (remplace le défaut global)", "usesGlobal": "(utilise global {{threshold}}%)" }, + "idleCondensing": { + "name": "Condensation en veille", + "description": "Un seuil plus bas pour condenser le contexte pendant les moments de veille, afin d’avoir toujours un contexte propre quand tu reviens au clavier.", + "thresholdDescription": "Lorsque le contexte atteint ce pourcentage en veille, le minuteur de condensation en veille démarre. Doit être inférieur ou égal au seuil de condensation du contexte.", + "thresholdProfileDescription": "Seuil de veille personnalisé pour ce profil uniquement (remplace le défaut global, plafonné au seuil de condensation du profil)", + "idleTime": { + "label": "Temps de veille", + "seconds": "secondes", + "description": "Durée pendant laquelle le modèle doit rester inactif avant que la condensation soit déclenchée. Mettre 0 pour condenser dès que le modèle passe en veille." + }, + "useGlobal": "Utiliser les paramètres globaux" + }, "includeCurrentTime": { "label": "Inclure l'heure actuelle dans le contexte", "description": "Lorsque cette option est activée, l'heure actuelle et les informations de fuseau horaire seront incluses dans le prompt système. Désactivez cette option si les modèles cessent de fonctionner en raison de problèmes liés à l'heure." diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 274d0b4217..e17625c863 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -756,6 +756,18 @@ "profileDescription": "केवल इस प्रोफ़ाइल के लिए कस्टम सीमा (वैश्विक डिफ़ॉल्ट को ओवरराइड करता है)", "usesGlobal": "(वैश्विक {{threshold}}% का उपयोग करता है)" }, + "idleCondensing": { + "name": "निष्क्रिय काल में संघनन", + "description": "निष्क्रिय क्षणों में संदर्भ संघनित करने के लिए एक निचली सीमा, ताकि जब आप कीबोर्ड पर वापस आएँ तो आपके पास हमेशा एक साफ़ संदर्भ हो।", + "thresholdDescription": "जब निष्क्रिय अवस्था में संदर्भ इस प्रतिशत तक पहुँचता है, तो निष्क्रिय संघनन टाइमर चलता है। यह संघनन ट्रिगर सीमा के बराबर या उससे कम होना चाहिए।", + "thresholdProfileDescription": "केवल इस प्रोफ़ाइल के लिए कस्टम निष्क्रिय सीमा (वैश्विक डिफ़ॉल्ट को ओवरराइड करती है, प्रोफ़ाइल की संघनन सीमा तक सीमित)", + "idleTime": { + "label": "निष्क्रिय समय", + "seconds": "सेकंड", + "description": "संघनन ट्रिगर होने से पहले मॉडल को कितनी देर निष्क्रिय रहना चाहिए। जैसे ही मॉडल निष्क्रिय हो संघनन करने के लिए इसे 0 पर सेट करें।" + }, + "useGlobal": "वैश्विक सेटिंग्स उपयोग करें" + }, "maxImageFileSize": { "label": "अधिकतम छवि फ़ाइल आकार", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index f88ddf8500..6ddef69815 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -744,6 +744,18 @@ "profileDescription": "Ambang batas kustom untuk profil ini saja (menimpa default global)", "usesGlobal": "(menggunakan global {{threshold}}%)" }, + "idleCondensing": { + "name": "Kondensasi saat idle", + "description": "Ambang batas lebih rendah untuk mengkondensasi konteks saat idle, sehingga kamu selalu memiliki konteks bersih saat kembali ke keyboard.", + "thresholdDescription": "Saat konteks mencapai persentase ini selama idle, timer kondensasi idle berjalan. Harus sama atau di bawah Ambang Batas Pemicu Kondensasi.", + "thresholdProfileDescription": "Ambang batas idle kustom hanya untuk profil ini (menimpa default global, dibatasi pada ambang kondensasi profil)", + "idleTime": { + "label": "Waktu idle", + "seconds": "detik", + "description": "Berapa lama model harus tidak aktif sebelum kondensasi dipicu. Setel ke 0 untuk mengondensasi segera setelah model menjadi idle." + }, + "useGlobal": "Gunakan pengaturan global" + }, "openTabs": { "label": "Batas konteks tab terbuka", "description": "Jumlah maksimum tab VSCode terbuka yang disertakan dalam konteks. Nilai yang lebih tinggi memberikan lebih banyak konteks tetapi meningkatkan penggunaan token." diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index ee1b282786..1406a741f6 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Soglia personalizzata solo per questo profilo (sovrascrive il predefinito globale)", "usesGlobal": "(usa globale {{threshold}}%)" }, + "idleCondensing": { + "name": "Condensazione inattiva", + "description": "Una soglia più bassa per condensare il contesto durante i momenti di inattività, così avrai sempre un contesto pulito quando torni alla tastiera.", + "thresholdDescription": "Quando il contesto raggiunge questa percentuale durante l'inattività, il timer di condensazione inattiva parte. Deve essere uguale o inferiore alla soglia di attivazione condensazione.", + "thresholdProfileDescription": "Soglia di inattività personalizzata solo per questo profilo (sovrascrive il predefinito globale, limitata alla soglia di condensazione del profilo)", + "idleTime": { + "label": "Tempo di inattività", + "seconds": "secondi", + "description": "Quanto a lungo il modello deve restare inattivo prima che la condensazione venga attivata. Imposta a 0 per condensare non appena il modello diventa inattivo." + }, + "useGlobal": "Usa le impostazioni globali" + }, "maxImageFileSize": { "label": "Dimensione massima file immagine", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index a277b5485c..1b4ee0ccea 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -756,6 +756,18 @@ "profileDescription": "このプロファイルのみのカスタムしきい値(グローバルデフォルトを上書き)", "usesGlobal": "(グローバル {{threshold}}% を使用)" }, + "idleCondensing": { + "name": "アイドル時の圧縮", + "description": "アイドル時にコンテキストを圧縮するためのしきい値を低めに設定。キーボードに戻ったとき、いつでもクリーンなコンテキストが使えるようになります。", + "thresholdDescription": "アイドル中にコンテキストがこのパーセンテージに達すると、アイドル圧縮タイマーが作動します。圧縮トリガーしきい値以下である必要があります。", + "thresholdProfileDescription": "このプロファイルのみのカスタムアイドルしきい値(グローバルデフォルトを上書き。プロファイルの圧縮しきい値を上限とします)", + "idleTime": { + "label": "アイドル時間", + "seconds": "秒", + "description": "圧縮がトリガーされる前にモデルがアイドル状態である必要がある時間。モデルがアイドルになったらすぐ圧縮するには 0 に設定してください。" + }, + "useGlobal": "グローバル設定を使用" + }, "maxImageFileSize": { "label": "最大画像ファイルサイズ", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 87bd718210..58aa3e697d 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -756,6 +756,18 @@ "profileDescription": "이 프로필만을 위한 사용자 정의 임계값 (글로벌 기본값 재정의)", "usesGlobal": "(글로벌 {{threshold}}% 사용)" }, + "idleCondensing": { + "name": "유휴 시 압축", + "description": "유휴 시간에 컨텍스트를 압축하기 위한 더 낮은 임계값으로, 키보드로 돌아올 때마다 항상 깨끗한 컨텍스트를 유지할 수 있습니다.", + "thresholdDescription": "유휴 상태인 동안 컨텍스트가 이 백분율에 도달하면 유휴 압축 타이머가 작동합니다. 압축 트리거 임계값 이하이어야 합니다.", + "thresholdProfileDescription": "이 프로필만의 사용자 정의 유휴 임계값 (글로벌 기본값을 재정의하며, 프로필의 압축 임계값을 상한으로 제한)", + "idleTime": { + "label": "유휴 시간", + "seconds": "초", + "description": "압축이 트리거되기 전에 모델이 유휴 상태여야 하는 시간. 모델이 유휴 상태가 되자마자 압축하려면 0으로 설정하세요." + }, + "useGlobal": "전역 설정 사용" + }, "maxImageFileSize": { "label": "최대 이미지 파일 크기", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 9185cd1ba8..f71c6b9fc2 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -766,6 +766,18 @@ "profileDescription": "Aangepaste drempelwaarde alleen voor dit profiel (overschrijft globale standaard)", "usesGlobal": "(gebruikt globaal {{threshold}}%)" }, + "idleCondensing": { + "name": "Comprimeren bij inactiviteit", + "description": "Een lagere drempel om de context tijdens inactieve momenten te comprimeren, zodat je altijd een schone context hebt als je terugkomt naar het toetsenbord.", + "thresholdDescription": "Wanneer de context tijdens inactiviteit dit percentage bereikt, start de timer voor inactiviteitscompressie. Moet gelijk zijn aan of lager dan de compressie trigger drempelwaarde.", + "thresholdProfileDescription": "Aangepaste inactiviteitsdrempel alleen voor dit profiel (overschrijft globale standaard, beperkt tot de compressiedrempel van het profiel)", + "idleTime": { + "label": "Inactiviteitstijd", + "seconds": "seconden", + "description": "Hoe lang het model inactief moet zijn voordat compressie wordt geactiveerd. Stel in op 0 om te comprimeren zodra het model inactief wordt." + }, + "useGlobal": "Globale instellingen gebruiken" + }, "includeCurrentTime": { "label": "Huidige tijd opnemen in context", "description": "Indien ingeschakeld, worden de huidige tijd en tijdzone-informatie opgenomen in de systeemprompt. Schakel dit uit als modellen stoppen met werken vanwege tijdproblemen." diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index a3dee217d0..8a2c4d0898 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Niestandardowy próg tylko dla tego profilu (zastępuje globalny domyślny)", "usesGlobal": "(używa globalnego {{threshold}}%)" }, + "idleCondensing": { + "name": "Kondensacja podczas bezczynności", + "description": "Niższy próg kondensacji kontekstu w chwilach bezczynności, dzięki czemu zawsze masz czysty kontekst, gdy wracasz do klawiatury.", + "thresholdDescription": "Gdy kontekst osiągnie ten procent podczas bezczynności, uruchamia się timer kondensacji. Musi być równy lub niższy niż próg wyzwalania kondensacji.", + "thresholdProfileDescription": "Niestandardowy próg bezczynności tylko dla tego profilu (zastępuje domyślny globalny, ograniczony do progu kondensacji profilu)", + "idleTime": { + "label": "Czas bezczynności", + "seconds": "sekund", + "description": "Jak długo model musi pozostawać bezczynny, zanim kondensacja zostanie wyzwolona. Ustaw na 0, aby kondensować zaraz po przejściu modelu w stan bezczynności." + }, + "useGlobal": "Użyj ustawień globalnych" + }, "maxImageFileSize": { "label": "Maksymalny rozmiar pliku obrazu", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index c5e103eec0..fc829414fa 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Limite personalizado apenas para este perfil (substitui o padrão global)", "usesGlobal": "(usa global {{threshold}}%)" }, + "idleCondensing": { + "name": "Condensação em ociosidade", + "description": "Um limite mais baixo para condensar o contexto durante momentos de ociosidade, para que você sempre tenha um contexto limpo ao voltar ao teclado.", + "thresholdDescription": "Quando o contexto atinge essa porcentagem durante a ociosidade, o temporizador de condensação em ociosidade é iniciado. Deve ser igual ou menor que o Limite de Ativação de Condensação.", + "thresholdProfileDescription": "Limite de ociosidade personalizado apenas para este perfil (substitui o padrão global, limitado ao limite de condensação do perfil)", + "idleTime": { + "label": "Tempo de ociosidade", + "seconds": "segundos", + "description": "Quanto tempo o modelo deve ficar ocioso antes que a condensação seja acionada. Defina como 0 para condensar assim que o modelo ficar ocioso." + }, + "useGlobal": "Usar configurações globais" + }, "maxImageFileSize": { "label": "Tamanho máximo do arquivo de imagem", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 8c944f3797..bad1a349e8 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Пользовательский порог только для этого профиля (переопределяет глобальный по умолчанию)", "usesGlobal": "(использует глобальный {{threshold}}%)" }, + "idleCondensing": { + "name": "Сжатие в простое", + "description": "Более низкий порог сжатия контекста в моменты простоя — чтобы, возвращаясь к клавиатуре, вы всегда видели чистый контекст.", + "thresholdDescription": "Когда во время простоя контекст достигает этого процента, запускается таймер сжатия в простое. Должен быть не выше порога запуска сжатия.", + "thresholdProfileDescription": "Пользовательский порог простоя только для этого профиля (переопределяет глобальный по умолчанию, ограничен порогом сжатия профиля)", + "idleTime": { + "label": "Время простоя", + "seconds": "секунд", + "description": "Как долго модель должна находиться в простое, прежде чем сжатие будет запущено. Установите 0, чтобы сжимать сразу при переходе модели в простой." + }, + "useGlobal": "Использовать глобальные настройки" + }, "maxImageFileSize": { "label": "Максимальный размер файла изображения", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 2872f7f9f3..d114e89c0a 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Sadece bu profil için özel eşik (küresel varsayılanı geçersiz kılar)", "usesGlobal": "(küresel {{threshold}}% kullanır)" }, + "idleCondensing": { + "name": "Boşta sıkıştırma", + "description": "Boşta anlarda bağlamı sıkıştırmak için daha düşük bir eşik; klavyeye geri döndüğünde her zaman temiz bir bağlamın olur.", + "thresholdDescription": "Boşta durumdayken bağlam bu yüzdelere ulaştığında boşta sıkıştırma zamanlayıcısı çalışır. Sıkıştırma Tetikleme Eşiği değerine eşit veya altında olmalıdır.", + "thresholdProfileDescription": "Yalnızca bu profil için özel boşta eşiği (küresel varsayılanı geçersiz kılar, profilin sıkıştırma eşiğiyle sınırlıdır)", + "idleTime": { + "label": "Boşta süresi", + "seconds": "saniye", + "description": "Sıkıştırmanın tetiklenmeden önce modelin ne kadar boşta kalması gerektiği. Model boşta kaldığı anda sıkıştırma yapmak için 0 olarak ayarlayın." + }, + "useGlobal": "Küresel ayarları kullan" + }, "maxImageFileSize": { "label": "Maksimum görüntü dosyası boyutu", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index b8c769f584..9d8c6a82be 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -756,6 +756,18 @@ "profileDescription": "Ngưỡng tùy chỉnh chỉ cho hồ sơ này (ghi đè mặc định toàn cục)", "usesGlobal": "(sử dụng toàn cục {{threshold}}%)" }, + "idleCondensing": { + "name": "Nén khi nhàn rỗi", + "description": "Ngưỡng thấp hơn để nén ngữ cảnh trong những lúc nhàn rỗi, để bạn luôn có ngữ cảnh sạch khi quay lại bàn phím.", + "thresholdDescription": "Khi ngữ cảnh đạt tỷ lệ phần trăm này trong lúc nhàn rỗi, bộ đếm nén khi nhàn rỗi sẽ chạy. Phải bằng hoặc thấp hơn Ngưỡng kích hoạt nén.", + "thresholdProfileDescription": "Ngưỡng nhàn rỗi tùy chỉnh chỉ cho hồ sơ này (ghi đè mặc định toàn cục, giới hạn ở ngưỡng nén của hồ sơ)", + "idleTime": { + "label": "Thời gian nhàn rỗi", + "seconds": "giây", + "description": "Mô hình phải nhàn rỗi bao lâu trước khi việc nén được kích hoạt. Đặt bằng 0 để nén ngay khi mô hình chuyển sang nhàn rỗi." + }, + "useGlobal": "Sử dụng cài đặt toàn cục" + }, "maxImageFileSize": { "label": "Kích thước tối đa của tệp hình ảnh", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 01c6cf7948..456f5e7d9f 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -756,6 +756,18 @@ "profileDescription": "仅此配置文件的自定义阈值(覆盖全局默认)", "usesGlobal": "(使用全局 {{threshold}}%)" }, + "idleCondensing": { + "name": "空闲时压缩", + "description": "在空闲时段以更低的阈值压缩上下文,让你回到键盘前时始终拥有干净的上下文。", + "thresholdDescription": "当上下文在空闲期间达到此百分比时,启动空闲压缩计时器。必须不高于压缩触发阈值。", + "thresholdProfileDescription": "仅此配置文件的自定义空闲阈值(覆盖全局默认值,上限为该配置文件的压缩阈值)", + "idleTime": { + "label": "空闲时间", + "seconds": "秒", + "description": "模型必须空闲多久才触发压缩。设为 0 可在模型进入空闲后立即压缩。" + }, + "useGlobal": "使用全局设置" + }, "maxImageFileSize": { "label": "最大图像文件大小", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index ceda034bf6..c7639734e0 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -793,6 +793,18 @@ "profileDescription": "僅此設定檔的自訂閾值(覆寫全域預設)", "usesGlobal": "(使用全域 {{threshold}}%)" }, + "idleCondensing": { + "name": "空閒時壓縮", + "description": "在空閒時段以更低的閾值壓縮上下文,讓你回到鍵盤前時始終擁有乾淨的上下文。", + "thresholdDescription": "當上下文在空閒期間達到此百分比時,啟動空閒壓縮計時器。必須不高於壓縮觸發閾值。", + "thresholdProfileDescription": "僅此設定檔的自訂空閒閾值(覆寫全域預設,上限為該設定檔的壓縮閾值)", + "idleTime": { + "label": "空閒時間", + "seconds": "秒", + "description": "模型必須空閒多久才觸發壓縮。設為 0 可在模型進入空閒後立即壓縮。" + }, + "useGlobal": "使用全域設定" + }, "includeCurrentTime": { "label": "在上下文中包含目前時間", "description": "啟用後,目前時間和時區資訊將包含在系統提示中。如果模型因時間問題停止工作,請停用此選項。"