From feb8254b9517ae6dc9d32ac61cfd5226295cce06 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 17 Sep 2026 01:55:19 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat(site/src/pages/AgentsPa?= =?UTF-8?q?ge/components):=20show=20model=20context=20window=20in=20compac?= =?UTF-8?q?tion=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...serCompactionThresholdSettings.stories.tsx | 26 +++++++++++ .../UserCompactionThresholdSettings.tsx | 44 ++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx index 8a3d57eb56771..24dcf5dfb124f 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx @@ -94,6 +94,32 @@ 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, + }); + const gpt4oRow = gpt4oInput.closest("tr"); + expect(gpt4oRow).not.toBeNull(); + const row = within(gpt4oRow as HTMLElement); + + // 128K window at the 80% default. + expect(row.getByText("128K tokens")).toBeInTheDocument(); + expect(row.getByText(/Compacts at ~102K/)).toBeInTheDocument(); + + // Trigger point follows the typed draft before saving. + await userEvent.type(gpt4oInput, "50"); + expect(row.getByText(/Compacts at ~64K/)).toBeInTheDocument(); + + // 100% never auto-compacts, so no trigger point is shown. + await userEvent.clear(gpt4oInput); + await userEvent.type(gpt4oInput, "100"); + expect(row.queryByText(/Compacts at/)).not.toBeInTheDocument(); + }, +}; + 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 5c0c0a8e232fc..9d3fc0be1dc5e 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx @@ -29,6 +29,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { formatContextLimit } from "#/modules/aiModels/ModelSelector"; import { ProviderIcon } from "#/modules/aiModels/ProviderIcon"; import { formatProviderLabel } from "#/utils/aiProviders"; @@ -62,6 +63,18 @@ const parseThresholdDraft = (value: string): number | null => { return parsedValue; }; +/** + * Number of tokens at which compaction triggers for the given context + * window and threshold, or undefined when the window is unknown. + */ +const compactionTriggerTokens = ( + contextLimit: number, + thresholdPercent: number, +): number | undefined => + contextLimit > 0 + ? Math.round((contextLimit * thresholdPercent) / 100) + : undefined; + const ContextCompactionHeader: FC = () => (

