diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 84917d7f2a..5e72778eb5 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -18,6 +18,10 @@ import { MockWorkspace, } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; +import { + getReasoningEffortForModel, + saveReasoningEffortForModel, +} from "../utils/reasoningEffort"; import { AgentCreateForm } from "./AgentCreateForm"; // Query key used by permittedOrganizations() in the form. @@ -329,6 +333,157 @@ const effortModelOptions = [ }, ] as const; +export const RemembersReasoningEffortByModel: Story = { + args: { + ...defaultArgs, + modelOptions: [...effortModelOptions], + }, + beforeEach: () => { + localStorage.clear(); + saveReasoningEffortForModel(modelConfigID, "high"); + saveReasoningEffortForModel(claudeModelConfigID, "medium"); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + const modelSelector = canvas.getByRole("combobox", { name: "GPT-4o" }); + + await userEvent.click(modelSelector); + expect(await body.findByRole("slider")).toHaveAttribute( + "aria-valuenow", + "4", + ); + await userEvent.click( + await body.findByRole("option", { name: /Claude Sonnet 4/i }), + ); + + await userEvent.click( + canvas.getByRole("combobox", { name: "Claude Sonnet 4" }), + ); + expect(await body.findByRole("slider")).toHaveAttribute( + "aria-valuenow", + "1", + ); + await userEvent.click(await body.findByRole("option", { name: /GPT-4o/i })); + + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + const restoredSlider = await body.findByRole("slider"); + expect(restoredSlider).toHaveAttribute("aria-valuenow", "4"); + restoredSlider.focus(); + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(getReasoningEffortForModel(modelConfigID)).toBe("xhigh"); + }); + await userEvent.keyboard("{Escape}"); + }, +}; + +export const PersistedReasoningEffortOutranksRootOverride: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelOptions: [...effortModelOptions], + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "model", + model_config_id: modelConfigID, + reasoning_effort: "high", + }), + }, + beforeEach: () => { + localStorage.clear(); + saveReasoningEffortForModel(modelConfigID, "low"); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + // The persisted per-model value wins over the root override. + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + expect(await body.findByRole("slider")).toHaveAttribute( + "aria-valuenow", + "2", + ); + await userEvent.keyboard("{Escape}"); + + await submitMessage(canvasElement, "create with persisted effort"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).reasoningEffort).toBe("low"); + }, +}; + +export const ManualReselectKeepsRootOverrideEffort: Story = { + args: { + ...defaultArgs, + modelOptions: [...effortModelOptions], + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "model", + model_config_id: modelConfigID, + reasoning_effort: "high", + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + // Re-selecting the override's own model keeps the override effort. + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + await userEvent.click(await body.findByRole("option", { name: /GPT-4o/i })); + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + expect(await body.findByRole("slider")).toHaveAttribute( + "aria-valuenow", + "4", + ); + await userEvent.keyboard("{Escape}"); + }, +}; + +export const StalePersistedEffortFallsThroughToRootOverride: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelOptions: [ + { + ...modelOptions[0], + reasoningEffortDefault: "low", + reasoningEfforts: ["low", "medium"], + }, + ], + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "model", + model_config_id: modelConfigID, + reasoning_effort: "medium", + }), + }, + beforeEach: () => { + localStorage.clear(); + saveReasoningEffortForModel(modelConfigID, "max"); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + // The stored "max" is no longer valid for this model, so the + // root override's "medium" applies instead of the default "low". + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + expect(await body.findByRole("slider")).toHaveAttribute( + "aria-valuenow", + "1", + ); + await userEvent.keyboard("{Escape}"); + + await submitMessage(canvasElement, "create with stale persisted effort"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).reasoningEffort).toBe("medium"); + }, +}; + export const SubmitsReasoningEffort: Story = { // TODO: This story fails when pixel runs its play function. Fix it and remove the exclude. parameters: { pixel: { exclude: true } }, @@ -348,6 +503,11 @@ export const SubmitsReasoningEffort: Story = { expect(slider).toHaveAttribute("aria-valuenow", "3"); // Bump the effort to "high" with the keyboard, then close. + // The info button precedes the slider in tab order. + await userEvent.tab(); + expect( + body.getByRole("button", { name: "About reasoning effort" }), + ).toHaveFocus(); await userEvent.tab(); expect(slider).toHaveFocus(); await userEvent.keyboard("{ArrowRight}"); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 2d1683d522..358a1d70b3 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -20,7 +20,11 @@ import { hasConfiguredModelsInCatalog, hasUserFixableProviders, } from "../utils/modelOptions"; -import { pickReasoningEffort } from "../utils/reasoningEffort"; +import { + getReasoningEffortForModel, + pickReasoningEffort, + saveReasoningEffortForModel, +} from "../utils/reasoningEffort"; import { formatUsageLimitMessage, isChatUsageLimitExceededResponse, @@ -240,13 +244,29 @@ export const AgentCreateForm: FC = ({ } return selectedModel || undefined; })(); - const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); + const [selectedReasoningEfforts, setSelectedReasoningEfforts] = useState< + Record + >({}); const selectedModelOption = modelOptions.find( (option) => option.id === selectedModel, ); + // Persisted per-model choice wins over a root override; a stale + // stored value is ignored so the override still applies. The + // override applies to its own model even after a manual re-select. + const rootOverrideReasoningEffort = + selectedModel === rootOverrideModelID + ? rootPersonalModelOverride?.reasoning_effort + : undefined; + const persistedReasoningEffort = (() => { + const stored = getReasoningEffortForModel(selectedModel); + const efforts = selectedModelOption?.reasoningEfforts; + return stored && efforts?.includes(stored) ? stored : undefined; + })(); const effectiveReasoningEffort = selectedModelOption ? pickReasoningEffort( - selectedReasoningEffort, + selectedReasoningEfforts[selectedModel] ?? + persistedReasoningEffort ?? + rootOverrideReasoningEffort, selectedModelOption.reasoningEfforts ?? [], selectedModelOption.reasoningEffortDefault, ) @@ -351,6 +371,14 @@ export const AgentCreateForm: FC = ({ setUserSelectedModel(value); }; + const handleReasoningEffortChange = (value: string) => { + setSelectedReasoningEfforts((current) => ({ + ...current, + [selectedModel]: value, + })); + saveReasoningEffortForModel(selectedModel, value); + }; + const isForbidden = !canCreateChat; // Filter workspaces by the selected organization. We use @@ -544,7 +572,7 @@ export const AgentCreateForm: FC = ({ modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} reasoningEffort={effectiveReasoningEffort} - onReasoningEffortChange={setSelectedReasoningEffort} + onReasoningEffortChange={handleReasoningEffortChange} isModelCatalogLoading={isModelCatalogLoading} hasModelOptions={hasModelOptions} planModeEnabled={planModeEnabled} diff --git a/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts b/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts index 83f79e2f34..a84e5eb174 100644 --- a/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts @@ -1,5 +1,41 @@ -import { describe, expect, it } from "vitest"; -import { formatReasoningEffort, pickReasoningEffort } from "./reasoningEffort"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatReasoningEffort, + getReasoningEffortForModel, + pickReasoningEffort, + saveReasoningEffortForModel, +} from "./reasoningEffort"; + +describe("reasoning effort storage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stores the latest effort independently for each model", () => { + saveReasoningEffortForModel("model-a", "high"); + saveReasoningEffortForModel("model-b", "medium"); + saveReasoningEffortForModel("model-a", "low"); + + expect(getReasoningEffortForModel("model-a")).toBe("low"); + expect(getReasoningEffortForModel("model-b")).toBe("medium"); + }); + + it("handles unavailable storage", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Storage unavailable", "SecurityError"); + }); + expect(getReasoningEffortForModel("model-a")).toBeUndefined(); + + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage full", "QuotaExceededError"); + }); + expect(() => saveReasoningEffortForModel("model-a", "high")).not.toThrow(); + }); +}); describe("formatReasoningEffort", () => { it("formats xhigh", () => { diff --git a/site/src/pages/AgentsPage/utils/reasoningEffort.ts b/site/src/pages/AgentsPage/utils/reasoningEffort.ts index 8c023a0bf6..530e5fb01f 100644 --- a/site/src/pages/AgentsPage/utils/reasoningEffort.ts +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.ts @@ -1,3 +1,37 @@ +const reasoningEffortStorageKeyPrefix = "agents.reasoning-effort."; + +const reasoningEffortStorageKey = (modelConfigID: string) => + `${reasoningEffortStorageKeyPrefix}${modelConfigID}`; + +/** Reads the persisted effort for a model, or undefined when none is stored or storage is unavailable. */ +export const getReasoningEffortForModel = ( + modelConfigID: string, +): string | undefined => { + try { + return ( + localStorage.getItem(reasoningEffortStorageKey(modelConfigID)) ?? + undefined + ); + } catch { + return undefined; + } +}; + +/** Persists the effort for a model. Swallows storage errors (private mode, quota) so the caller's in-memory selection is unaffected. */ +export const saveReasoningEffortForModel = ( + modelConfigID: string, + reasoningEffort: string, +): void => { + try { + localStorage.setItem( + reasoningEffortStorageKey(modelConfigID), + reasoningEffort, + ); + } catch { + // Keep the in-memory selection when storage is unavailable. + } +}; + /** Display label for an effort value, e.g. "xhigh" renders as "Xhigh". */ export const formatReasoningEffort = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1);