diff --git a/README.md b/README.md index 4423afe8df..ff28fabdc3 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 57e0c7c722..defa3d0254 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx index d00912734a..72c3803282 100644 --- a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx +++ b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx @@ -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(() => @@ -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( 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( 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} + {modelSwitchUnresolved && ( +
+

+ {t.agents.settingsModelSwitchNeedsCatalog} +

+ +
+ )} {/* Temperature */} @@ -395,8 +467,8 @@ export function AgentSettingsDialog({ /> - {/* Thinking mode (only when the selected model supports it) */} - {supportsThinking && ( + {/* Keep incompatible saved values visible and explicitly clearable. */} + {(supportsThinking || thinkingUnsupported) && (
{t.agents.settingsThinking} @@ -405,43 +477,85 @@ export function AgentSettingsDialog({ value={effectiveThinking} onValueChange={(value) => setThinking(value as typeof thinking)} > - + {t.agents.settingsInherit} - - {t.agents.settingsThinkingOn} - - {!thinkingRequired && ( + {supportsThinking && ( + + {t.agents.settingsThinkingOn} + + )} + {supportsThinking && !thinkingRequired && ( {t.agents.settingsThinkingOff} )} + {thinkingUnsupported && ( + + {thinking === "off" + ? t.agents.settingsThinkingOff + : t.agents.settingsThinkingOn}{" "} + ({t.agents.settingsReasoningUnsupported}) + + )} + {thinkingUnsupported && !normalizeReasoning && ( + + )}
)} - {/* Reasoning effort (only when supported) */} - {supportsReasoningEffort && ( + {(supportsReasoningEffort || effortUnsupported) && (
{t.agents.settingsReasoningEffort} + {effortUnsupported && !normalizeReasoning && ( +

+ {t.agents.settingsReasoningUnsupportedHint} +

+ )}
)} @@ -573,7 +692,10 @@ export function AgentSettingsDialog({ > {t.common.cancel} - diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index b5b0a31b99..0e4202bdb2 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 3eba5f02d6..877edb06c2 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 651109c5f1..9bfc41d29a 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1341,6 +1341,13 @@ export const zhCN: Translations = { settingsKnowledgeSaveToolAccess: "请先保存工具权限,再选择默认知识库。", settingsModel: "默认模型", settingsModelDefault: "使用全局默认", + settingsModelSwitchNeedsCatalog: + "请先加载所选模型,再保存模型切换。你可以重试加载,或恢复原模型后保存其他修改。", + settingsReasoningUnsupported: "不支持", + settingsThinkingRequiredHint: + "此模型必须开启思考,已保存的关闭设置可能导致运行无法开始。请选择继承或开启以替换该值。", + settingsReasoningUnsupportedHint: + "所选模型不支持此已保存值。请选择继承或支持的值以替换它。", settingsTemperature: "温度", settingsTemperatureHint: "0 = 确定性输出,越高越发散(0–2)。", settingsMaxTokens: "最大输出 token", diff --git a/frontend/tests/e2e/agent-reasoning-settings.spec.ts b/frontend/tests/e2e/agent-reasoning-settings.spec.ts new file mode 100644 index 0000000000..d847abf7e9 --- /dev/null +++ b/frontend/tests/e2e/agent-reasoning-settings.spec.ts @@ -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((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[] = []; + 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 | undefined; + await page.route("**/api/agents/test-agent", (route) => { + if (route.request().method() === "PUT") { + saved = route.request().postDataJSON() as Record; + 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 | undefined; + await page.route("**/api/agents/test-agent", (route) => { + if (route.request().method() === "PUT") { + saved = route.request().postDataJSON() as Record; + 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((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 | undefined; + await page.route("**/api/agents/test-agent", (route) => { + if (route.request().method() === "PUT") { + saved = route.request().postDataJSON() as Record; + 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(); + } + }); +} diff --git a/frontend/tests/unit/components/workspace/agents/agent-reasoning-settings.dom.test.tsx b/frontend/tests/unit/components/workspace/agents/agent-reasoning-settings.dom.test.tsx new file mode 100644 index 0000000000..ee0047688b --- /dev/null +++ b/frontend/tests/unit/components/workspace/agents/agent-reasoning-settings.dom.test.tsx @@ -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>(), +})); +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( + , + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + 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 }) + .request; +} + +function expectReasoningOmitted(request: Record) { + 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("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( + , + ); + 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, + }); + }); +});