feat: add general subagent model override (#24610)

Adds a deployment-wide admin override for general delegated subagents.

## What changed
- store the general override in `site_configs` and expose it through the
shared `agent-model-override/{context}` API
- apply the general override when spawning delegated general subagents,
while preserving the existing Explore override behavior
- reuse a shared Agents settings form for the general and Explore
override sections

## Validation
- `make gen`
- `go test ./coderd -run 'TestChatModelOverrides'`
- `go test ./coderd/x/chatd -run
'TestSpawnAgent_(GeneralUsesConfiguredModelOverride|GeneralOverrideLogsAndFallsBackWhenCredentialsUnavailable|GeneralOverrideLogsAndFallsBackWhenProviderDisabled)'`
- `pnpm -C site lint:types`
- `pnpm -C site test:storybook --
AgentSettingsAgentsPageView.stories.tsx`
- `make lint`
- `make pre-commit`

> Mux is acting on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-04-24 12:37:20 +02:00
committed by GitHub
parent 4505278a9f
commit 3d90546aae
23 changed files with 1780 additions and 677 deletions
+13 -11
View File
@@ -3257,20 +3257,22 @@ class ExperimentalApiMethods {
);
};
getChatExploreModelOverride =
async (): Promise<TypesGen.ChatExploreModelOverrideResponse> => {
const response =
await this.axios.get<TypesGen.ChatExploreModelOverrideResponse>(
"/api/experimental/chats/config/explore-model-override",
);
return response.data;
};
getChatAgentModelOverride = async (
context: TypesGen.ChatAgentModelOverrideContext,
): Promise<TypesGen.ChatAgentModelOverrideResponse> => {
const response =
await this.axios.get<TypesGen.ChatAgentModelOverrideResponse>(
`/api/experimental/chats/config/agent-model-override/${encodeURIComponent(context)}`,
);
return response.data;
};
updateChatExploreModelOverride = async (
req: TypesGen.UpdateChatExploreModelOverrideRequest,
updateChatAgentModelOverride = async (
context: TypesGen.ChatAgentModelOverrideContext,
req: TypesGen.UpdateChatAgentModelOverrideRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/explore-model-override",
`/api/experimental/chats/config/agent-model-override/${encodeURIComponent(context)}`,
req,
);
};
-17
View File
@@ -1127,23 +1127,6 @@ export const updateChatPlanModeInstructions = (queryClient: QueryClient) => ({
},
});
const chatExploreModelOverrideKey = ["chat-explore-model-override"] as const;
export const chatExploreModelOverride = () => ({
queryKey: chatExploreModelOverrideKey,
queryFn: () => API.experimental.getChatExploreModelOverride(),
});
export const updateChatExploreModelOverride = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.UpdateChatExploreModelOverrideRequest) =>
API.experimental.updateChatExploreModelOverride(req),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatExploreModelOverrideKey,
});
},
});
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
export const chatDesktopEnabled = () => ({
+28 -23
View File
@@ -1272,6 +1272,25 @@ export interface Chat {
readonly children: readonly Chat[];
}
// From codersdk/chats.go
export type ChatAgentModelOverrideContext = "explore" | "general";
export const ChatAgentModelOverrideContexts: ChatAgentModelOverrideContext[] = [
"explore",
"general",
];
// From codersdk/chats.go
/**
* ChatAgentModelOverrideResponse is the response body for the chat agent
* model override configuration endpoint.
*/
export interface ChatAgentModelOverrideResponse {
readonly context: ChatAgentModelOverrideContext;
readonly model_config_id: string;
readonly is_malformed: boolean;
}
// From codersdk/chats.go
export type ChatBusyBehavior = "interrupt" | "queue";
@@ -1590,20 +1609,6 @@ export interface ChatDiffStatus {
readonly stale_at?: string;
}
// From codersdk/chats.go
/**
* ChatExploreModelOverrideResponse is the response body for the Explore
* subagent model override configuration endpoint.
*/
export interface ChatExploreModelOverrideResponse {
readonly model_config_id?: string;
/**
* HasMalformedOverride reports whether the saved override is malformed and
* is currently being treated as unset.
*/
readonly has_malformed_override: boolean;
}
// From codersdk/chats.go
/**
* ChatFileMetadata contains lightweight metadata about a file
@@ -7657,6 +7662,15 @@ export interface UpdateAppearanceConfig {
readonly announcement_banners: readonly BannerConfig[];
}
// From codersdk/chats.go
/**
* UpdateChatAgentModelOverrideRequest is the request body for updating the
* chat agent model override configuration endpoint.
*/
export interface UpdateChatAgentModelOverrideRequest {
readonly model_config_id: string;
}
// From codersdk/chats.go
/**
* UpdateChatDebugLoggingAllowUsersRequest is the admin request to
@@ -7674,15 +7688,6 @@ export interface UpdateChatDesktopEnabledRequest {
readonly enable_desktop: boolean;
}
// From codersdk/chats.go
/**
* UpdateChatExploreModelOverrideRequest is the request body for updating the
* Explore subagent model override configuration endpoint.
*/
export interface UpdateChatExploreModelOverrideRequest {
readonly model_config_id?: string;
}
// From codersdk/chats.go
/**
* UpdateChatModelConfigRequest updates a chat model config.
@@ -1,34 +1,83 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatExploreModelOverride,
chatModelConfigs,
updateChatExploreModelOverride,
} from "#/api/queries/chats";
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { API } from "#/api/api";
import { chatModelConfigs } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
const generalOverrideContext: TypesGen.ChatAgentModelOverrideContext =
"general";
const exploreOverrideContext: TypesGen.ChatAgentModelOverrideContext =
"explore";
const chatAgentModelOverrideKey = (
context: TypesGen.ChatAgentModelOverrideContext,
) => ["chat-agent-model-override", context] as const;
const chatAgentModelOverrideQuery = (
context: TypesGen.ChatAgentModelOverrideContext,
) => ({
queryKey: chatAgentModelOverrideKey(context),
queryFn: () => API.experimental.getChatAgentModelOverride(context),
});
const updateChatAgentModelOverrideMutation = (
queryClient: QueryClient,
context: TypesGen.ChatAgentModelOverrideContext,
) => ({
mutationFn: (req: TypesGen.UpdateChatAgentModelOverrideRequest) =>
API.experimental.updateChatAgentModelOverride(context, req),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatAgentModelOverrideKey(context),
exact: true,
});
},
});
const AgentSettingsAgentsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const canEditDeploymentConfig = permissions.editDeploymentConfig;
const generalModelOverrideQuery = useQuery({
...chatAgentModelOverrideQuery(generalOverrideContext),
enabled: canEditDeploymentConfig,
});
const exploreModelOverrideQuery = useQuery({
...chatExploreModelOverride(),
enabled: permissions.editDeploymentConfig,
...chatAgentModelOverrideQuery(exploreOverrideContext),
enabled: canEditDeploymentConfig,
});
const modelConfigsQuery = useQuery(chatModelConfigs());
const saveGeneralModelOverrideMutation = useMutation(
updateChatAgentModelOverrideMutation(queryClient, generalOverrideContext),
);
const saveExploreModelOverrideMutation = useMutation(
updateChatExploreModelOverride(queryClient),
updateChatAgentModelOverrideMutation(queryClient, exploreOverrideContext),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<RequirePermission isFeatureVisible={canEditDeploymentConfig}>
<AgentSettingsAgentsPageView
generalModelOverrideData={generalModelOverrideQuery.data}
exploreModelOverrideData={exploreModelOverrideQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelConfigsError={modelConfigsQuery.error}
isLoadingModelConfigs={modelConfigsQuery.isLoading}
onSaveGeneralModelOverride={saveGeneralModelOverrideMutation.mutate}
isSavingGeneralModelOverride={
saveGeneralModelOverrideMutation.isPending
}
isSaveGeneralModelOverrideError={
saveGeneralModelOverrideMutation.isError
}
onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate}
isSavingExploreModelOverride={
saveExploreModelOverrideMutation.isPending
@@ -6,208 +6,294 @@ import {
type AgentSettingsAgentsPageViewProps,
} from "./AgentSettingsAgentsPageView";
const baseArgs: AgentSettingsAgentsPageViewProps = {
exploreModelOverrideData: {
has_malformed_override: false,
},
modelConfigsData: [],
const OVERRIDE_MALFORMED_WARNING =
"The saved override is malformed and is being treated as unset. Click Save to clear it.";
const UNAVAILABLE_SAVED_MODEL_WARNING =
"The saved model is no longer enabled and will be ignored until you choose a new override.";
const buildModelConfig = (
overrides: Partial<TypesGen.ChatModelConfig>,
): TypesGen.ChatModelConfig => ({
id: "model-default",
provider: "openai",
model: "gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
enabled: true,
is_default: false,
context_limit: 1_000_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
...overrides,
});
const buildOverrideData = (
context: TypesGen.ChatAgentModelOverrideContext,
overrides: Partial<TypesGen.ChatAgentModelOverrideResponse> = {},
): TypesGen.ChatAgentModelOverrideResponse => ({
context,
model_config_id: "",
is_malformed: false,
...overrides,
});
const generalModelConfig = buildModelConfig({
id: "model-general-gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
});
const claudeSonnetModelConfig = buildModelConfig({
id: "model-claude-sonnet-4",
provider: "anthropic",
model: "claude-sonnet-4",
display_name: "Claude Sonnet 4",
context_limit: 200_000,
});
const exploreFallbackModelConfig = buildModelConfig({
id: "model-explore-blank-display",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
display_name: "",
context_limit: 200_000,
});
const generalDisabledModelConfig = buildModelConfig({
id: "model-general-disabled",
model: "gpt-4.1-legacy",
display_name: "GPT 4.1 Legacy",
enabled: false,
});
const exploreDisabledModelConfig = buildModelConfig({
id: "model-explore-disabled",
provider: "anthropic",
model: "claude-haiku-legacy",
display_name: "Claude Haiku Legacy",
enabled: false,
context_limit: 200_000,
});
const allModelConfigs: TypesGen.ChatModelConfig[] = [
generalModelConfig,
claudeSonnetModelConfig,
exploreFallbackModelConfig,
generalDisabledModelConfig,
exploreDisabledModelConfig,
];
const makeArgs = (
overrides: Partial<AgentSettingsAgentsPageViewProps> = {},
): AgentSettingsAgentsPageViewProps => ({
generalModelOverrideData: buildOverrideData("general"),
exploreModelOverrideData: buildOverrideData("explore"),
modelConfigsData: allModelConfigs,
modelConfigsError: undefined,
isLoadingModelConfigs: false,
onSaveGeneralModelOverride: fn(),
isSavingGeneralModelOverride: false,
isSaveGeneralModelOverrideError: false,
onSaveExploreModelOverride: fn(),
isSavingExploreModelOverride: false,
isSaveExploreModelOverrideError: false,
...overrides,
});
const getSection = async (
canvasElement: HTMLElement,
headingName: string,
): Promise<HTMLElement> => {
const canvas = within(canvasElement);
const heading = await canvas.findByRole("heading", { name: headingName });
const section = heading.closest("section");
if (!(section instanceof HTMLElement)) {
throw new Error(
`Expected ${headingName} heading to live inside a section.`,
);
}
return section;
};
const selectModelInSection = async (
section: HTMLElement,
canvasElement: HTMLElement,
currentSelectionName: string | RegExp,
optionName: string,
) => {
const trigger = within(section).getByRole("combobox", {
name: currentSelectionName,
});
await userEvent.click(trigger);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("option", { name: optionName }));
};
const meta = {
title: "pages/AgentsPage/AgentSettingsAgentsPageView",
component: AgentSettingsAgentsPageView,
args: baseArgs,
args: makeArgs(),
} satisfies Meta<typeof AgentSettingsAgentsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsAgentsPageView>;
export const ExploreModelOverrideSetting: Story = {
args: {
exploreModelOverrideData: {
model_config_id: "model-explore-1",
has_malformed_override: false,
},
modelConfigsData: [
{
id: "model-explore-1",
provider: "openai",
model: "gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
enabled: true,
is_default: false,
context_limit: 1_000_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
{
id: "model-explore-2",
provider: "anthropic",
model: "claude-sonnet-4",
display_name: "Claude Sonnet 4",
enabled: true,
is_default: false,
context_limit: 200_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText("Agents");
await canvas.findByText("Explore subagent model");
const trigger = canvas.getByRole("combobox", {
name: /gpt 4.1 mini/i,
});
await userEvent.click(trigger);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("option", { name: "Claude Sonnet 4" }),
);
const form = trigger.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected Explore model selector to live inside a form.");
}
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{ model_config_id: "model-explore-2" },
expect.anything(),
);
});
},
};
export const ExploreModelOverrideAllowsExplicitClear: Story = {
args: {
exploreModelOverrideData: {
model_config_id: "model-explore-clear",
has_malformed_override: false,
},
modelConfigsData: [
{
id: "model-explore-clear",
provider: "openai",
model: "gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
enabled: true,
is_default: false,
context_limit: 1_000_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
onSaveExploreModelOverride: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const clearButton = await canvas.findByRole("button", { name: "Clear" });
const form = clearButton.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error(
"Expected Explore model clear button to live inside a form.",
);
}
const saveButton = within(form).getByRole("button", { name: "Save" });
await userEvent.click(clearButton);
expect(args.onSaveExploreModelOverride).not.toHaveBeenCalled();
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{},
expect.anything(),
);
});
},
};
export const ExploreModelOverrideClearsMalformedSavedValue: Story = {
args: {
exploreModelOverrideData: {
has_malformed_override: true,
},
modelConfigsData: [],
onSaveExploreModelOverride: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(
"The saved override is malformed and is being treated as unset. Click Save to clear it.",
);
const clearButton = await canvas.findByRole("button", { name: "Clear" });
const form = clearButton.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error(
"Expected Explore model clear button to live inside a form.",
);
}
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(clearButton);
expect(args.onSaveExploreModelOverride).not.toHaveBeenCalled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{},
expect.anything(),
);
});
},
};
export const ExploreModelOverrideFallsBackToModelName: Story = {
args: {
exploreModelOverrideData: {
model_config_id: "model-explore-empty-name",
has_malformed_override: false,
},
modelConfigsData: [
{
id: "model-explore-empty-name",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
display_name: "",
enabled: true,
is_default: false,
context_limit: 200_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
},
export const AllOverridesUnset: Story = {
args: makeArgs(),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole("combobox", {
name: /claude-sonnet-4-20250514/i,
});
expect(trigger).toHaveTextContent("claude-sonnet-4-20250514");
await userEvent.click(trigger);
const body = within(canvasElement.ownerDocument.body);
await canvas.findByText("Agents");
const headings = await canvas.findAllByRole("heading", { level: 3 });
expect(headings.map((heading) => heading.textContent?.trim())).toEqual([
"General model",
"Explore subagent model",
]);
for (const headingName of ["General model", "Explore subagent model"]) {
const section = await getSection(canvasElement, headingName);
expect(
within(section).getByRole("combobox", { name: "Use chat default" }),
).toBeInTheDocument();
expect(
within(section).getByRole("button", { name: "Save" }),
).toBeDisabled();
}
},
};
export const EachOverrideSetToEnabledModel: Story = {
args: makeArgs({
generalModelOverrideData: buildOverrideData("general", {
model_config_id: generalModelConfig.id,
}),
exploreModelOverrideData: buildOverrideData("explore", {
model_config_id: exploreFallbackModelConfig.id,
}),
}),
play: async ({ canvasElement, args }) => {
const generalSection = await getSection(canvasElement, "General model");
const exploreSection = await getSection(
canvasElement,
"Explore subagent model",
);
expect(
await body.findByRole("option", {
name: "claude-sonnet-4-20250514",
within(exploreSection).getByRole("combobox", {
name: /claude-sonnet-4-20250514/i,
}),
).toBeInTheDocument();
).toHaveTextContent("claude-sonnet-4-20250514");
await selectModelInSection(
generalSection,
canvasElement,
/gpt 4\.1 mini/i,
"Claude Sonnet 4",
);
const generalSaveButton = within(generalSection).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(generalSaveButton).toBeEnabled();
});
await userEvent.click(generalSaveButton);
await waitFor(() => {
expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith(
{ model_config_id: claudeSonnetModelConfig.id },
expect.anything(),
);
});
const exploreClearButton = within(exploreSection).getByRole("button", {
name: "Clear",
});
await userEvent.click(exploreClearButton);
const exploreSaveButton = within(exploreSection).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(exploreSaveButton).toBeEnabled();
});
await userEvent.click(exploreSaveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{ model_config_id: "" },
expect.anything(),
);
});
},
};
export const MalformedOverridesRemainClearableAndSaveable: Story = {
args: makeArgs({
generalModelOverrideData: buildOverrideData("general", {
is_malformed: true,
}),
exploreModelOverrideData: buildOverrideData("explore", {
is_malformed: true,
}),
}),
play: async ({ canvasElement, args }) => {
const generalSection = await getSection(canvasElement, "General model");
const exploreSection = await getSection(
canvasElement,
"Explore subagent model",
);
for (const section of [generalSection, exploreSection]) {
await within(section).findByText(OVERRIDE_MALFORMED_WARNING);
}
const generalSaveButton = within(generalSection).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(generalSaveButton).toBeEnabled();
});
await userEvent.click(generalSaveButton);
await waitFor(() => {
expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith(
{ model_config_id: "" },
expect.anything(),
);
});
const exploreSaveButton = within(exploreSection).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(exploreSaveButton).toBeEnabled();
});
await userEvent.click(exploreSaveButton);
await waitFor(() => {
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
{ model_config_id: "" },
expect.anything(),
);
});
},
};
export const UnavailableSavedModels: Story = {
args: makeArgs({
generalModelOverrideData: buildOverrideData("general", {
model_config_id: generalDisabledModelConfig.id,
}),
exploreModelOverrideData: buildOverrideData("explore", {
model_config_id: exploreDisabledModelConfig.id,
}),
}),
play: async ({ canvasElement }) => {
const generalSection = await getSection(canvasElement, "General model");
const exploreSection = await getSection(
canvasElement,
"Explore subagent model",
);
for (const section of [generalSection, exploreSection]) {
await within(section).findByText(UNAVAILABLE_SAVED_MODEL_WARNING);
expect(
within(section).getByRole("combobox", { name: "Unavailable model" }),
).toBeInTheDocument();
}
},
};
@@ -1,24 +1,26 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ExploreModelOverrideSettings } from "./components/ExploreModelOverrideSettings";
import { SectionHeader } from "./components/SectionHeader";
import {
type MutationCallbacks,
SubagentModelOverrideSettings,
} from "./components/SubagentModelOverrideSettings";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
type SaveModelOverride = (
req: TypesGen.UpdateChatAgentModelOverrideRequest,
options?: MutationCallbacks,
) => void;
export interface AgentSettingsAgentsPageViewProps {
exploreModelOverrideData:
| TypesGen.ChatExploreModelOverrideResponse
| undefined;
generalModelOverrideData?: TypesGen.ChatAgentModelOverrideResponse;
exploreModelOverrideData?: TypesGen.ChatAgentModelOverrideResponse;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
onSaveExploreModelOverride: (
req: TypesGen.UpdateChatExploreModelOverrideRequest,
options?: MutationCallbacks,
) => void;
onSaveGeneralModelOverride?: SaveModelOverride;
isSavingGeneralModelOverride?: boolean;
isSaveGeneralModelOverrideError?: boolean;
onSaveExploreModelOverride: SaveModelOverride;
isSavingExploreModelOverride: boolean;
isSaveExploreModelOverrideError: boolean;
}
@@ -26,37 +28,84 @@ export interface AgentSettingsAgentsPageViewProps {
export const AgentSettingsAgentsPageView: FC<
AgentSettingsAgentsPageViewProps
> = ({
generalModelOverrideData,
exploreModelOverrideData,
modelConfigsData,
modelConfigsError,
isLoadingModelConfigs,
onSaveGeneralModelOverride,
isSavingGeneralModelOverride = false,
isSaveGeneralModelOverrideError = false,
onSaveExploreModelOverride,
isSavingExploreModelOverride,
isSaveExploreModelOverrideError,
}) => {
const enabledModelConfigs = (modelConfigsData ?? []).filter(
(modelConfig) => modelConfig.enabled,
);
const showGeneralModelSection =
onSaveGeneralModelOverride !== undefined ||
generalModelOverrideData !== undefined ||
isSavingGeneralModelOverride ||
isSaveGeneralModelOverrideError;
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Agents"
description="Configure defaults for delegated agents and other agent-specific capabilities."
/>
<div className="flex flex-col gap-3">
{showGeneralModelSection && onSaveGeneralModelOverride && (
<section aria-label="General model" className="flex flex-col gap-3">
<SectionHeader
label="General model"
description="Deployment-wide model override for delegated subagents with write capabilities, such as editing files or running commands in the workspace."
level="section"
/>
<SubagentModelOverrideSettings
title="General model"
description="Deployment-wide model override for delegated subagents with write capabilities, such as editing files or running commands in the workspace."
modelOverrideData={generalModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
modelConfigsError={modelConfigsError}
isLoading={isLoadingModelConfigs}
onSaveModelOverride={onSaveGeneralModelOverride}
isSaving={isSavingGeneralModelOverride}
isSaveError={isSaveGeneralModelOverrideError}
saveErrorMessage="Failed to save general model override."
showHeader={false}
/>
</section>
)}
<section
aria-label="Explore subagent model"
className="flex flex-col gap-3"
>
<SectionHeader
label="Explore subagent model"
description="Optional deployment-wide model override for read-only Explore subagents."
description="Deployment-wide model override for read-only Explore subagents."
level="section"
/>
<ExploreModelOverrideSettings
exploreModelOverrideData={exploreModelOverrideData}
modelConfigs={modelConfigsData ?? []}
<SubagentModelOverrideSettings
title="Explore subagent model"
description={
<>
Deployment-wide model override for read-only Explore subagents
launched through the <code>spawn_agent</code> tool with a
<code>type=explore</code> argument.
</>
}
modelOverrideData={exploreModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
modelConfigsError={modelConfigsError}
isLoadingModelConfigs={isLoadingModelConfigs}
onSaveExploreModelOverride={onSaveExploreModelOverride}
isSavingExploreModelOverride={isSavingExploreModelOverride}
isSaveExploreModelOverrideError={isSaveExploreModelOverrideError}
isLoading={isLoadingModelConfigs}
onSaveModelOverride={onSaveExploreModelOverride}
isSaving={isSavingExploreModelOverride}
isSaveError={isSaveExploreModelOverrideError}
saveErrorMessage="Failed to save Explore model override."
showHeader={false}
/>
</div>
</section>
</div>
);
};
@@ -156,7 +156,11 @@ const fixedNow = dayjs("2026-03-12T12:00:00");
const AgentsRouteElement = () => (
<AgentSettingsAgentsPageView
exploreModelOverrideData={{ has_malformed_override: false }}
exploreModelOverrideData={{
context: "explore",
model_config_id: "",
is_malformed: false,
}}
modelConfigsData={[]}
modelConfigsError={undefined}
isLoadingModelConfigs={false}
@@ -609,10 +613,23 @@ export const WithErrorReasons: Story = {
const openSettingsView = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
const link = await waitFor(() =>
canvas.getByRole("link", { name: "Settings" }),
const settingsLink = canvas.queryByRole("link", { name: "Settings" });
if (settingsLink) {
await userEvent.click(settingsLink);
return;
}
const mobileMoreOptionsButton = canvas
.getAllByRole("button", { name: "More options" })
.find((button) => button.getAttribute("aria-haspopup") === "menu");
if (!mobileMoreOptionsButton) {
throw new Error("Expected a mobile More options menu button.");
}
await userEvent.click(mobileMoreOptionsButton);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("menuitem", { name: "Settings" }),
);
await userEvent.click(link);
};
export const OpensAnalyticsForAdmins: Story = {
@@ -1,176 +0,0 @@
import { useFormik } from "formik";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface ExploreModelOverrideSettingsProps {
exploreModelOverrideData:
| TypesGen.ChatExploreModelOverrideResponse
| undefined;
modelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
onSaveExploreModelOverride: (
req: TypesGen.UpdateChatExploreModelOverrideRequest,
options?: MutationCallbacks,
) => void;
isSavingExploreModelOverride: boolean;
isSaveExploreModelOverrideError: boolean;
showHeader?: boolean;
}
const toModelSelectorOption = (
modelConfig: TypesGen.ChatModelConfig,
): ModelSelectorOption => ({
id: modelConfig.id,
provider: modelConfig.provider,
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
});
export const ExploreModelOverrideSettings: FC<
ExploreModelOverrideSettingsProps
> = ({
exploreModelOverrideData,
modelConfigs,
modelConfigsError,
isLoadingModelConfigs,
onSaveExploreModelOverride,
isSavingExploreModelOverride,
isSaveExploreModelOverrideError,
showHeader = true,
}) => {
const hasLoadedExploreModelOverride = exploreModelOverrideData !== undefined;
const enabledModelOptions = modelConfigs
.filter((modelConfig) => modelConfig.enabled)
.map(toModelSelectorOption);
const form = useFormik({
enableReinitialize: true,
initialValues: {
model_config_id: exploreModelOverrideData?.model_config_id ?? "",
},
onSubmit: (values, { resetForm }) => {
onSaveExploreModelOverride(
{
model_config_id: values.model_config_id || undefined,
},
{
onSuccess: () => {
resetForm({ values });
},
},
);
},
});
const isUnavailableSavedModel =
form.values.model_config_id !== "" &&
!enabledModelOptions.some(
(option) => option.id === form.values.model_config_id,
);
const hasMalformedOverride =
exploreModelOverrideData?.has_malformed_override ?? false;
const isExploreModelOverrideDisabled =
isSavingExploreModelOverride ||
isLoadingModelConfigs ||
!hasLoadedExploreModelOverride;
const canSaveExploreModelOverride =
hasLoadedExploreModelOverride && (form.dirty || hasMalformedOverride);
return (
<form className="space-y-2" onSubmit={form.handleSubmit}>
{showHeader && (
<>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Explore subagent model
</h3>
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Optional deployment-wide model override for read-only Explore
subagents spawned with <code>spawn_agent</code> using
<code>type=explore</code>.
</p>
</>
)}
<div className="rounded-lg border border-border bg-surface-primary px-3 py-2">
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) =>
form.setFieldValue("model_config_id", value)
}
disabled={isExploreModelOverrideDisabled}
placeholder={
isUnavailableSavedModel ? "Unavailable model" : "Use chat default"
}
emptyMessage={
isLoadingModelConfigs
? "Loading models..."
: "No enabled models found."
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
contentClassName="min-w-[18rem]"
/>
</div>
{isUnavailableSavedModel && (
<Alert severity="warning">
<AlertDescription>
The saved model is no longer enabled and will be ignored until you
choose a new override.
</AlertDescription>
</Alert>
)}
{hasMalformedOverride && (
<Alert severity="warning">
<AlertDescription>
The saved override is malformed and is being treated as unset. Click
Save to clear it.
</AlertDescription>
</Alert>
)}
{Boolean(modelConfigsError) && (
<p className="m-0 text-xs text-content-destructive">
Failed to load model configs.
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => {
form.setFieldValue("model_config_id", "");
}}
disabled={isExploreModelOverrideDisabled}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={
isExploreModelOverrideDisabled || !canSaveExploreModelOverride
}
>
Save
</Button>
</div>
{isSaveExploreModelOverrideError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save Explore model override.
</p>
)}
</form>
);
};
@@ -0,0 +1,174 @@
import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
export interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface ModelOverrideData {
readonly model_config_id: string;
readonly is_malformed: boolean;
}
interface UpdateModelOverrideRequest {
readonly model_config_id: string;
}
interface SubagentModelOverrideSettingsProps {
title: string;
description: ReactNode;
modelOverrideData: ModelOverrideData | undefined;
enabledModelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsError: unknown;
isLoading: boolean;
onSaveModelOverride: (
req: UpdateModelOverrideRequest,
options?: MutationCallbacks,
) => void;
isSaving: boolean;
isSaveError: boolean;
saveErrorMessage: string;
showHeader?: boolean;
disabled?: boolean;
}
const toModelSelectorOption = (
modelConfig: TypesGen.ChatModelConfig,
): ModelSelectorOption => ({
id: modelConfig.id,
provider: modelConfig.provider,
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
});
export const SubagentModelOverrideSettings: FC<
SubagentModelOverrideSettingsProps
> = ({
title,
description,
modelOverrideData,
enabledModelConfigs,
modelConfigsError,
isLoading,
onSaveModelOverride,
isSaving,
isSaveError,
saveErrorMessage,
showHeader = true,
disabled = false,
}) => {
const hasLoadedModelOverride = modelOverrideData !== undefined;
const enabledModelOptions = enabledModelConfigs.map(toModelSelectorOption);
const form = useFormik({
enableReinitialize: true,
initialValues: {
model_config_id: modelOverrideData?.model_config_id ?? "",
},
onSubmit: (values, { resetForm }) => {
onSaveModelOverride(
{
model_config_id: values.model_config_id,
},
{
onSuccess: () => {
resetForm({ values });
},
},
);
},
});
const isUnavailableSavedModel =
form.values.model_config_id !== "" &&
!enabledModelOptions.some(
(option) => option.id === form.values.model_config_id,
);
const isMalformedOverride = modelOverrideData?.is_malformed ?? false;
const isModelOverrideDisabled =
disabled || isSaving || isLoading || !hasLoadedModelOverride;
const canSaveModelOverride =
hasLoadedModelOverride && (form.dirty || isMalformedOverride);
return (
<form aria-label={title} className="space-y-2" onSubmit={form.handleSubmit}>
{showHeader && (
<>
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
{title}
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
{description}
</p>
</>
)}
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) => form.setFieldValue("model_config_id", value)}
disabled={isModelOverrideDisabled}
placeholder={
isUnavailableSavedModel ? "Unavailable model" : "Use chat default"
}
emptyMessage={
isLoading ? "Loading models..." : "No enabled models found."
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
contentClassName="min-w-[18rem]"
/>
{isUnavailableSavedModel && (
<Alert severity="warning">
<AlertDescription>
The saved model is no longer enabled and will be ignored until you
choose a new override.
</AlertDescription>
</Alert>
)}
{isMalformedOverride && (
<Alert severity="warning">
<AlertDescription>
The saved override is malformed and is being treated as unset. Click
Save to clear it.
</AlertDescription>
</Alert>
)}
{Boolean(modelConfigsError) && (
<p className="m-0 text-xs text-content-destructive">
Failed to load model configs.
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => {
form.setFieldValue("model_config_id", "");
}}
disabled={isModelOverrideDisabled}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isModelOverrideDisabled || !canSaveModelOverride}
>
Save
</Button>
</div>
{isSaveError && (
<p className="m-0 text-xs text-content-destructive">
{saveErrorMessage}
</p>
)}
</form>
);
};