fix(agents): preserve reasoning defaults when the model catalog is unavailable (#6907)

* fix(frontend): preserve untouched custom-agent reasoning defaults

* docs: keep frontend agent guidance within size budget

* test(agents): cover reasoning edits across catalog transitions

* fix(agents): expose stale defaults and validate model switches

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Lucien 2026-10-16 17:22:21 +08:00 committed by GitHub
parent 6a6aeb4bf2
commit 34316550df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 882 additions and 35 deletions

View File

@ -2162,6 +2162,14 @@ the stable English `name`. API callers can pass `display_name` to agent creation
or update requests; an omitted update preserves it and `null` clears it. The
same optional field is supported in the agent's `config.yaml`.
Saving an unrelated Agent setting preserves its existing thinking and reasoning
defaults, including while the model catalog is loading, unavailable, or missing
the configured model. Explicit reasoning edits are saved independently; choosing
a different known model clears defaults that the new model cannot support.
Saving a model switch waits until the selected model is available; unrelated
edits remain saveable without the catalog. Incompatible saved reasoning values
are shown explicitly so they can be replaced with Inherit or a supported value.
Custom Agents migrated to `agent_storage.backend: db` remain editable through
Agent settings and `update_agent` while the importer retains their old files
for rollback. See [Agent Storage](backend/docs/CONFIGURATION.md#agent-storage)

View File

@ -385,10 +385,11 @@ through the contract's aliases/default (`resolveReasoningEffort`). Effort values
are open strings (`ReasoningEffortValue`), so provider-specific tokens such as
`max` flow through local settings and account preferences unchanged; the custom
agent dialog offers only the intersection with the per-agent `low/medium/high`
schema and hides "off" for required-thinking models. Do not read the booleans in
components directly. On a legacy model, `resolveReasoningEffort` keeps only its
advertised generic values; this drops a remembered provider-specific token after
a model switch without changing the backend's direct legacy-request behavior.
schema and blocks new "off" for required-thinking models. Do not read the booleans in
components directly. Legacy `resolveReasoningEffort` keeps only advertised generic
values, dropping provider tokens after a switch; backend requests stay unchanged.
Agent settings omit untouched reasoning, show invalid saved values for clearing,
and require a known model to save a model switch.
## Full-stack plugin UI

View File

@ -83,7 +83,11 @@ export function AgentSettingsDialog({
onOpenChange,
}: AgentSettingsDialogProps) {
const { t } = useI18n();
const { models } = useModels();
const {
models,
isFetching: modelsFetching,
refetch: refetchModels,
} = useModels();
const { scopeSelectionEnabled } = useKnowledgeBaseEnabled();
const [knowledgeSelection, setKnowledgeSelection] =
useState<KnowledgeScopeSelection>(() =>
@ -92,12 +96,18 @@ export function AgentSettingsDialog({
const [knowledgeChanged, setKnowledgeChanged] = useState(false);
const { subagents } = useSubagents();
const subagentDescriptionId = useId();
const modelStatusId = useId();
const thinkingHintId = useId();
const effortHintId = useId();
const updateAgent = useUpdateAgent();
// Keep the opening snapshot even if a background refetch updates agent props.
const [initialSelections] = useState(() => ({
plugins: agent.mcp_plugins ?? null,
skills: agent.skills ?? null,
toolGroups: agent.tool_groups ?? null,
model: agent.model ?? DEFAULT_MODEL_VALUE,
thinking: thinkingEnabledToSelection(agent.thinking_enabled),
reasoningEffort: agent.reasoning_effort ?? INHERIT_VALUE,
}));
const [plugins, setPlugins] = useState<string[] | null>(
agent.mcp_plugins ?? null,
@ -120,7 +130,7 @@ export function AgentSettingsDialog({
!agent.tool_groups.includes("knowledge");
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
const [model, setModel] = useState(agent.model ?? DEFAULT_MODEL_VALUE);
const [model, setModel] = useState(initialSelections.model);
const [temperature, setTemperature] = useState(
agent.model_settings?.temperature != null
? String(agent.model_settings.temperature)
@ -131,11 +141,9 @@ export function AgentSettingsDialog({
? String(agent.model_settings.max_tokens)
: "",
);
const [thinking, setThinking] = useState(
thinkingEnabledToSelection(agent.thinking_enabled),
);
const [thinking, setThinking] = useState(initialSelections.thinking);
const [reasoningEffort, setReasoningEffort] = useState(
agent.reasoning_effort ?? INHERIT_VALUE,
initialSelections.reasoningEffort,
);
const [subagentAccess, setSubagentAccess] = useState<SubagentAccessSelection>(
allowedSubagentsToSelection(agent.allowed_subagents),
@ -152,6 +160,12 @@ export function AgentSettingsDialog({
() => resolveEffectiveModel(models, model),
[models, model],
);
const modelChanged = model !== initialSelections.model;
// Missing catalog data does not prove a model lacks reasoning support.
// Normalize dependent settings only after an intentional, known model switch.
const modelKnown = selectedModel !== undefined;
const modelSwitchUnresolved = modelChanged && !modelKnown;
const normalizeReasoning = modelChanged && modelKnown;
const supportsThinking = modelSupportsThinking(selectedModel);
const thinkingRequired = isThinkingRequired(selectedModel);
// Per-agent defaults keep the generic low/medium/high schema; offer only the
@ -165,10 +179,20 @@ export function AgentSettingsDialog({
[selectedModel],
);
const supportsReasoningEffort = reasoningEffortOptions.length > 0;
// A required-thinking model cannot be switched off: a stale "off" default
// collapses to "inherit" so the save never asks for the impossible.
const thinkingUnsupported =
modelKnown &&
thinking !== INHERIT_VALUE &&
(!supportsThinking || (thinkingRequired && thinking === "off"));
const effortUnsupported =
modelKnown &&
reasoningEffort !== INHERIT_VALUE &&
!reasoningEffortOptions.includes(reasoningEffort as ReasoningEffort);
// Preview normalization only for a deliberate model switch. Otherwise show
// the saved value, including an incompatible one, so Inherit is actionable.
const effectiveThinking =
thinkingRequired && thinking === "off" ? INHERIT_VALUE : thinking;
normalizeReasoning && thinkingUnsupported ? INHERIT_VALUE : thinking;
const effectiveEffort =
normalizeReasoning && effortUnsupported ? INHERIT_VALUE : reasoningEffort;
const selectableSubagents = useMemo(
() =>
Array.from(
@ -188,6 +212,7 @@ export function AgentSettingsDialog({
}, [selectableSubagents, selectedSubagents]);
async function handleSave() {
if (modelSwitchUnresolved) return;
if ([...displayName.trim()].length > 100) {
toast.error(t.agents.settingsDisplayNameTooLong);
return;
@ -223,16 +248,29 @@ export function AgentSettingsDialog({
...(toolGroupsChanged && {
tool_groups: toolGroups,
}),
model: model === DEFAULT_MODEL_VALUE ? null : model,
...(modelChanged && {
model: model === DEFAULT_MODEL_VALUE ? null : model,
}),
model_settings: parsedSettings.modelSettings,
thinking_enabled: supportsThinking
? selectionToThinkingEnabled(effectiveThinking)
: null,
reasoning_effort:
supportsReasoningEffort &&
reasoningEffortOptions.includes(reasoningEffort as ReasoningEffort)
? (reasoningEffort as ReasoningEffort)
: null,
...((thinking !== initialSelections.thinking ||
normalizeReasoning) && {
thinking_enabled:
modelKnown && !supportsThinking
? null
: selectionToThinkingEnabled(effectiveThinking),
}),
...((reasoningEffort !== initialSelections.reasoningEffort ||
normalizeReasoning) && {
reasoning_effort:
reasoningEffort === INHERIT_VALUE ||
(modelKnown &&
(!supportsReasoningEffort ||
!reasoningEffortOptions.includes(
reasoningEffort as ReasoningEffort,
)))
? null
: (reasoningEffort as ReasoningEffort),
}),
allowed_subagents: selectionToAllowedSubagents(
subagentAccess,
selectedSubagents,
@ -344,13 +382,25 @@ export function AgentSettingsDialog({
{t.agents.settingsModel}
</span>
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-full">
<SelectTrigger
className="w-full"
aria-label={t.agents.settingsModel}
aria-describedby={
modelSwitchUnresolved ? modelStatusId : undefined
}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_MODEL_VALUE}>
{t.agents.settingsModelDefault}
</SelectItem>
{initialSelections.model !== DEFAULT_MODEL_VALUE &&
!models.some((m) => m.name === initialSelections.model) && (
<SelectItem value={initialSelections.model}>
{initialSelections.model}
</SelectItem>
)}
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.display_name || m.name}
@ -358,6 +408,28 @@ export function AgentSettingsDialog({
))}
</SelectContent>
</Select>
{modelSwitchUnresolved && (
<div className="space-y-1.5">
<p
id={modelStatusId}
role="status"
className="text-muted-foreground text-xs"
>
{t.agents.settingsModelSwitchNeedsCatalog}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={modelsFetching}
onClick={() => void refetchModels()}
>
{modelsFetching
? t.workspace.modelLoadRetrying
: t.workspace.modelLoadRetry}
</Button>
</div>
)}
</div>
{/* Temperature */}
@ -395,8 +467,8 @@ export function AgentSettingsDialog({
/>
</div>
{/* Thinking mode (only when the selected model supports it) */}
{supportsThinking && (
{/* Keep incompatible saved values visible and explicitly clearable. */}
{(supportsThinking || thinkingUnsupported) && (
<div className="space-y-1.5">
<span className="text-sm font-medium">
{t.agents.settingsThinking}
@ -405,43 +477,85 @@ export function AgentSettingsDialog({
value={effectiveThinking}
onValueChange={(value) => setThinking(value as typeof thinking)}
>
<SelectTrigger className="w-full">
<SelectTrigger
className="w-full"
aria-label={t.agents.settingsThinking}
aria-describedby={
thinkingUnsupported && !normalizeReasoning
? thinkingHintId
: undefined
}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT_VALUE}>
{t.agents.settingsInherit}
</SelectItem>
<SelectItem value="on">
{t.agents.settingsThinkingOn}
</SelectItem>
{!thinkingRequired && (
{supportsThinking && (
<SelectItem value="on">
{t.agents.settingsThinkingOn}
</SelectItem>
)}
{supportsThinking && !thinkingRequired && (
<SelectItem value="off">
{t.agents.settingsThinkingOff}
</SelectItem>
)}
{thinkingUnsupported && (
<SelectItem value={thinking} disabled>
{thinking === "off"
? t.agents.settingsThinkingOff
: t.agents.settingsThinkingOn}{" "}
({t.agents.settingsReasoningUnsupported})
</SelectItem>
)}
</SelectContent>
</Select>
{thinkingUnsupported && !normalizeReasoning && (
<p
id={thinkingHintId}
role="alert"
className="text-muted-foreground text-xs"
>
{thinkingRequired
? t.agents.settingsThinkingRequiredHint
: t.agents.settingsReasoningUnsupportedHint}
</p>
)}
</div>
)}
{/* Reasoning effort (only when supported) */}
{supportsReasoningEffort && (
{(supportsReasoningEffort || effortUnsupported) && (
<div className="space-y-1.5">
<span className="text-sm font-medium">
{t.agents.settingsReasoningEffort}
</span>
<Select
value={reasoningEffort}
value={effectiveEffort}
onValueChange={setReasoningEffort}
>
<SelectTrigger className="w-full">
<SelectTrigger
className="w-full"
aria-label={t.agents.settingsReasoningEffort}
aria-describedby={
effortUnsupported && !normalizeReasoning
? effortHintId
: undefined
}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT_VALUE}>
{t.agents.settingsInherit}
</SelectItem>
{effortUnsupported && (
<SelectItem value={reasoningEffort} disabled>
{reasoningEffort} ({t.agents.settingsReasoningUnsupported}
)
</SelectItem>
)}
{reasoningEffortOptions.map((effort) => (
<SelectItem key={effort} value={effort}>
{effort}
@ -449,6 +563,11 @@ export function AgentSettingsDialog({
))}
</SelectContent>
</Select>
{effortUnsupported && !normalizeReasoning && (
<p id={effortHintId} className="text-muted-foreground text-xs">
{t.agents.settingsReasoningUnsupportedHint}
</p>
)}
</div>
)}
@ -573,7 +692,10 @@ export function AgentSettingsDialog({
>
{t.common.cancel}
</Button>
<Button onClick={handleSave} disabled={updateAgent.isPending}>
<Button
onClick={handleSave}
disabled={updateAgent.isPending || modelSwitchUnresolved}
>
{updateAgent.isPending ? t.common.loading : t.common.save}
</Button>
</DialogFooter>

View File

@ -1434,6 +1434,13 @@ export const enUS: Translations = {
"Save tool access before choosing default knowledge bases.",
settingsModel: "Default model",
settingsModelDefault: "Use global default",
settingsModelSwitchNeedsCatalog:
"Load the selected model before saving this model change. Retry loading, or restore the original model to save other changes.",
settingsReasoningUnsupported: "not supported",
settingsThinkingRequiredHint:
"This model requires thinking. The saved Off setting can prevent runs from starting. Choose Inherit or On to replace it.",
settingsReasoningUnsupportedHint:
"This saved value is not supported by the selected model. Choose Inherit or a supported value to replace it.",
settingsTemperature: "Temperature",
settingsTemperatureHint: "0 = deterministic, higher = more creative (0–2).",
settingsMaxTokens: "Max output tokens",

View File

@ -1168,6 +1168,10 @@ export interface Translations {
settingsKnowledgeSaveToolAccess: string;
settingsModel: string;
settingsModelDefault: string;
settingsModelSwitchNeedsCatalog: string;
settingsReasoningUnsupported: string;
settingsThinkingRequiredHint: string;
settingsReasoningUnsupportedHint: string;
settingsTemperature: string;
settingsTemperatureHint: string;
settingsMaxTokens: string;

View File

@ -1341,6 +1341,13 @@ export const zhCN: Translations = {
settingsKnowledgeSaveToolAccess: "请先保存工具权限,再选择默认知识库。",
settingsModel: "默认模型",
settingsModelDefault: "使用全局默认",
settingsModelSwitchNeedsCatalog:
"请先加载所选模型,再保存模型切换。你可以重试加载,或恢复原模型后保存其他修改。",
settingsReasoningUnsupported: "不支持",
settingsThinkingRequiredHint:
"此模型必须开启思考,已保存的关闭设置可能导致运行无法开始。请选择继承或开启以替换该值。",
settingsReasoningUnsupportedHint:
"所选模型不支持此已保存值。请选择继承或支持的值以替换它。",
settingsTemperature: "温度",
settingsTemperatureHint: "0 = 确定性输出,越高越发散(0–2)。",
settingsMaxTokens: "最大输出 token",

View File

@ -0,0 +1,270 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const agent = {
name: "test-agent",
model: "reasoner",
thinking_enabled: false,
reasoning_effort: "high",
};
const models = [
{
name: "reasoner",
display_name: "Reasoner",
supports_thinking: true,
supports_reasoning_effort: true,
},
{
name: "plain",
display_name: "Plain model",
supports_thinking: false,
supports_reasoning_effort: false,
},
];
async function mockSettingsDependencies(page: Page) {
mockLangGraphAPI(page, { agents: [agent] });
await page.route("**/api/subagents", (route) =>
route.fulfill({ json: { subagents: [] } }),
);
await page.route("**/api/agent-tool-groups", (route) =>
route.fulfill({ json: { tool_groups: [] } }),
);
}
for (const state of ["pending", "failed", "loaded", "missing"] as const) {
test(`rename preserves saved reasoning when model catalog is ${state}`, async ({
page,
}) => {
await mockSettingsDependencies(page);
let release: (() => void) | undefined;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let catalogResponses = 0;
await page.route("**/api/models", async (route) => {
if (state === "pending") await held;
await route.fulfill(
state === "failed"
? { status: 503, json: { detail: "Catalog unavailable" } }
: {
json: {
models: state === "missing" ? [models[1]] : models,
token_usage: { enabled: false },
},
},
);
catalogResponses += 1;
});
const requests: Record<string, unknown>[] = [];
await page.route("**/api/agents/test-agent", (route) => {
if (route.request().method() === "PUT") {
const request = route.request().postDataJSON() as Record<
string,
unknown
>;
requests.push(request);
return route.fulfill({ json: { ...agent, ...request } });
}
return route.fulfill({ json: agent });
});
try {
await page.goto("/workspace/agents", { waitUntil: "domcontentloaded" });
await page.getByTitle("Agent settings", { exact: true }).click();
const dialog = page.getByRole("dialog");
if (state === "failed" || state === "missing") {
await expect
.poll(() => catalogResponses)
.toBeGreaterThanOrEqual(state === "failed" ? 2 : 1);
}
if (state === "loaded")
await expect(
dialog.getByText("Thinking mode", { exact: true }),
).toBeVisible();
await dialog.getByLabel("Display name").fill("Renamed reviewer");
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({ display_name: "Renamed reviewer" });
expect(requests[0]).not.toHaveProperty("thinking_enabled");
expect(requests[0]).not.toHaveProperty("reasoning_effort");
} finally {
await page.unrouteAll({ behavior: "ignoreErrors" });
release?.();
}
});
}
test("an explicit switch to a model without reasoning clears incompatible defaults", async ({
page,
}) => {
await mockSettingsDependencies(page);
await page.route("**/api/models", (route) =>
route.fulfill({ json: { models, token_usage: { enabled: false } } }),
);
let saved: Record<string, unknown> | undefined;
await page.route("**/api/agents/test-agent", (route) => {
if (route.request().method() === "PUT") {
saved = route.request().postDataJSON() as Record<string, unknown>;
return route.fulfill({ json: { ...agent, ...saved } });
}
return route.fulfill({ json: agent });
});
await page.goto("/workspace/agents");
await page.getByTitle("Agent settings", { exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(
dialog.getByText("Thinking mode", { exact: true }),
).toBeVisible();
await dialog
.getByRole("combobox", { name: "Default model", exact: true })
.click();
await page.getByRole("option", { name: "Plain model", exact: true }).click();
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
expect(saved).toMatchObject({
model: "plain",
thinking_enabled: null,
reasoning_effort: null,
});
});
const requiredModel = {
...models[0],
name: "required",
display_name: "Required model",
reasoning: {
thinking: "required",
effort: { values: ["low"], default: "low", aliases: {} },
history: null,
source: "contract",
},
};
test("stale reasoning values are visible and can be explicitly cleared", async ({
page,
}) => {
await mockSettingsDependencies(page);
await page.route("**/api/models", (route) =>
route.fulfill({
json: {
models: [{ ...requiredModel, name: "reasoner" }],
token_usage: { enabled: false },
},
}),
);
let saved: Record<string, unknown> | undefined;
await page.route("**/api/agents/test-agent", (route) => {
if (route.request().method() === "PUT") {
saved = route.request().postDataJSON() as Record<string, unknown>;
return route.fulfill({ json: { ...agent, ...saved } });
}
return route.fulfill({ json: agent });
});
await page.goto("/workspace/agents");
await page.getByTitle("Agent settings", { exact: true }).click();
const dialog = page.getByRole("dialog");
const thinking = dialog.getByRole("combobox", {
name: "Thinking mode",
exact: true,
});
const effort = dialog.getByRole("combobox", {
name: "Reasoning effort",
exact: true,
});
await expect(thinking).toContainText("Off (not supported)");
await expect(effort).toContainText("high (not supported)");
await expect(dialog.getByRole("alert")).toContainText(
"can prevent runs from starting",
);
await thinking.click();
await page.getByRole("option", { name: "Inherit", exact: true }).click();
await effort.click();
await page.getByRole("option", { name: "Inherit", exact: true }).click();
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
expect(saved).toMatchObject({
thinking_enabled: null,
reasoning_effort: null,
});
expect(saved).not.toHaveProperty("model");
});
for (const state of ["pending", "failed"] as const) {
test(`global-default switch waits for a ${state} catalog and normalizes the resolved model`, async ({
page,
}) => {
await mockSettingsDependencies(page);
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let ready = false;
let responses = 0;
await page.route("**/api/models", async (route) => {
if (!ready && state === "pending") await held;
if (!ready) {
await route.fulfill({
status: 503,
json: { detail: "Catalog unavailable" },
});
} else {
await route.fulfill({
json: {
models: [requiredModel, ...models],
token_usage: { enabled: false },
},
});
}
responses += 1;
});
let saved: Record<string, unknown> | undefined;
await page.route("**/api/agents/test-agent", (route) => {
if (route.request().method() === "PUT") {
saved = route.request().postDataJSON() as Record<string, unknown>;
return route.fulfill({ json: { ...agent, ...saved } });
}
return route.fulfill({ json: agent });
});
try {
await page.goto("/workspace/agents", { waitUntil: "domcontentloaded" });
await page.getByTitle("Agent settings", { exact: true }).click();
const dialog = page.getByRole("dialog");
if (state === "failed")
await expect.poll(() => responses).toBeGreaterThanOrEqual(2);
await dialog
.getByRole("combobox", { name: "Default model", exact: true })
.click();
await page
.getByRole("option", { name: "Use global default", exact: true })
.click();
await expect(
dialog.getByRole("button", { name: "Save", exact: true }),
).toBeDisabled();
await expect(dialog.getByRole("status")).toContainText(
"Load the selected model before saving",
);
expect(saved).toBeUndefined();
ready = true;
if (state === "pending") release();
else
await dialog
.getByRole("button", { name: "Retry", exact: true })
.click();
await expect(
dialog.getByRole("button", { name: "Save", exact: true }),
).toBeEnabled();
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
expect(saved).toMatchObject({
model: null,
thinking_enabled: null,
reasoning_effort: null,
});
} finally {
await page.unrouteAll({ behavior: "ignoreErrors" });
release();
}
});
}

View File

@ -0,0 +1,428 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { AgentSettingsDialog } from "@/components/workspace/agents/agent-settings-dialog";
import type { Agent } from "@/core/agents";
import { enUS } from "@/core/i18n/locales/en-US";
import type { Model, ModelsResponse } from "@/core/models/types";
const { mutateAsync, loadModels } = rs.hoisted(() => ({
mutateAsync: rs.fn().mockResolvedValue({}),
loadModels: rs.fn<() => Promise<ModelsResponse>>(),
}));
rs.mock("@/core/agents", () => ({
useAgentToolGroups: () => ({ toolGroups: [], isLoading: false, error: null }),
useUpdateAgent: () => ({ mutateAsync, isPending: false }),
}));
rs.mock("@/core/models/api", () => ({ loadModels }));
rs.mock("@/core/features", () => ({
useKnowledgeBaseEnabled: () => ({ scopeSelectionEnabled: false }),
}));
rs.mock("@/core/subagents", () => ({
useSubagents: () => ({ subagents: [] }),
}));
rs.mock("@/core/capabilities/hooks", () => ({
useCapabilityInstallations: () => ({
data: { items: [], can_manage: false },
isLoading: false,
isError: false,
}),
}));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } }));
const reasoningModel: Model = {
id: "reasoner",
name: "reasoner",
model: "reasoner",
display_name: "Reasoner",
supports_thinking: true,
supports_reasoning_effort: true,
};
const plainModel: Model = {
id: "plain",
name: "plain",
model: "plain",
display_name: "Plain model",
};
const catalog: ModelsResponse = {
models: [reasoningModel, plainModel],
token_usage: { enabled: false },
};
const agent: Agent = {
name: "reviewer",
description: "",
model: "reasoner",
tool_groups: null,
skills: null,
thinking_enabled: false,
reasoning_effort: "high",
};
const clients: QueryClient[] = [];
beforeEach(() => {
loadModels.mockReset();
loadModels.mockResolvedValue(catalog);
rs.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(
() => undefined,
);
});
afterEach(() => {
cleanup();
for (const client of clients.splice(0)) client.clear();
mutateAsync.mockClear();
rs.restoreAllMocks();
});
function renderSettings(value: Agent = agent) {
const client = new QueryClient();
client.setQueryDefaults(["models"], { retryDelay: 0 });
clients.push(client);
const view = render(
<AgentSettingsDialog agent={value} open onOpenChange={rs.fn()} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
},
);
return { ...view, client };
}
async function saveRequest() {
fireEvent.change(screen.getByLabelText("Display name"), {
target: { value: "Rename only" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
return (mutateAsync.mock.calls[0]![0] as { request: Record<string, unknown> })
.request;
}
function expectReasoningOmitted(request: Record<string, unknown>) {
expect(request).not.toHaveProperty("thinking_enabled");
expect(request).not.toHaveProperty("reasoning_effort");
}
async function selectOption(label: string, name: string) {
fireEvent.keyDown(screen.getByRole("combobox", { name: label }), {
key: "ArrowDown",
});
fireEvent.click(await screen.findByRole("option", { name }));
}
describe("custom agent reasoning update isolation", () => {
it("preserves saved reasoning while the initial model catalog is pending", async () => {
loadModels.mockReturnValue(new Promise(() => undefined));
renderSettings();
const request = await saveRequest();
expect(request.display_name).toBe("Rename only");
expectReasoningOmitted(request);
});
it("preserves saved reasoning after the initial catalog request fails", async () => {
loadModels.mockRejectedValue(new Error("Model catalog unavailable"));
const { client } = renderSettings();
await waitFor(() =>
expect(client.getQueryState(["models"])?.status).toBe("error"),
);
expectReasoningOmitted(await saveRequest());
});
it("preserves saved reasoning when a successful catalog is empty", async () => {
loadModels.mockResolvedValue({ ...catalog, models: [] });
const { client } = renderSettings();
await waitFor(() =>
expect(client.getQueryState(["models"])?.status).toBe("success"),
);
expectReasoningOmitted(await saveRequest());
});
it.each(["pending", "failed", "empty"] as const)(
"blocks a model switch with an unresolved %s catalog but allows reverting it",
async (state) => {
if (state === "pending") {
loadModels.mockReturnValue(new Promise(() => undefined));
} else if (state === "failed") {
loadModels.mockRejectedValue(new Error("Model catalog unavailable"));
} else {
loadModels.mockResolvedValue({ ...catalog, models: [] });
}
const { client } = renderSettings();
if (state !== "pending") {
await waitFor(() =>
expect(client.getQueryState(["models"])?.status).toBe(
state === "failed" ? "error" : "success",
),
);
}
await selectOption("Default model", "Use global default");
const save = screen.getByRole("button", { name: "Save" });
expect((save as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByRole("status").textContent).toContain(
"Load the selected model before saving",
);
fireEvent.click(save);
expect(mutateAsync).not.toHaveBeenCalled();
await selectOption("Default model", "reasoner");
expect((save as HTMLButtonElement).disabled).toBe(false);
expectReasoningOmitted(await saveRequest());
},
);
it("normalizes a global-default switch when the pending catalog resolves before saving", async () => {
let resolveCatalog!: (value: ModelsResponse) => void;
loadModels.mockReturnValue(
new Promise((resolve) => {
resolveCatalog = resolve;
}),
);
const { client } = renderSettings();
await selectOption("Default model", "Use global default");
expect(client.getQueryState(["models"])?.status).toBe("pending");
expect(
screen.getByRole<HTMLButtonElement>("button", { name: "Save" }).disabled,
).toBe(true);
await act(async () => {
resolveCatalog({ ...catalog, models: [plainModel, reasoningModel] });
});
await waitFor(() =>
expect(client.getQueryState(["models"])?.status).toBe("success"),
);
expect(await saveRequest()).toMatchObject({
model: null,
thinking_enabled: null,
reasoning_effort: null,
});
});
it.each(["reasoner", "missing", null])(
"preserves unchanged reasoning for model %s after a successful catalog load",
async (model) => {
const { client } = renderSettings({ ...agent, model });
await waitFor(() =>
expect(client.getQueryState(["models"])?.status).toBe("success"),
);
expectReasoningOmitted(await saveRequest());
},
);
it("preserves opening selections after a concurrent agent refresh", async () => {
const { rerender } = renderSettings();
await screen.findByText("Thinking mode");
rerender(
<AgentSettingsDialog
agent={{ ...agent, thinking_enabled: true, reasoning_effort: "low" }}
open
onOpenChange={rs.fn()}
/>,
);
expectReasoningOmitted(await saveRequest());
});
it("clears incompatible reasoning after deliberately switching to a known unsupported model", async () => {
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Default model", "Plain model");
expect(await saveRequest()).toMatchObject({
model: "plain",
thinking_enabled: null,
reasoning_effort: null,
});
});
it("retains valid reasoning after deliberately switching to another supported model", async () => {
loadModels.mockResolvedValue({
...catalog,
models: [
...catalog.models,
{
...reasoningModel,
name: "other-reasoner",
display_name: "Other reasoner",
},
],
});
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Default model", "Other reasoner");
expect(await saveRequest()).toMatchObject({
model: "other-reasoner",
thinking_enabled: false,
reasoning_effort: "high",
});
});
it("submits an explicit thinking edit without rewriting effort", async () => {
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Thinking mode", "On");
const request = await saveRequest();
expect(request.thinking_enabled).toBe(true);
expect(request).not.toHaveProperty("reasoning_effort");
});
it.each(["pending", "failed"] as const)(
"preserves an explicit thinking edit if the catalog becomes %s before saving",
async (state) => {
const { client } = renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Thinking mode", "On");
// The control is hidden without model data. Edit while available, then
// clear the query so the draft survives a pending or failed reload.
if (state === "pending") {
loadModels.mockReturnValue(new Promise(() => undefined));
} else {
loadModels.mockRejectedValue(new Error("Model catalog unavailable"));
}
act(() => {
void client.resetQueries({ queryKey: ["models"] });
});
await waitFor(() => {
expect(client.getQueryState(["models"])?.status).toBe(
state === "pending" ? "pending" : "error",
);
expect(screen.queryByText("Thinking mode")).toBeNull();
});
const request = await saveRequest();
expect(request.thinking_enabled).toBe(true);
expect(request).not.toHaveProperty("model");
expect(request).not.toHaveProperty("reasoning_effort");
},
);
it("submits an explicit inherit effort without rewriting thinking", async () => {
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Reasoning effort", "Inherit");
const request = await saveRequest();
expect(request.reasoning_effort).toBe(null);
expect(request).not.toHaveProperty("thinking_enabled");
});
it("omits model-dependent fields when a model switch is reverted", async () => {
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Default model", "Plain model");
await selectOption("Default model", "Reasoner");
const request = await saveRequest();
expect(request).not.toHaveProperty("model");
expectReasoningOmitted(request);
});
it("omits thinking and effort edits that were restored before saving", async () => {
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Thinking mode", "On");
await selectOption("Thinking mode", "Off");
await selectOption("Reasoning effort", "low");
await selectOption("Reasoning effort", "high");
expectReasoningOmitted(await saveRequest());
});
it("shows a stale required-thinking off value and lets Inherit explicitly clear it", async () => {
loadModels.mockResolvedValue({
...catalog,
models: [
{
...reasoningModel,
reasoning: {
thinking: "required",
effort: { values: ["low"], default: "low", aliases: {} },
history: null,
source: "contract",
},
},
],
});
renderSettings();
await screen.findByText("Thinking mode");
expect(
screen.getByRole("combobox", { name: "Thinking mode" }).textContent,
).toContain("Off");
expect(
screen.getByText(enUS.agents.settingsThinkingRequiredHint),
).toBeTruthy();
expectReasoningOmitted(await saveRequest());
mutateAsync.mockClear();
await selectOption("Thinking mode", "Inherit");
const request = await saveRequest();
expect(request.thinking_enabled).toBe(null);
expect(request).not.toHaveProperty("reasoning_effort");
});
it.each(["limited", "unsupported"] as const)(
"shows a saved effort outside a %s model's options and allows clearing it",
async (state) => {
loadModels.mockResolvedValue({
...catalog,
models: [
{
...reasoningModel,
reasoning: {
thinking: state === "limited" ? "optional" : "unsupported",
effort:
state === "limited"
? { values: ["low"], default: "low", aliases: {} }
: null,
history: null,
source: "contract",
},
},
],
});
renderSettings();
const effort = await screen.findByRole("combobox", {
name: "Reasoning effort",
});
expect(effort.textContent).toContain("high");
expect(effort.textContent).toContain("not supported");
expectReasoningOmitted(await saveRequest());
mutateAsync.mockClear();
await selectOption("Reasoning effort", "Inherit");
const request = await saveRequest();
expect(request.reasoning_effort).toBe(null);
expect(request).not.toHaveProperty("thinking_enabled");
},
);
it("normalizes thinking and effort when deliberately switching to a required-thinking model", async () => {
loadModels.mockResolvedValue({
...catalog,
models: [
...catalog.models,
{
...reasoningModel,
name: "required",
display_name: "Required model",
reasoning: {
thinking: "required",
effort: { values: ["low"], default: "low", aliases: {} },
history: null,
source: "contract",
},
},
],
});
renderSettings();
await screen.findByText("Thinking mode");
await selectOption("Default model", "Required model");
expect(await saveRequest()).toMatchObject({
model: "required",
thinking_enabled: null,
reasoning_effort: null,
});
});
});