mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
fix(site): remember reasoning effort per model on new chat (#27457)
This commit is contained in:
@@ -18,6 +18,10 @@ import {
|
|||||||
MockWorkspace,
|
MockWorkspace,
|
||||||
} from "#/testHelpers/entities";
|
} from "#/testHelpers/entities";
|
||||||
import { withDashboardProvider } from "#/testHelpers/storybook";
|
import { withDashboardProvider } from "#/testHelpers/storybook";
|
||||||
|
import {
|
||||||
|
getReasoningEffortForModel,
|
||||||
|
saveReasoningEffortForModel,
|
||||||
|
} from "../utils/reasoningEffort";
|
||||||
import { AgentCreateForm } from "./AgentCreateForm";
|
import { AgentCreateForm } from "./AgentCreateForm";
|
||||||
|
|
||||||
// Query key used by permittedOrganizations() in the form.
|
// Query key used by permittedOrganizations() in the form.
|
||||||
@@ -329,6 +333,157 @@ const effortModelOptions = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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 = {
|
export const SubmitsReasoningEffort: Story = {
|
||||||
// TODO: This story fails when pixel runs its play function. Fix it and remove the exclude.
|
// TODO: This story fails when pixel runs its play function. Fix it and remove the exclude.
|
||||||
parameters: { pixel: { exclude: true } },
|
parameters: { pixel: { exclude: true } },
|
||||||
@@ -348,6 +503,11 @@ export const SubmitsReasoningEffort: Story = {
|
|||||||
expect(slider).toHaveAttribute("aria-valuenow", "3");
|
expect(slider).toHaveAttribute("aria-valuenow", "3");
|
||||||
|
|
||||||
// Bump the effort to "high" with the keyboard, then close.
|
// 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();
|
await userEvent.tab();
|
||||||
expect(slider).toHaveFocus();
|
expect(slider).toHaveFocus();
|
||||||
await userEvent.keyboard("{ArrowRight}");
|
await userEvent.keyboard("{ArrowRight}");
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import {
|
|||||||
hasConfiguredModelsInCatalog,
|
hasConfiguredModelsInCatalog,
|
||||||
hasUserFixableProviders,
|
hasUserFixableProviders,
|
||||||
} from "../utils/modelOptions";
|
} from "../utils/modelOptions";
|
||||||
import { pickReasoningEffort } from "../utils/reasoningEffort";
|
import {
|
||||||
|
getReasoningEffortForModel,
|
||||||
|
pickReasoningEffort,
|
||||||
|
saveReasoningEffortForModel,
|
||||||
|
} from "../utils/reasoningEffort";
|
||||||
import {
|
import {
|
||||||
formatUsageLimitMessage,
|
formatUsageLimitMessage,
|
||||||
isChatUsageLimitExceededResponse,
|
isChatUsageLimitExceededResponse,
|
||||||
@@ -240,13 +244,29 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
}
|
}
|
||||||
return selectedModel || undefined;
|
return selectedModel || undefined;
|
||||||
})();
|
})();
|
||||||
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState("");
|
const [selectedReasoningEfforts, setSelectedReasoningEfforts] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
const selectedModelOption = modelOptions.find(
|
const selectedModelOption = modelOptions.find(
|
||||||
(option) => option.id === selectedModel,
|
(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
|
const effectiveReasoningEffort = selectedModelOption
|
||||||
? pickReasoningEffort(
|
? pickReasoningEffort(
|
||||||
selectedReasoningEffort,
|
selectedReasoningEfforts[selectedModel] ??
|
||||||
|
persistedReasoningEffort ??
|
||||||
|
rootOverrideReasoningEffort,
|
||||||
selectedModelOption.reasoningEfforts ?? [],
|
selectedModelOption.reasoningEfforts ?? [],
|
||||||
selectedModelOption.reasoningEffortDefault,
|
selectedModelOption.reasoningEffortDefault,
|
||||||
)
|
)
|
||||||
@@ -351,6 +371,14 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
setUserSelectedModel(value);
|
setUserSelectedModel(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReasoningEffortChange = (value: string) => {
|
||||||
|
setSelectedReasoningEfforts((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedModel]: value,
|
||||||
|
}));
|
||||||
|
saveReasoningEffortForModel(selectedModel, value);
|
||||||
|
};
|
||||||
|
|
||||||
const isForbidden = !canCreateChat;
|
const isForbidden = !canCreateChat;
|
||||||
|
|
||||||
// Filter workspaces by the selected organization. We use
|
// Filter workspaces by the selected organization. We use
|
||||||
@@ -544,7 +572,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||||
reasoningEffort={effectiveReasoningEffort}
|
reasoningEffort={effectiveReasoningEffort}
|
||||||
onReasoningEffortChange={setSelectedReasoningEffort}
|
onReasoningEffortChange={handleReasoningEffortChange}
|
||||||
isModelCatalogLoading={isModelCatalogLoading}
|
isModelCatalogLoading={isModelCatalogLoading}
|
||||||
hasModelOptions={hasModelOptions}
|
hasModelOptions={hasModelOptions}
|
||||||
planModeEnabled={planModeEnabled}
|
planModeEnabled={planModeEnabled}
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { formatReasoningEffort, pickReasoningEffort } from "./reasoningEffort";
|
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", () => {
|
describe("formatReasoningEffort", () => {
|
||||||
it("formats xhigh", () => {
|
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". */
|
/** Display label for an effort value, e.g. "xhigh" renders as "Xhigh". */
|
||||||
export const formatReasoningEffort = (value: string): string =>
|
export const formatReasoningEffort = (value: string): string =>
|
||||||
value.charAt(0).toUpperCase() + value.slice(1);
|
value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user