mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): remember reasoning effort per model on new chat (#27457)
This commit is contained in:
@@ -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}");
|
||||
|
||||
@@ -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<AgentCreateFormProps> = ({
|
||||
}
|
||||
return selectedModel || undefined;
|
||||
})();
|
||||
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState("");
|
||||
const [selectedReasoningEfforts, setSelectedReasoningEfforts] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
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<AgentCreateFormProps> = ({
|
||||
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<AgentCreateFormProps> = ({
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
reasoningEffort={effectiveReasoningEffort}
|
||||
onReasoningEffortChange={setSelectedReasoningEffort}
|
||||
onReasoningEffortChange={handleReasoningEffortChange}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
hasModelOptions={hasModelOptions}
|
||||
planModeEnabled={planModeEnabled}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user