diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx
index 16a338e6b1e..f7e9cbbf92b 100644
--- a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx
+++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx
@@ -1,7 +1,8 @@
import type { FC } from "react";
-import { useMutation, useQuery, useQueryClient } from "react-query";
+import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import {
deleteUserCompactionThreshold,
+ organizationChatModelOverrides,
updateUserCompactionThreshold,
userChatProviderConfigs,
userCompactionThresholds,
@@ -19,6 +20,25 @@ const AgentSettingsCompactionPage: FC = () => {
);
const providerConfigsQuery = useQuery(userChatProviderConfigs());
const thresholdsQuery = useQuery(userCompactionThresholds());
+ // Only refines the displayed trigger point; a failed request falls back
+ // to the chat model's own window.
+ const modelOverrideQueries = useQueries({
+ queries: organizations.map((organization) =>
+ organizationChatModelOverrides(organization.id),
+ ),
+ });
+ const compactionModelIDByOrganization = new Map();
+ for (const [index, query] of modelOverrideQueries.entries()) {
+ const compactionOverride = query.data?.overrides.find(
+ (override) => override.context === "compaction",
+ );
+ if (compactionOverride) {
+ compactionModelIDByOrganization.set(
+ organizations[index].id,
+ compactionOverride.model_config_id,
+ );
+ }
+ }
const saveThresholdMutation = useMutation(
updateUserCompactionThreshold(queryClient),
);
@@ -44,6 +64,7 @@ const AgentSettingsCompactionPage: FC = () => {
models={organizationModels.models}
providerTypeByID={providerTypeByID}
organizations={organizations}
+ compactionModelIDByOrganization={compactionModelIDByOrganization}
modelsError={organizationModels.error ?? organizationModels.partialError}
isLoadingModels={organizationModels.isLoading}
thresholds={thresholdsQuery.data?.thresholds}
diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx
index 61136d266f2..1fa6aa89c99 100644
--- a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx
+++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx
@@ -7,6 +7,7 @@ export interface AgentSettingsCompactionPageViewProps {
models: readonly TypesGen.ChatModel[] | undefined;
providerTypeByID: ReadonlyMap;
organizations: readonly TypesGen.Organization[];
+ compactionModelIDByOrganization?: ReadonlyMap;
modelsError: unknown;
isLoadingModels: boolean;
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
@@ -25,6 +26,7 @@ export const AgentSettingsCompactionPageView: FC<
models,
providerTypeByID,
organizations,
+ compactionModelIDByOrganization,
modelsError,
isLoadingModels,
thresholds,
@@ -43,6 +45,7 @@ export const AgentSettingsCompactionPageView: FC<
models={models ?? []}
providerTypeByID={providerTypeByID}
organizations={organizations}
+ compactionModelIDByOrganization={compactionModelIDByOrganization}
modelsError={modelsError}
isLoadingModels={isLoadingModels}
thresholds={thresholds}
diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx
index 8a3d57eb567..e9b9ccf369f 100644
--- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx
+++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx
@@ -94,6 +94,36 @@ export const Default: Story = {
},
};
+export const ContextWindowTracksDraft: Story = {
+ name: "Context Window Tracks Draft",
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const gpt4oInput = await canvas.findByRole("textbox", {
+ name: /GPT-4o compaction threshold/i,
+ });
+
+ // 128K window: default 80% compacts at ~102K, the draft moves it to ~64K.
+ await userEvent.type(gpt4oInput, "50");
+ },
+};
+
+export const CompactionOverrideShrinksWindow: Story = {
+ name: "Compaction Override Shrinks Window",
+ args: {
+ // The organization summarizes with the 16K model, so both enabled
+ // models show a 16K compaction window instead of their own.
+ compactionModelIDByOrganization: new Map([
+ [MockChatModel.organization_id, "model-3"],
+ ]),
+ },
+};
+
+export const UnknownContextWindow: Story = {
+ args: {
+ models: [{ ...mockModels[0], context_limit: 0 }],
+ },
+};
+
export const EmptyOrganizationDisplayNameFallsBackToName: Story = {
args: {
organizations: [organizationWithEmptyDisplayName],
diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx
index 5c0c0a8e232..b7e7049de32 100644
--- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx
+++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx
@@ -29,13 +29,23 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
+import { formatContextLimit } from "#/modules/aiModels/ModelSelector";
import { ProviderIcon } from "#/modules/aiModels/ProviderIcon";
import { formatProviderLabel } from "#/utils/aiProviders";
+import {
+ compactionTriggerTokens,
+ resolveCompactionContextLimit,
+} from "../utils/modelOptions";
interface UserCompactionThresholdSettingsProps {
models: readonly TypesGen.ChatModel[];
providerTypeByID: ReadonlyMap;
organizations: readonly TypesGen.Organization[];
+ /**
+ * Organization ID to the model config the organization routes compaction
+ * through. Missing entries mean the chat model summarizes itself.
+ */
+ compactionModelIDByOrganization?: ReadonlyMap;
modelsError?: unknown;
isLoadingModels?: boolean;
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
@@ -48,6 +58,8 @@ interface UserCompactionThresholdSettingsProps {
onResetThreshold: (modelId: string) => Promise;
}
+const noCompactionOverrides: ReadonlyMap = new Map();
+
const parseThresholdDraft = (value: string): number | null => {
const trimmedValue = value.trim();
if (!/^\d+$/.test(trimmedValue)) {
@@ -80,6 +92,7 @@ export const UserCompactionThresholdSettings: FC<
models,
providerTypeByID,
organizations,
+ compactionModelIDByOrganization = noCompactionOverrides,
modelsError,
isLoadingModels,
thresholds,
@@ -330,6 +343,7 @@ export const UserCompactionThresholdSettings: FC<
Model
+ Context
Default
Threshold
@@ -361,6 +375,21 @@ export const UserCompactionThresholdSettings: FC<
const organizationName =
organizationNameByID.get(modelConfig.organization_id) ??
modelConfig.organization_id;
+ // Prefer the typed draft so the trigger point tracks
+ // what the user is about to save.
+ const effectiveThreshold =
+ parsedDraftValue ??
+ existingOverride ??
+ modelConfig.compression_threshold;
+ const contextLimit = resolveCompactionContextLimit(
+ modelConfig,
+ models,
+ compactionModelIDByOrganization,
+ );
+ const triggerTokens = compactionTriggerTokens(
+ contextLimit,
+ effectiveThreshold,
+ );
return (
@@ -388,6 +417,20 @@ export const UserCompactionThresholdSettings: FC<
)}
+
+ {contextLimit > 0 ? (
+
+ {formatContextLimit(contextLimit)} tokens
+ {triggerTokens !== undefined && (
+
+ Compacts at ~{formatContextLimit(triggerTokens)}
+
+ )}
+
+ ) : (
+ Unknown
+ )}
+
{modelConfig.compression_threshold}%
@@ -484,7 +527,7 @@ export const UserCompactionThresholdSettings: FC<
-
+
{isSavedVisible ? (
diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts
index 188590d5527..6cc6e46b6e6 100644
--- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts
+++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts
@@ -11,6 +11,7 @@ import {
MockChatProviderConfig,
} from "#/testHelpers/chatModels";
import {
+ compactionTriggerTokens,
countConfiguredProviderConfigs,
filterModelsWithEnabledProvider,
formatProviderLabel,
@@ -25,6 +26,7 @@ import {
NIL_UUID,
providerInfoByIDFromUserConfigs,
providerTypeByIDFromUserConfigs,
+ resolveCompactionContextLimit,
resolveCompactionThreshold,
resolveModelOptionId,
resolveModelSelector,
@@ -1210,3 +1212,89 @@ describe("resolveCompactionThreshold", () => {
expect(resolveCompactionThreshold("missing", [], models)).toBe(undefined);
});
});
+
+describe("resolveCompactionContextLimit", () => {
+ const chatModel = createConfig({
+ id: "chat",
+ ai_provider_id: "prov-openai",
+ model: "gpt-4o",
+ context_limit: 1_000,
+ });
+ const smallSummarizer = createConfig({
+ id: "small",
+ ai_provider_id: "prov-openai",
+ model: "gpt-4o-mini",
+ context_limit: 100,
+ });
+ const largeSummarizer = createConfig({
+ id: "large",
+ ai_provider_id: "prov-anthropic",
+ model: "claude",
+ context_limit: 5_000,
+ });
+ const models = [chatModel, smallSummarizer, largeSummarizer];
+
+ it("uses the chat model window without an override", () => {
+ expect(resolveCompactionContextLimit(chatModel, models, new Map())).toBe(
+ 1_000,
+ );
+ });
+
+ it("uses the override window when it is smaller", () => {
+ expect(
+ resolveCompactionContextLimit(
+ chatModel,
+ models,
+ new Map([[testOrganizationID, "small"]]),
+ ),
+ ).toBe(100);
+ });
+
+ it("keeps the chat model window when the override is larger", () => {
+ expect(
+ resolveCompactionContextLimit(
+ chatModel,
+ models,
+ new Map([[testOrganizationID, "large"]]),
+ ),
+ ).toBe(1_000);
+ });
+
+ it("falls back to the override window when the chat window is unknown", () => {
+ expect(
+ resolveCompactionContextLimit(
+ { ...chatModel, context_limit: 0 },
+ models,
+ new Map([[testOrganizationID, "small"]]),
+ ),
+ ).toBe(100);
+ });
+
+ it("ignores overrides for other organizations and unknown models", () => {
+ expect(
+ resolveCompactionContextLimit(
+ chatModel,
+ models,
+ new Map([
+ ["other-org", "small"],
+ [testOrganizationID, "missing"],
+ ]),
+ ),
+ ).toBe(1_000);
+ });
+});
+
+describe("compactionTriggerTokens", () => {
+ it("scales the window by the threshold", () => {
+ expect(compactionTriggerTokens(128_000, 80)).toBe(102_400);
+ expect(compactionTriggerTokens(1_000, 70)).toBe(700);
+ });
+
+ it("returns undefined when the window is unknown", () => {
+ expect(compactionTriggerTokens(0, 80)).toBe(undefined);
+ });
+
+ it("returns undefined at 100% because compaction never triggers", () => {
+ expect(compactionTriggerTokens(128_000, 100)).toBe(undefined);
+ });
+});
diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts
index b58f446842c..5f03bd1c886 100644
--- a/site/src/pages/AgentsPage/utils/modelOptions.ts
+++ b/site/src/pages/AgentsPage/utils/modelOptions.ts
@@ -342,6 +342,42 @@ export function resolveCompactionThreshold(
return model.compression_threshold;
}
+/**
+ * Context window the compaction trigger is measured against. Mirrors the
+ * backend: when the organization routes compaction to an override model,
+ * the history must also fit that model's window, so the smaller of the
+ * two limits wins. Returns 0 when neither limit is known.
+ */
+export function resolveCompactionContextLimit(
+ model: TypesGen.ChatModel,
+ models: readonly TypesGen.ChatModel[],
+ compactionModelIDByOrganization: ReadonlyMap,
+): number {
+ const chatLimit = model.context_limit > 0 ? model.context_limit : 0;
+ const overrideID = compactionModelIDByOrganization.get(model.organization_id);
+ const overrideLimit =
+ models.find((candidate) => candidate.id === overrideID)?.context_limit ?? 0;
+ if (overrideLimit > 0 && (chatLimit <= 0 || overrideLimit < chatLimit)) {
+ return overrideLimit;
+ }
+ return chatLimit;
+}
+
+/**
+ * Token count at which compaction triggers for the given context window and
+ * threshold, or undefined when the window is unknown or compaction is
+ * disabled (100%).
+ */
+export function compactionTriggerTokens(
+ contextLimit: number,
+ thresholdPercent: number,
+): number | undefined {
+ if (contextLimit <= 0 || thresholdPercent >= 100) {
+ return undefined;
+ }
+ return Math.round((contextLimit * thresholdPercent) / 100);
+}
+
export const getModelSelectorPlaceholder = (
modelOptions: readonly ModelSelectorOption[],
isModelCatalogLoading: boolean,