mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
6a6aeb4bf2
commit
34316550df
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1341,6 +1341,13 @@ export const zhCN: Translations = {
|
||||
settingsKnowledgeSaveToolAccess: "请先保存工具权限,再选择默认知识库。",
|
||||
settingsModel: "默认模型",
|
||||
settingsModelDefault: "使用全局默认",
|
||||
settingsModelSwitchNeedsCatalog:
|
||||
"请先加载所选模型,再保存模型切换。你可以重试加载,或恢复原模型后保存其他修改。",
|
||||
settingsReasoningUnsupported: "不支持",
|
||||
settingsThinkingRequiredHint:
|
||||
"此模型必须开启思考,已保存的关闭设置可能导致运行无法开始。请选择继承或开启以替换该值。",
|
||||
settingsReasoningUnsupportedHint:
|
||||
"所选模型不支持此已保存值。请选择继承或支持的值以替换它。",
|
||||
settingsTemperature: "温度",
|
||||
settingsTemperatureHint: "0 = 确定性输出,越高越发散(0–2)。",
|
||||
settingsMaxTokens: "最大输出 token",
|
||||
|
||||
270
frontend/tests/e2e/agent-reasoning-settings.spec.ts
Normal file
270
frontend/tests/e2e/agent-reasoning-settings.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user