diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 76420bc020..3e709b412f 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -116,6 +116,8 @@ export const globalSettingsSchema = z.object({ openRouterImageApiKey: z.string().optional(), openRouterImageGenerationSelectedModel: z.string().optional(), + condensingApiConfigOverride: z.boolean().optional(), + condensingApiConfigId: z.string().optional(), customCondensingPrompt: z.string().optional(), autoApprovalEnabled: z.boolean().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..c46215b4be 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -303,6 +303,8 @@ export type ExtensionState = Pick< | "customModePrompts" | "customSupportPrompts" | "enhancementApiConfigId" + | "condensingApiConfigOverride" + | "condensingApiConfigId" | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index d11635c6fd..a1c770e31f 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -1250,6 +1250,60 @@ describe("summarizeConversation with custom settings", () => { expect(createMessageCalls[0][0]).toContain("CRITICAL: This is a summarization-only request") }) + it("should use the selected condensing handler while counting the resulting context with the current handler", async () => { + const condensingApiHandler = { + createMessage: vi.fn().mockImplementation(() => { + return (async function* () { + yield { type: "text" as const, text: "Summary from selected profile" } + yield { type: "usage" as const, totalCost: 0.01, outputTokens: 40 } + })() + }), + getModel: vi.fn().mockReturnValue({ + id: "condensing-model", + info: { + contextWindow: 4000, + supportsImages: false, + supportsVision: false, + maxTokens: 2000, + }, + }), + } as unknown as ApiHandler + + const result = await summarizeConversation({ + messages: sampleMessages, + apiHandler: mockMainApiHandler, + condensingApiHandler, + systemPrompt: defaultSystemPrompt, + taskId: localTaskId, + }) + + expect(condensingApiHandler.createMessage).toHaveBeenCalledOnce() + expect(mockMainApiHandler.createMessage).not.toHaveBeenCalled() + expect(mockMainApiHandler.countTokens).toHaveBeenCalled() + expect(result.summary).toBe("Summary from selected profile") + }) + + it("should fall back to the current handler when the selected condensing handler is invalid", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + const invalidHandler = { + getModel: vi.fn(), + } as unknown as ApiHandler + + try { + await summarizeConversation({ + messages: sampleMessages, + apiHandler: mockMainApiHandler, + condensingApiHandler: invalidHandler, + systemPrompt: defaultSystemPrompt, + taskId: localTaskId, + }) + } finally { + warnSpy.mockRestore() + } + + expect(mockMainApiHandler.createMessage).toHaveBeenCalledOnce() + }) + /** * Test that telemetry is called for custom prompt usage */ diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 0438bf6bcb..7d1c9caa86 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -224,6 +224,8 @@ export type SummarizeResponse = { export type SummarizeConversationOptions = { messages: ApiMessage[] apiHandler: ApiHandler + /** Optional API handler used only for the summarization request. Token counting still uses apiHandler. */ + condensingApiHandler?: ApiHandler systemPrompt: string taskId: string isAutomaticTrigger?: boolean @@ -257,6 +259,7 @@ export async function summarizeConversation(options: SummarizeConversationOption const { messages, apiHandler, + condensingApiHandler, systemPrompt, taskId, isAutomaticTrigger, @@ -311,8 +314,17 @@ export async function summarizeConversation(options: SummarizeConversationOption // This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter // when tool blocks are present. By converting them to text, we can send the conversation for // summarization without needing to pass the tools parameter. + const handlerToUse = + condensingApiHandler && typeof condensingApiHandler.createMessage === "function" + ? condensingApiHandler + : apiHandler + + if (condensingApiHandler && handlerToUse !== condensingApiHandler) { + console.warn("Selected API handler for condensing is invalid; using the current mode's API handler.") + } + const messagesWithTextToolBlocks = transformMessagesForCondensing( - maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler), + maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], handlerToUse), ) const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content })) @@ -321,7 +333,7 @@ export async function summarizeConversation(options: SummarizeConversationOption const promptToUse = SUMMARY_PROMPT // Validate that the API handler supports message creation - if (!apiHandler || typeof apiHandler.createMessage !== "function") { + if (!handlerToUse || typeof handlerToUse.createMessage !== "function") { console.error("API handler is invalid for condensing. Cannot proceed.") const error = t("common:errors.condense_handler_invalid") return { ...response, error } @@ -332,7 +344,7 @@ export async function summarizeConversation(options: SummarizeConversationOption let outputTokens = 0 try { - const stream = apiHandler.createMessage(promptToUse, requestMessages, metadata) + const stream = handlerToUse.createMessage(promptToUse, requestMessages, metadata) for await (const chunk of stream) { if (chunk.type === "text") { diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 82cb4fdf2e..ab3ba473b3 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -251,6 +251,7 @@ export type ContextManagementOptions = { contextWindow: number maxTokens?: number | null apiHandler: ApiHandler + condensingApiHandler?: ApiHandler autoCondenseContext: boolean autoCondenseContextPercent: number systemPrompt: string @@ -294,6 +295,7 @@ export async function manageContext({ contextWindow, maxTokens, apiHandler, + condensingApiHandler, autoCondenseContext, autoCondenseContextPercent, systemPrompt, @@ -361,6 +363,7 @@ export async function manageContext({ const result = await summarizeConversation({ messages, apiHandler, + condensingApiHandler, systemPrompt, taskId, isAutomaticTrigger: true, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7bee3c5e14..85e71e42cb 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1572,6 +1572,37 @@ export class Task extends EventEmitter implements TaskLike { } } + private async getCondensingApiHandler( + state: + | { + condensingApiConfigOverride?: boolean + condensingApiConfigId?: string + listApiConfigMeta?: Array<{ id: string }> + } + | undefined, + ): Promise { + const configId = state?.condensingApiConfigId + if (!state?.condensingApiConfigOverride || !configId) { + return undefined + } + + if (!state.listApiConfigMeta?.some((config) => config.id === configId)) { + console.warn(`[Task] Context condensing profile "${configId}" no longer exists; using the current mode.`) + return undefined + } + + try { + const profile = await this.providerRef.deref()?.providerSettingsManager.getProfile({ id: configId }) + return profile?.apiProvider ? buildApiHandler(profile) : undefined + } catch (error) { + console.warn( + `[Task] Failed to load context condensing profile "${configId}"; using the current mode.`, + error, + ) + return undefined + } + } + public async condenseContext(): Promise { // CRITICAL: Flush any pending tool results before condensing // to ensure tool_use/tool_result pairs are complete in history @@ -1583,6 +1614,7 @@ export class Task extends EventEmitter implements TaskLike { const state = await this.providerRef.deref()?.getState() const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE const { mode, apiConfiguration } = state ?? {} + const condensingApiHandler = await this.getCondensingApiHandler(state) const { contextTokens: prevContextTokens } = this.getTokenUsage() @@ -1638,6 +1670,7 @@ export class Task extends EventEmitter implements TaskLike { } = await summarizeConversation({ messages: this.apiConversationHistory, apiHandler: this.api, + condensingApiHandler, systemPrompt, taskId: this.taskId, isAutomaticTrigger: false, @@ -3802,6 +3835,8 @@ export class Task extends EventEmitter implements TaskLike { private async handleContextWindowExceededError(): Promise { const state = await this.providerRef.deref()?.getState() const { profileThresholds = {}, mode, apiConfiguration } = state ?? {} + const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE + const condensingApiHandler = await this.getCondensingApiHandler(state) const { contextTokens } = this.getTokenUsage() const modelInfo = this.api.getModel().info @@ -3876,10 +3911,12 @@ export class Task extends EventEmitter implements TaskLike { maxTokens, contextWindow, apiHandler: this.api, + condensingApiHandler, autoCondenseContext: true, autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, systemPrompt: await this.getSystemPrompt(), taskId: this.taskId, + customCondensingPrompt, profileThresholds, currentProfileId, metadata, @@ -4042,11 +4079,13 @@ export class Task extends EventEmitter implements TaskLike { lastMessageTokens, useAvailableInputForContextPercent, }) + let condensingApiHandler: ApiHandler | undefined // Send condenseTaskContextStarted BEFORE manageContext to show in-progress indicator // This notification must be sent here (not earlier) because the early check uses stale token count // (before user message is added to history), which could incorrectly skip showing the indicator if (contextManagementWillRun && autoCondenseContext) { + condensingApiHandler = await this.getCondensingApiHandler(state) await this.providerRef .deref() ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) @@ -4111,6 +4150,7 @@ export class Task extends EventEmitter implements TaskLike { maxTokens, contextWindow, apiHandler: this.api, + condensingApiHandler, autoCondenseContext, autoCondenseContextPercent, systemPrompt, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 27ba5ce8ff..dceb0000e2 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1833,6 +1833,102 @@ describe("Cline", () => { expect("abortSignal" in (options.metadata ?? {})).toBe(false) }) + it("should resolve and pass the selected provider profile for manual condensing", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const condensingProfile = { + apiProvider: "anthropic" as const, + apiModelId: "claude-3-5-haiku-20241022", + apiKey: "condensing-api-key", + } + const providerState = await mockProvider.getState() + + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + condensingApiConfigOverride: true, + condensingApiConfigId: "condensing-profile", + listApiConfigMeta: [{ id: "condensing-profile", name: "Condensing Profile" }], + }) + vi.spyOn(mockProvider.providerSettingsManager, "getProfile").mockResolvedValue( + condensingProfile as any, + ) + vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + + await task.condenseContext() + + const [options] = vi.mocked(summarizeConversation).mock.calls.at(-1)! + expect(mockProvider.providerSettingsManager.getProfile).toHaveBeenCalledWith({ + id: "condensing-profile", + }) + expect(options.condensingApiHandler).toBeDefined() + expect(options.condensingApiHandler).not.toBe(task.api) + expect(options.condensingApiHandler?.getModel().id).toBe("claude-3-5-haiku-20241022") + }) + + it.each([ + [ + "the override is disabled", + { + condensingApiConfigOverride: false, + condensingApiConfigId: "condensing-profile", + listApiConfigMeta: [{ id: "condensing-profile" }], + }, + "skip", + ], + [ + "the selected profile no longer exists", + { + condensingApiConfigOverride: true, + condensingApiConfigId: "missing-profile", + listApiConfigMeta: [], + }, + "skip", + ], + [ + "the selected profile has no provider", + { + condensingApiConfigOverride: true, + condensingApiConfigId: "condensing-profile", + listApiConfigMeta: [{ id: "condensing-profile" }], + }, + {}, + ], + [ + "the selected profile cannot be loaded", + { + condensingApiConfigOverride: true, + condensingApiConfigId: "condensing-profile", + listApiConfigMeta: [{ id: "condensing-profile" }], + }, + new Error("profile load failed"), + ], + ])("should use the current mode when %s", async (_name, state, profileResult) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const getProfileSpy = vi.spyOn(mockProvider.providerSettingsManager, "getProfile") + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + if (profileResult instanceof Error) { + getProfileSpy.mockRejectedValue(profileResult) + } else if (profileResult !== "skip") { + getProfileSpy.mockResolvedValue(profileResult as any) + } + + try { + await expect((task as any).getCondensingApiHandler(state)).resolves.toBeUndefined() + } finally { + warnSpy.mockRestore() + } + }) + it("should pass AbortController signal to createMessage metadata", async () => { const task = new Task({ provider: mockProvider, @@ -2229,6 +2325,11 @@ describe("Cline", () => { } as ModelInfo, }) const providerState = await mockProvider.getState() + const condensingProfile = { + apiProvider: "anthropic" as const, + apiModelId: "claude-3-5-haiku-20241022", + apiKey: "condensing-api-key", + } vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, @@ -2243,7 +2344,11 @@ describe("Cline", () => { autoApprovalEnabled: true, profileThresholds: {}, currentApiConfigName: "default", + condensingApiConfigOverride: true, + condensingApiConfigId: "condensing-profile", + listApiConfigMeta: [{ id: "condensing-profile", name: "Condensing Profile" }], }) + vi.spyOn(mockProvider.providerSettingsManager, "getProfile").mockResolvedValue(condensingProfile as any) task.apiConversationHistory = [ { @@ -2305,6 +2410,57 @@ describe("Cline", () => { expect(options.metadata?.taskId).toBe(task.taskId) expect(options.metadata?.abortSignal).toBeInstanceOf(AbortSignal) expect(options.metadata?.abortSignal?.aborted).toBe(false) + expect(options.condensingApiHandler).toBeDefined() + expect(options.condensingApiHandler).not.toBe(task.api) + expect(options.condensingApiHandler?.getModel().id).toBe("claude-3-5-haiku-20241022") + }) + + it("should use the selected provider profile during forced context-window recovery", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const condensingProfile = { + apiProvider: "anthropic" as const, + apiModelId: "claude-3-5-haiku-20241022", + apiKey: "condensing-api-key", + } + const providerState = await mockProvider.getState() + + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration: mockApiConfig, + mode: "code", + profileThresholds: {}, + customSupportPrompts: {}, + condensingApiConfigOverride: true, + condensingApiConfigId: "condensing-profile", + listApiConfigMeta: [{ id: "condensing-profile", name: "Condensing Profile" }], + }) + vi.spyOn(mockProvider.providerSettingsManager, "getProfile").mockResolvedValue(condensingProfile as any) + vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 120000, + }) + task.apiConversationHistory = [ + { role: "user", content: "test message", ts: Date.now() }, + { role: "assistant", content: "test response", ts: Date.now() + 1 }, + ] + + await (task as any).handleContextWindowExceededError() + + const [options] = vi.mocked(summarizeConversation).mock.calls.at(-1)! + expect(mockProvider.providerSettingsManager.getProfile).toHaveBeenCalledWith({ + id: "condensing-profile", + }) + expect(options.condensingApiHandler).toBeDefined() + expect(options.condensingApiHandler).not.toBe(task.api) + expect(options.condensingApiHandler?.getModel().id).toBe("claude-3-5-haiku-20241022") }) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6e5545a1f2..8f924988e8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2293,6 +2293,8 @@ export class ClineProvider customModePrompts, customSupportPrompts, enhancementApiConfigId, + condensingApiConfigOverride, + condensingApiConfigId, autoApprovalEnabled, customModes, experiments, @@ -2450,6 +2452,8 @@ export class ClineProvider customModePrompts: customModePrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {}, enhancementApiConfigId, + condensingApiConfigOverride, + condensingApiConfigId, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, experiments: experiments ?? experimentDefault, @@ -2665,6 +2669,8 @@ export class ClineProvider customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, enhancementApiConfigId: stateValues.enhancementApiConfigId, + condensingApiConfigOverride: stateValues.condensingApiConfigOverride, + condensingApiConfigId: stateValues.condensingApiConfigId, experiments: stateValues.experiments ?? experimentDefault, autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index eaa566f36a..719deba710 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -29,6 +29,8 @@ import { vscode } from "@/utils/vscode" type ContextManagementSettingsProps = HTMLAttributes & { autoCondenseContext: boolean autoCondenseContextPercent: number + condensingApiConfigOverride: boolean + condensingApiConfigId?: string listApiConfigMeta: any[] maxOpenTabsContext: number maxWorkspaceFiles: number @@ -49,6 +51,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { setCachedStateField: SetCachedStateField< | "autoCondenseContext" | "autoCondenseContextPercent" + | "condensingApiConfigOverride" + | "condensingApiConfigId" | "maxOpenTabsContext" | "maxWorkspaceFiles" | "showRooIgnoredFiles" @@ -69,6 +73,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { export const ContextManagementSettings = ({ autoCondenseContext, autoCondenseContextPercent, + condensingApiConfigOverride, + condensingApiConfigId, listApiConfigMeta, maxOpenTabsContext, maxWorkspaceFiles, @@ -501,6 +507,50 @@ export const ContextManagementSettings = ({ /> + + setCachedStateField("condensingApiConfigOverride", e.target.checked)} + data-testid="condensing-model-override-checkbox"> + + {t("settings:contextManagement.condensingApiConfiguration.label")} + + +
+ {t("settings:contextManagement.condensingApiConfiguration.description")} +
+ {condensingApiConfigOverride && ( +
+ +
+ {t("settings:contextManagement.condensingApiConfiguration.description")} +
+
+ )} +
+ {/* Auto Condense Context */} (({ onDone, t enableSubfolderRules, maxImageFileSize, maxTotalImageSize, + condensingApiConfigOverride, + condensingApiConfigId, customSupportPrompts, profileThresholds, alwaysAllowFollowupQuestions, @@ -396,6 +398,8 @@ const SettingsView = forwardRef(({ onDone, t allowedMaxCost: allowedMaxCost ?? null, autoCondenseContext, autoCondenseContextPercent, + condensingApiConfigOverride: condensingApiConfigOverride ?? false, + condensingApiConfigId: condensingApiConfigId ?? "", soundEnabled: soundEnabled ?? true, soundVolume: soundVolume ?? 0.5, ttsEnabled, @@ -856,6 +860,8 @@ const SettingsView = forwardRef(({ onDone, t ({ {children} ), - Select: ({ children, ...props }: any) => ( -
+ Select: ({ children, onValueChange, "data-testid": dataTestId, ...props }: any) => ( +
+ {dataTestId && ( + <> +
), @@ -88,6 +97,8 @@ describe("ContextManagementSettings", () => { const defaultProps = { autoCondenseContext: false, autoCondenseContextPercent: 80, + condensingApiConfigOverride: false, + condensingApiConfigId: "", listApiConfigMeta: [], maxOpenTabsContext: 20, maxWorkspaceFiles: 200, @@ -338,6 +349,71 @@ describe("ContextManagementSettings", () => { expect(selects).toHaveLength(1) }) + describe("Context condensing model override", () => { + const profiles = [ + { id: "config-1", name: "Fast Condenser" }, + { id: "config-2", name: "Accurate Condenser" }, + ] + + it("hides the profile dropdown when the override is disabled", () => { + render( + , + ) + + expect(screen.queryByTestId("condensing-profile-select")).not.toBeInTheDocument() + }) + + it("shows the current-mode option and provider profiles when enabled", () => { + render( + , + ) + + expect(screen.getByTestId("condensing-profile-select")).toBeInTheDocument() + expect( + screen.getByText("settings:contextManagement.condensingApiConfiguration.useCurrentConfig"), + ).toBeInTheDocument() + expect(screen.getByText("Fast Condenser")).toBeInTheDocument() + expect(screen.getByText("Accurate Condenser")).toBeInTheDocument() + }) + + it("updates the cached override setting", () => { + const setCachedStateField = vi.fn() + render() + + const checkbox = screen.getByTestId("condensing-model-override-checkbox").querySelector("input") + fireEvent.click(checkbox!) + + expect(setCachedStateField).toHaveBeenCalledWith("condensingApiConfigOverride", true) + }) + + it("updates the cached provider profile selection", () => { + const setCachedStateField = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId("condensing-profile-select-profile-option")) + expect(setCachedStateField).toHaveBeenCalledWith("condensingApiConfigId", "config-1") + + fireEvent.click(screen.getByTestId("condensing-profile-select-current-option")) + expect(setCachedStateField).toHaveBeenCalledWith("condensingApiConfigId", "") + }) + }) + describe("Auto Condense Context functionality", () => { const autoCondenseProps = { ...defaultProps, @@ -368,7 +444,7 @@ describe("ContextManagementSettings", () => { // Threshold settings should be visible expect(screen.getByTestId("condense-threshold-slider")).toBeInTheDocument() - // One combobox for profile selection + // One combobox for threshold profile selection; model override is disabled. expect(screen.getAllByRole("combobox")).toHaveLength(1) }) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 9ef634f866..7911cb56ff 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -751,9 +751,9 @@ "description": "When the context window reaches this threshold, Zoo will automatically condense it." }, "condensingApiConfiguration": { - "label": "API Configuration for Context Condensing", - "description": "Select which API configuration to use for context condensing operations. Leave unselected to use the current active configuration.", - "useCurrentConfig": "Default" + "label": "Override API Configuration for Context Condensing", + "description": "Select which provider configuration profile to use for context condensing.", + "useCurrentConfig": "Use the model running in the current mode" }, "customCondensingPrompt": { "label": "Custom Context Condensing Prompt",