@@ -330,6 +343,7 @@ export const UserCompactionThresholdSettings: FC< Model + Context Default Threshold @@ -361,6 +375,16 @@ 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 triggerTokens = compactionTriggerTokens( + modelConfig.context_limit, + effectiveThreshold, + ); return ( @@ -388,6 +412,24 @@ export const UserCompactionThresholdSettings: FC<

)} + + {modelConfig.context_limit > 0 ? ( +
+ + {formatContextLimit(modelConfig.context_limit)}{" "} + tokens + + {triggerTokens !== undefined && + effectiveThreshold < 100 && ( + + Compacts at ~{formatContextLimit(triggerTokens)} + + )} +
+ ) : ( + Unknown + )} +
{modelConfig.compression_threshold}% @@ -484,7 +526,7 @@ export const UserCompactionThresholdSettings: FC< - +
{isSavedVisible ? ( From b3137459b5cdb5a326f9797ae934a9095a4e139c Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 17 Sep 2026 02:08:28 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site/src/pages/AgentsPag?= =?UTF-8?q?e):=20use=20effective=20compaction=20window=20and=20move=20asse?= =?UTF-8?q?rtions=20to=20Vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentSettingsCompactionPage.tsx | 23 ++++- .../AgentSettingsCompactionPageView.tsx | 3 + ...serCompactionThresholdSettings.stories.tsx | 30 ++++--- .../UserCompactionThresholdSettings.tsx | 47 +++++----- .../AgentsPage/utils/modelOptions.test.ts | 88 +++++++++++++++++++ .../pages/AgentsPage/utils/modelOptions.ts | 36 ++++++++ 6 files changed, 189 insertions(+), 38 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx index 16a338e6b1e68..f7e9cbbf92b86 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 61136d266f2d3..1fa6aa89c9928 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 24dcf5dfb124f..e9b9ccf369f74 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx @@ -101,22 +101,26 @@ export const ContextWindowTracksDraft: Story = { const gpt4oInput = await canvas.findByRole("textbox", { name: /GPT-4o compaction threshold/i, }); - const gpt4oRow = gpt4oInput.closest("tr"); - expect(gpt4oRow).not.toBeNull(); - const row = within(gpt4oRow as HTMLElement); - // 128K window at the 80% default. - expect(row.getByText("128K tokens")).toBeInTheDocument(); - expect(row.getByText(/Compacts at ~102K/)).toBeInTheDocument(); - - // Trigger point follows the typed draft before saving. + // 128K window: default 80% compacts at ~102K, the draft moves it to ~64K. await userEvent.type(gpt4oInput, "50"); - expect(row.getByText(/Compacts at ~64K/)).toBeInTheDocument(); + }, +}; - // 100% never auto-compacts, so no trigger point is shown. - await userEvent.clear(gpt4oInput); - await userEvent.type(gpt4oInput, "100"); - expect(row.queryByText(/Compacts at/)).not.toBeInTheDocument(); +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 }], }, }; diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx index 9d3fc0be1dc5e..0f72699c4ddee 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx @@ -32,11 +32,20 @@ import { 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; @@ -63,18 +72,6 @@ const parseThresholdDraft = (value: string): number | null => { return parsedValue; }; -/** - * Number of tokens at which compaction triggers for the given context - * window and threshold, or undefined when the window is unknown. - */ -const compactionTriggerTokens = ( - contextLimit: number, - thresholdPercent: number, -): number | undefined => - contextLimit > 0 - ? Math.round((contextLimit * thresholdPercent) / 100) - : undefined; - const ContextCompactionHeader: FC = () => (

@@ -93,6 +90,7 @@ export const UserCompactionThresholdSettings: FC< models, providerTypeByID, organizations, + compactionModelIDByOrganization = new Map(), modelsError, isLoadingModels, thresholds, @@ -381,8 +379,13 @@ export const UserCompactionThresholdSettings: FC< parsedDraftValue ?? existingOverride ?? modelConfig.compression_threshold; + const contextLimit = resolveCompactionContextLimit( + modelConfig, + models, + compactionModelIDByOrganization, + ); const triggerTokens = compactionTriggerTokens( - modelConfig.context_limit, + contextLimit, effectiveThreshold, ); @@ -413,18 +416,14 @@ export const UserCompactionThresholdSettings: FC< )} - {modelConfig.context_limit > 0 ? ( + {contextLimit > 0 ? (
- - {formatContextLimit(modelConfig.context_limit)}{" "} - tokens - - {triggerTokens !== undefined && - effectiveThreshold < 100 && ( - - Compacts at ~{formatContextLimit(triggerTokens)} - - )} + {formatContextLimit(contextLimit)} tokens + {triggerTokens !== undefined && ( + + Compacts at ~{formatContextLimit(triggerTokens)} + + )}
) : ( Unknown diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index 188590d5527fb..6cc6e46b6e6be 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 b58f446842c80..5f03bd1c88667 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, From 85b09cd8696ea5311b8f082fba22b8919643451b Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 17 Sep 2026 02:17:06 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site/src/pages/AgentsPag?= =?UTF-8?q?e/components):=20hoist=20default=20override=20map=20for=20React?= =?UTF-8?q?=20Compiler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentsPage/components/UserCompactionThresholdSettings.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx index 0f72699c4ddee..b7e7049de3294 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx @@ -58,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)) { @@ -90,7 +92,7 @@ export const UserCompactionThresholdSettings: FC< models, providerTypeByID, organizations, - compactionModelIDByOrganization = new Map(), + compactionModelIDByOrganization = noCompactionOverrides, modelsError, isLoadingModels, thresholds,