refactor(app): extract resolveAgentForm pure reducer from use-agent-form-state (#719)
Moves all preference-resolution logic into a pure `resolveAgentForm(state, action)` reducer with a discriminated-union action type. The hook becomes a thin React container: it holds state via `useReducer`, dispatches actions, and owns the hydration-ref timing logic. Removes the `__private__` export and the vi.mock-heavy live test in favour of direct pure-function unit tests (53 tests, zero vi.mock of internal modules).
This commit is contained in:
@@ -0,0 +1,967 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveAgentForm,
|
||||||
|
resolveFormState,
|
||||||
|
resolveThinkingOptionId,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
|
combineInitialValues,
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
|
resolveDefaultModel,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
type AgentFormReducerState,
|
||||||
|
type UserModifiedFields,
|
||||||
|
} from "./resolve-agent-form";
|
||||||
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex test provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [
|
||||||
|
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude test provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
||||||
|
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
||||||
|
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODEX_MODELS: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.3-codex",
|
||||||
|
label: "gpt-5.3-codex",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "xhigh",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "low" },
|
||||||
|
{ id: "xhigh", label: "xhigh", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeProviderMap(
|
||||||
|
...definitions: AgentProviderDefinition[]
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map(definitions.map((d) => [d.id, d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||||
|
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
||||||
|
const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION);
|
||||||
|
|
||||||
|
function makeState(
|
||||||
|
overrides: Partial<AgentFormReducerState["form"]> = {},
|
||||||
|
modified: Partial<UserModifiedFields> = {},
|
||||||
|
): AgentFormReducerState {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
serverId: null,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveDefaultModel", () => {
|
||||||
|
it("returns null for empty or null input", () => {
|
||||||
|
expect(resolveDefaultModel(null)).toBeNull();
|
||||||
|
expect(resolveDefaultModel([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the model marked isDefault", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||||
|
{ provider: "codex", id: "b", label: "B", isDefault: true },
|
||||||
|
];
|
||||||
|
expect(resolveDefaultModel(models)?.id).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first model when none is marked default", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||||
|
{ provider: "codex", id: "b", label: "B", isDefault: false },
|
||||||
|
];
|
||||||
|
expect(resolveDefaultModel(models)?.id).toBe("a");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveThinkingOptionId", () => {
|
||||||
|
it("returns empty string when model has no thinking options", () => {
|
||||||
|
const modelsWithoutThinking: AgentModelDefinition[] = [
|
||||||
|
{ provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6", isDefault: true },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: modelsWithoutThinking,
|
||||||
|
modelId: "claude-sonnet-4-6",
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
}),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the requested option when it is valid", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
requestedThinkingOptionId: "low",
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaultThinkingOptionId when requested option is invalid", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
requestedThinkingOptionId: "invalid",
|
||||||
|
}),
|
||||||
|
).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to first option when no default and requested is invalid", () => {
|
||||||
|
const modelsNoDefault: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "m",
|
||||||
|
label: "M",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: modelsNoDefault,
|
||||||
|
modelId: "m",
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combineInitialValues", () => {
|
||||||
|
it("returns undefined when no initial values and no initial server id", () => {
|
||||||
|
expect(combineInitialValues(undefined, null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
||||||
|
const combined = combineInitialValues({}, null);
|
||||||
|
expect(combined).toEqual({});
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects serverId from options when provided", () => {
|
||||||
|
expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other initial values without forcing serverId", () => {
|
||||||
|
const combined = combineInitialValues({ workingDir: "/repo" }, null);
|
||||||
|
expect(combined).toEqual({ workingDir: "/repo" });
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
||||||
|
expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null });
|
||||||
|
expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
||||||
|
serverId: "daemon-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeSelectedComposerPreferences", () => {
|
||||||
|
it("stores the selected model for the selected provider", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {},
|
||||||
|
provider: "codex",
|
||||||
|
updates: { model: "gpt-5.4" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: { codex: { model: "gpt-5.4" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing provider preferences when the selected model changes", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: { model: "gpt-5.4" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores mode and thinking preferences without dropping the selected model", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "auto",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4": "xhigh" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "low", "gpt-5.4": "xhigh" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitions", () => {
|
||||||
|
it("returns empty array when snapshot data is unavailable", () => {
|
||||||
|
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
||||||
|
expect(buildProviderDefinitions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds provider definitions from snapshot metadata", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "zai",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildProviderDefinitions(entries)).toEqual([
|
||||||
|
{
|
||||||
|
id: "zai",
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveFormState", () => {
|
||||||
|
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState().form,
|
||||||
|
|
||||||
|
bothProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
{ model: "default" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first thinking option when model exposes options without a provider default", () => {
|
||||||
|
const claudeWithThinking: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "default",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
||||||
|
claudeWithThinking,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "claude" }).form,
|
||||||
|
|
||||||
|
claudeProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("default");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
claudeProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||||
|
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||||
|
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: loadingEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: true,
|
||||||
|
model: true,
|
||||||
|
thinkingOptionId: true,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
makeState({
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
}).form,
|
||||||
|
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("full-access");
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: false,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
selectableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: false,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
|
||||||
|
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
||||||
|
expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||||
|
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "unavailable",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||||
|
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: unavailableEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{ ...INITIAL_USER_MODIFIED, provider: true },
|
||||||
|
makeState({
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
}).form,
|
||||||
|
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
new Map<AgentProvider, AgentProviderDefinition>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAgentForm", () => {
|
||||||
|
describe("RESOLVE", () => {
|
||||||
|
it("applies resolved provider and mode when no user modifications", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "codex" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: codexProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same state reference when nothing changed", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "codex" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: codexProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override user-modified provider", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "claude" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: bothProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_SERVER_ID", () => {
|
||||||
|
it("updates serverId without marking it user-modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" });
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-1");
|
||||||
|
expect(next.userModified.serverId).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_SERVER_ID_FROM_USER", () => {
|
||||||
|
it("updates serverId and marks it user-modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" });
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-2");
|
||||||
|
expect(next.userModified.serverId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_PROVIDER_FROM_USER", () => {
|
||||||
|
it("switches provider, picks preferred model and mode, marks provider modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.modeId).toBe("full-access");
|
||||||
|
expect(next.userModified.provider).toBe(true);
|
||||||
|
expect(next.userModified.model).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider defaults when no prefs", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
||||||
|
it("sets provider, model, and default mode; marks both modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
expect(next.userModified.provider).toBe(true);
|
||||||
|
expect(next.userModified.model).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider default model when modelId is empty", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects default thinking option for the chosen model", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_MODE_FROM_USER", () => {
|
||||||
|
it("updates modeId and marks it modified", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_MODE_FROM_USER", modeId: "full-access" });
|
||||||
|
|
||||||
|
expect(next.form.modeId).toBe("full-access");
|
||||||
|
expect(next.userModified.modeId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_MODEL_FROM_USER", () => {
|
||||||
|
it("updates model and resets thinking to model default when thinking is not user-modified", () => {
|
||||||
|
const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||||
|
expect(next.userModified.model).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves user-chosen thinking option when switching to same model", () => {
|
||||||
|
const state = makeState(
|
||||||
|
{ provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||||
|
{ thinkingOptionId: true },
|
||||||
|
);
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider default model when modelId is blank", () => {
|
||||||
|
const state = makeState({ provider: "codex" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: " ",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
||||||
|
it("updates thinkingOptionId and marks it modified", () => {
|
||||||
|
const state = makeState({ thinkingOptionId: "xhigh" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_THINKING_OPTION_FROM_USER",
|
||||||
|
thinkingOptionId: "low",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
expect(next.userModified.thinkingOptionId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_WORKING_DIR", () => {
|
||||||
|
it("updates workingDir without marking it modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" });
|
||||||
|
|
||||||
|
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||||
|
expect(next.userModified.workingDir).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_WORKING_DIR_FROM_USER", () => {
|
||||||
|
it("updates workingDir and marks it modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_WORKING_DIR_FROM_USER",
|
||||||
|
value: "/home/user/proj",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||||
|
expect(next.userModified.workingDir).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AUTO_SELECT_SERVER", () => {
|
||||||
|
it("sets serverId when currently null", () => {
|
||||||
|
const state = makeState({ serverId: null });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "AUTO_SELECT_SERVER",
|
||||||
|
candidateServerId: "host-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override an already-set serverId", () => {
|
||||||
|
const state = makeState({ serverId: "existing" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "AUTO_SELECT_SERVER",
|
||||||
|
candidateServerId: "host-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RESET", () => {
|
||||||
|
it("resets userModified flags while keeping form state", () => {
|
||||||
|
const state = makeState(
|
||||||
|
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
|
||||||
|
{ provider: true, modeId: true, model: true },
|
||||||
|
);
|
||||||
|
const next = resolveAgentForm(state, { type: "RESET" });
|
||||||
|
|
||||||
|
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
||||||
|
expect(next.form).toEqual(state.form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitionMap", () => {
|
||||||
|
it("builds a map from provider id to definition", () => {
|
||||||
|
const map = buildProviderDefinitionMap([TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION]);
|
||||||
|
expect(map.get("codex")).toBe(TEST_CODEX_DEFINITION);
|
||||||
|
expect(map.get("claude")).toBe(TEST_CLAUDE_DEFINITION);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitionMapForStatuses", () => {
|
||||||
|
it("returns all definitions when no snapshot entries", () => {
|
||||||
|
const map = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: undefined,
|
||||||
|
providerDefinitions: [TEST_CODEX_DEFINITION],
|
||||||
|
statuses: new Set(["ready"]),
|
||||||
|
});
|
||||||
|
expect([...map.keys()]).toEqual(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters to only matching-status enabled providers", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: "Codex",
|
||||||
|
description: "",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "loading",
|
||||||
|
enabled: true,
|
||||||
|
label: "Claude",
|
||||||
|
description: "",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const map = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions: [TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION],
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...map.keys()]).toEqual(["codex"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
import {
|
||||||
|
mergeProviderPreferences,
|
||||||
|
type FormPreferences,
|
||||||
|
type ProviderPreferences,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
|
export interface FormInitialValues {
|
||||||
|
serverId?: string | null;
|
||||||
|
provider?: AgentProvider;
|
||||||
|
modeId?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormState {
|
||||||
|
serverId: string | null;
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
modeId: string;
|
||||||
|
model: string;
|
||||||
|
thinkingOptionId: string;
|
||||||
|
workingDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserModifiedFields {
|
||||||
|
serverId: boolean;
|
||||||
|
provider: boolean;
|
||||||
|
modeId: boolean;
|
||||||
|
model: boolean;
|
||||||
|
thinkingOptionId: boolean;
|
||||||
|
workingDir: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentFormReducerState {
|
||||||
|
form: FormState;
|
||||||
|
userModified: UserModifiedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
||||||
|
|
||||||
|
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
|
||||||
|
"ready",
|
||||||
|
"loading",
|
||||||
|
]);
|
||||||
|
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||||
|
|
||||||
|
export type AgentFormAction =
|
||||||
|
| {
|
||||||
|
type: "RESOLVE";
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
preferences: FormPreferences | null;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
|
}
|
||||||
|
| { type: "SET_SERVER_ID"; value: string | null }
|
||||||
|
| { type: "SET_SERVER_ID_FROM_USER"; value: string | null }
|
||||||
|
| {
|
||||||
|
type: "SET_PROVIDER_FROM_USER";
|
||||||
|
provider: AgentProvider;
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER";
|
||||||
|
provider: AgentProvider;
|
||||||
|
modelId: string;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
}
|
||||||
|
| { type: "SET_MODE_FROM_USER"; modeId: string }
|
||||||
|
| {
|
||||||
|
type: "SET_MODEL_FROM_USER";
|
||||||
|
modelId: string;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
}
|
||||||
|
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||||
|
| { type: "SET_WORKING_DIR"; value: string }
|
||||||
|
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
|
||||||
|
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
|
||||||
|
| { type: "RESET" };
|
||||||
|
|
||||||
|
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
|
return typeof modelId === "string" ? modelId.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModel(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
|
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||||
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveModel(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
modelId: string,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
|
const normalizedModelId = modelId.trim();
|
||||||
|
if (!normalizedModelId) return null;
|
||||||
|
return (
|
||||||
|
availableModels.find((model) => model.id === normalizedModelId) ??
|
||||||
|
resolveDefaultModel(availableModels)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveThinkingOptionId(args: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
modelId: string;
|
||||||
|
requestedThinkingOptionId: string;
|
||||||
|
}): string {
|
||||||
|
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||||
|
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
if (thinkingOptions.length === 0) return "";
|
||||||
|
|
||||||
|
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||||
|
if (
|
||||||
|
normalizedThinkingOptionId &&
|
||||||
|
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||||
|
) {
|
||||||
|
return normalizedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSelectedComposerPreferences(args: {
|
||||||
|
preferences: FormPreferences;
|
||||||
|
provider: AgentProvider;
|
||||||
|
updates: Partial<ProviderPreferences>;
|
||||||
|
}): FormPreferences {
|
||||||
|
return mergeProviderPreferences({
|
||||||
|
preferences: args.preferences,
|
||||||
|
provider: args.provider,
|
||||||
|
updates: args.updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combineInitialValues(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
initialServerId: string | null,
|
||||||
|
): FormInitialValues | undefined {
|
||||||
|
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
||||||
|
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
||||||
|
|
||||||
|
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitServerId) {
|
||||||
|
return { ...initialValues, serverId: initialValues?.serverId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverIdFromOptions !== undefined) {
|
||||||
|
return { ...initialValues, serverId: serverIdFromOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
||||||
|
return (
|
||||||
|
prev.serverId !== next.serverId ||
|
||||||
|
prev.provider !== next.provider ||
|
||||||
|
prev.modeId !== next.modeId ||
|
||||||
|
prev.model !== next.model ||
|
||||||
|
prev.thinkingOptionId !== next.thinkingOptionId ||
|
||||||
|
prev.workingDir !== next.workingDir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderDefinitionMap(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
|
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderDefinitionMapForStatuses(args: {
|
||||||
|
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||||
|
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
if (!args.snapshotEntries?.length) {
|
||||||
|
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProviders = new Set(
|
||||||
|
args.snapshotEntries
|
||||||
|
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
||||||
|
.map((entry) => entry.provider),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildProviderDefinitionMap(
|
||||||
|
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProvider(input: {
|
||||||
|
currentProvider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
preferences: FormPreferences | null;
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
|
}): AgentProvider | null {
|
||||||
|
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
||||||
|
if (userModified) {
|
||||||
|
if (
|
||||||
|
currentProvider &&
|
||||||
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(currentProvider)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||||
|
return initialValues.provider;
|
||||||
|
}
|
||||||
|
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||||
|
return preferences.provider;
|
||||||
|
}
|
||||||
|
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModeId(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentModeId: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
||||||
|
input;
|
||||||
|
if (userModified) return currentModeId;
|
||||||
|
if (!provider) return "";
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
if (
|
||||||
|
typeof initialValues?.modeId === "string" &&
|
||||||
|
initialValues.modeId.length > 0 &&
|
||||||
|
validModeIds.includes(initialValues.modeId)
|
||||||
|
) {
|
||||||
|
return initialValues.modeId;
|
||||||
|
}
|
||||||
|
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||||
|
return providerPrefs.mode;
|
||||||
|
}
|
||||||
|
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelField(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentModel: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
||||||
|
input;
|
||||||
|
if (userModified) return currentModel;
|
||||||
|
if (!provider) return "";
|
||||||
|
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
if (initialModel) {
|
||||||
|
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
||||||
|
}
|
||||||
|
if (preferredModel) {
|
||||||
|
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThinkingOption(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentThinkingOptionId: string;
|
||||||
|
modelId: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
||||||
|
input;
|
||||||
|
if (!provider) return "";
|
||||||
|
if (userModified) return currentThinkingOptionId;
|
||||||
|
const initialThinkingOptionId =
|
||||||
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
|
? initialValues.thinkingOptionId.trim()
|
||||||
|
: "";
|
||||||
|
const effectiveModelId = modelId.trim();
|
||||||
|
const preferredThinking = effectiveModelId
|
||||||
|
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||||
|
: "";
|
||||||
|
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
||||||
|
if (preferredThinking.length > 0) return preferredThinking;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFormState(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
preferences: FormPreferences | null,
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
userModified: UserModifiedFields,
|
||||||
|
currentState: FormState,
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||||
|
): FormState {
|
||||||
|
const result = { ...currentState };
|
||||||
|
|
||||||
|
result.provider = resolveProvider({
|
||||||
|
currentProvider: result.provider,
|
||||||
|
userModified: userModified.provider,
|
||||||
|
initialValues,
|
||||||
|
preferences,
|
||||||
|
allowedProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||||
|
const providerPrefs = result.provider
|
||||||
|
? preferences?.providerPreferences?.[result.provider]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
result.modeId = resolveModeId({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.modeId,
|
||||||
|
currentModeId: result.modeId,
|
||||||
|
initialValues,
|
||||||
|
providerDef,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.model = resolveModelField({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.model,
|
||||||
|
currentModel: result.model,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
availableModels,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.thinkingOptionId = resolveThinkingOption({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.thinkingOptionId,
|
||||||
|
currentThinkingOptionId: result.thinkingOptionId,
|
||||||
|
modelId: result.model,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.provider && availableModels) {
|
||||||
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels,
|
||||||
|
modelId: result.model,
|
||||||
|
requestedThinkingOptionId: result.thinkingOptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
||||||
|
result.serverId = initialValues.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
||||||
|
result.workingDir = initialValues.workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextModelForProvider(input: {
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { providerModels, providerPrefs } = input;
|
||||||
|
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
|
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
||||||
|
return preferredModel;
|
||||||
|
}
|
||||||
|
return defaultModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextModeForProvider(input: {
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { providerDef, providerPrefs } = input;
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||||
|
return providerPrefs.mode;
|
||||||
|
}
|
||||||
|
return providerDef?.defaultModeId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextThinkingOptionForProvider(input: {
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
modelId: string;
|
||||||
|
}): string {
|
||||||
|
const { providerModels, providerPrefs, modelId } = input;
|
||||||
|
const preferredThinking = modelId
|
||||||
|
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
||||||
|
: "";
|
||||||
|
return resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId,
|
||||||
|
requestedThinkingOptionId: preferredThinking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentForm(
|
||||||
|
state: AgentFormReducerState,
|
||||||
|
action: AgentFormAction,
|
||||||
|
): AgentFormReducerState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "RESOLVE": {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
action.initialValues,
|
||||||
|
action.preferences,
|
||||||
|
action.availableModels,
|
||||||
|
state.userModified,
|
||||||
|
state.form,
|
||||||
|
action.allowedProviderMap,
|
||||||
|
);
|
||||||
|
if (!hasFormStateChanged(state.form, resolved)) return state;
|
||||||
|
return { ...state, form: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_SERVER_ID":
|
||||||
|
return { ...state, form: { ...state.form, serverId: action.value } };
|
||||||
|
|
||||||
|
case "SET_SERVER_ID_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, serverId: action.value },
|
||||||
|
userModified: { ...state.userModified, serverId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_PROVIDER_FROM_USER": {
|
||||||
|
const nextModelId = pickNextModelForProvider({
|
||||||
|
providerModels: action.providerModels,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
});
|
||||||
|
const nextModeId = pickNextModeForProvider({
|
||||||
|
providerDef: action.providerDef,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
});
|
||||||
|
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
||||||
|
providerModels: action.providerModels,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
modelId: nextModelId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
provider: action.provider,
|
||||||
|
modeId: nextModeId,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, provider: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: action.providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
provider: action.provider,
|
||||||
|
model: nextModelId,
|
||||||
|
modeId: action.providerDef?.defaultModeId ?? "",
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, provider: true, model: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_MODE_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, modeId: action.modeId },
|
||||||
|
userModified: { ...state.userModified, modeId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_MODEL_FROM_USER": {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: action.availableModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: state.userModified.thinkingOptionId
|
||||||
|
? state.form.thinkingOptionId
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, model: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_THINKING_OPTION_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||||
|
userModified: { ...state.userModified, thinkingOptionId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_WORKING_DIR":
|
||||||
|
return { ...state, form: { ...state.form, workingDir: action.value } };
|
||||||
|
|
||||||
|
case "SET_WORKING_DIR_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, workingDir: action.value },
|
||||||
|
userModified: { ...state.userModified, workingDir: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "AUTO_SELECT_SERVER":
|
||||||
|
if (state.form.serverId) return state;
|
||||||
|
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
|
||||||
|
|
||||||
|
case "RESET":
|
||||||
|
return { ...state, userModified: INITIAL_USER_MODIFIED };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { FormPreferences } from "./use-form-preferences";
|
|
||||||
import { useAgentFormState } from "./use-agent-form-state";
|
|
||||||
|
|
||||||
const { mocks } = vi.hoisted(() => ({
|
|
||||||
mocks: {
|
|
||||||
preferences: {} as FormPreferences,
|
|
||||||
isPreferencesLoading: false,
|
|
||||||
snapshotEntries: [] as ProviderSnapshotEntry[],
|
|
||||||
updatePreferences: vi.fn(),
|
|
||||||
refreshSnapshot: vi.fn(),
|
|
||||||
refetchSnapshotIfStale: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "host-1" }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./use-form-preferences", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("./use-form-preferences")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useFormPreferences: () => ({
|
|
||||||
preferences: mocks.preferences,
|
|
||||||
isLoading: mocks.isPreferencesLoading,
|
|
||||||
updatePreferences: mocks.updatePreferences,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./use-providers-snapshot", () => ({
|
|
||||||
useProvidersSnapshot: () => ({
|
|
||||||
entries: mocks.snapshotEntries,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
isRefreshing: false,
|
|
||||||
error: null,
|
|
||||||
supportsSnapshot: true,
|
|
||||||
refresh: mocks.refreshSnapshot,
|
|
||||||
refetchIfStale: mocks.refetchSnapshotIfStale,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useAgentFormState live preference hydration", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
|
||||||
url: "http://localhost",
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "document", {
|
|
||||||
value: dom.window.document,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: dom.window,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
|
||||||
value: dom.window.navigator,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.preferences = {};
|
|
||||||
mocks.isPreferencesLoading = false;
|
|
||||||
mocks.snapshotEntries = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex test provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [
|
|
||||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{
|
|
||||||
id: "full-access",
|
|
||||||
label: "Full Access",
|
|
||||||
icon: "ShieldAlert",
|
|
||||||
colorTier: "dangerous",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4",
|
|
||||||
label: "gpt-5.4",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "low",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "xhigh", label: "XHigh" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude test provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
label: "Sonnet 4.6",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mocks.updatePreferences.mockReset();
|
|
||||||
mocks.refreshSnapshot.mockReset();
|
|
||||||
mocks.refetchSnapshotIfStale.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(() =>
|
|
||||||
useAgentFormState({
|
|
||||||
initialServerId: "host-1",
|
|
||||||
isVisible: true,
|
|
||||||
onlineServerIds: ["host-1"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("codex");
|
|
||||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
|
||||||
expect(result.current.selectedMode).toBe("full-access");
|
|
||||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
mode: "default",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "auto",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
rerender();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("codex");
|
|
||||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
|
||||||
expect(result.current.selectedMode).toBe("full-access");
|
|
||||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the latest preferences when a separate composer hydrates later", async () => {
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
mode: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentFormState({
|
|
||||||
initialServerId: "host-1",
|
|
||||||
isVisible: true,
|
|
||||||
onlineServerIds: ["host-1"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
|
|
||||||
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
|
|
||||||
expect(result.current.selectedMode).toBe("default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { __private__ } from "./use-agent-form-state";
|
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
||||||
import type {
|
|
||||||
AgentModelDefinition,
|
|
||||||
AgentProvider,
|
|
||||||
ProviderSnapshotEntry,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
|
||||||
id: "codex",
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex test provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [
|
|
||||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude test provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
|
||||||
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
|
||||||
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeProviderMap(
|
|
||||||
...definitions: AgentProviderDefinition[]
|
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
return new Map(definitions.map((d) => [d.id, d]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
|
||||||
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
|
||||||
|
|
||||||
describe("useAgentFormState", () => {
|
|
||||||
describe("buildProviderDefinitions", () => {
|
|
||||||
it("returns empty array when snapshot data is unavailable", () => {
|
|
||||||
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
|
||||||
expect(buildProviderDefinitions([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds custom provider definitions from snapshot metadata", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "zai",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "ZAI",
|
|
||||||
description: "Claude with ZAI config",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Safe mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Claude",
|
|
||||||
description: "Anthropic Claude",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const definitions = buildProviderDefinitions(entries);
|
|
||||||
|
|
||||||
expect(definitions).toEqual([
|
|
||||||
{
|
|
||||||
id: "zai",
|
|
||||||
label: "ZAI",
|
|
||||||
description: "Claude with ZAI config",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Safe mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Anthropic Claude",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Always Ask",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.combineInitialValues", () => {
|
|
||||||
it("returns undefined when no initial values and no initial server id", () => {
|
|
||||||
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
|
||||||
const combined = __private__.combineInitialValues({}, null);
|
|
||||||
expect(combined).toEqual({});
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects serverId from options when provided", () => {
|
|
||||||
expect(__private__.combineInitialValues({}, "daemon-1")).toEqual({
|
|
||||||
serverId: "daemon-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps other initial values without forcing serverId", () => {
|
|
||||||
const combined = __private__.combineInitialValues({ workingDir: "/repo" }, null);
|
|
||||||
expect(combined).toEqual({ workingDir: "/repo" });
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
|
||||||
expect(__private__.combineInitialValues({ serverId: null }, "daemon-1")).toEqual({
|
|
||||||
serverId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(__private__.combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
|
||||||
serverId: "daemon-2",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.mergeSelectedComposerPreferences", () => {
|
|
||||||
it("stores the selected model for the selected provider", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves existing provider preferences when the selected model changes", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "medium",
|
|
||||||
},
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "medium",
|
|
||||||
},
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores mode and thinking preferences without dropping the selected model", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "auto",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "low",
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.resolveFormState", () => {
|
|
||||||
const codexModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.3-codex",
|
|
||||||
label: "gpt-5.3-codex",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "xhigh",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "low" },
|
|
||||||
{ id: "xhigh", label: "xhigh", isDefault: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: null,
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
expect(resolved.modeId).toBe("");
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "default" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' to the provider default model", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "default" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an explicit initial thinking option when it is valid", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the first thinking option when the model exposes options without a provider default", () => {
|
|
||||||
const claudeModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "default",
|
|
||||||
label: "Default (Sonnet 4.6)",
|
|
||||||
isDefault: true,
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "medium", label: "Medium" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
|
||||||
claudeModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "claude",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
claudeProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("default");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
claudeProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
|
||||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "loading",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
|
||||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: loadingEntries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: true,
|
|
||||||
modeId: true,
|
|
||||||
model: true,
|
|
||||||
thinkingOptionId: true,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "xhigh",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
resolvableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
expect(resolved.modeId).toBe("full-access");
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: false,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
|
||||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: entries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "claude" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
selectableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
expect(resolved.modeId).toBe("auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: false,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
|
||||||
|
|
||||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: entries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
|
|
||||||
const snapshotProviderStates = [
|
|
||||||
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
|
|
||||||
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
|
||||||
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
|
|
||||||
expect(snapshotProviderStates).toEqual([
|
|
||||||
{ provider: "codex", enabled: true },
|
|
||||||
{ provider: "claude", enabled: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
|
||||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "unavailable",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
|
||||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: unavailableEntries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: true,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "xhigh",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
resolvableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
expect(resolved.modeId).toBe("");
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -13,49 +13,26 @@ import {
|
|||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
type FormPreferences,
|
type FormPreferences,
|
||||||
type ProviderPreferences,
|
|
||||||
} from "./use-form-preferences";
|
} from "./use-form-preferences";
|
||||||
|
import {
|
||||||
|
resolveAgentForm,
|
||||||
|
resolveEffectiveModel,
|
||||||
|
normalizeSelectedModelId,
|
||||||
|
resolveDefaultModelId,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
|
combineInitialValues,
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
RESOLVABLE_PROVIDER_STATUSES,
|
||||||
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
|
type FormInitialValues,
|
||||||
|
type FormState,
|
||||||
|
} from "./resolve-agent-form";
|
||||||
|
|
||||||
// Explicit overrides from URL params or "New Agent" button
|
export type { FormInitialValues } from "./resolve-agent-form";
|
||||||
export interface FormInitialValues {
|
|
||||||
serverId?: string | null;
|
|
||||||
provider?: AgentProvider;
|
|
||||||
modeId?: string | null;
|
|
||||||
model?: string | null;
|
|
||||||
thinkingOptionId?: string | null;
|
|
||||||
workingDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks which fields the user has explicitly modified in this session
|
export interface UseAgentFormStateOptions {
|
||||||
interface UserModifiedFields {
|
|
||||||
serverId: boolean;
|
|
||||||
provider: boolean;
|
|
||||||
modeId: boolean;
|
|
||||||
model: boolean;
|
|
||||||
thinkingOptionId: boolean;
|
|
||||||
workingDir: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Internal form state
|
|
||||||
interface FormState {
|
|
||||||
serverId: string | null;
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
modeId: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId: string;
|
|
||||||
workingDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAgentFormStateOptions {
|
|
||||||
initialServerId?: string | null;
|
initialServerId?: string | null;
|
||||||
initialValues?: FormInitialValues;
|
initialValues?: FormInitialValues;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
@@ -97,316 +74,6 @@ export interface UseAgentFormStateResult {
|
|||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
|
||||||
if (!normalized) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModel(
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
): AgentModelDefinition | null {
|
|
||||||
if (!availableModels || availableModels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
|
||||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
modelId: string,
|
|
||||||
): AgentModelDefinition | null {
|
|
||||||
if (!availableModels || availableModels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedModelId = modelId.trim();
|
|
||||||
if (!normalizedModelId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
|
||||||
resolveDefaultModel(availableModels)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThinkingOptionId(args: {
|
|
||||||
availableModels: AgentModelDefinition[] | null;
|
|
||||||
modelId: string;
|
|
||||||
requestedThinkingOptionId: string;
|
|
||||||
}): string {
|
|
||||||
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
|
||||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
|
||||||
if (thinkingOptions.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
|
||||||
if (
|
|
||||||
normalizedThinkingOptionId &&
|
|
||||||
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
|
||||||
) {
|
|
||||||
return normalizedThinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeSelectedComposerPreferences(args: {
|
|
||||||
preferences: FormPreferences;
|
|
||||||
provider: AgentProvider;
|
|
||||||
updates: Partial<ProviderPreferences>;
|
|
||||||
}): FormPreferences {
|
|
||||||
return mergeProviderPreferences({
|
|
||||||
preferences: args.preferences,
|
|
||||||
provider: args.provider,
|
|
||||||
updates: args.updates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure function that resolves form state from multiple data sources.
|
|
||||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
|
||||||
*
|
|
||||||
* Only resolves fields that haven't been user-modified.
|
|
||||||
*/
|
|
||||||
function resolveProvider(input: {
|
|
||||||
currentProvider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
preferences: FormPreferences | null;
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
|
||||||
}): AgentProvider | null {
|
|
||||||
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
|
||||||
if (userModified) {
|
|
||||||
if (
|
|
||||||
currentProvider &&
|
|
||||||
allowedProviderMap.size > 0 &&
|
|
||||||
!allowedProviderMap.has(currentProvider)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return currentProvider;
|
|
||||||
}
|
|
||||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
|
||||||
return initialValues.provider;
|
|
||||||
}
|
|
||||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
|
||||||
return preferences.provider;
|
|
||||||
}
|
|
||||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return currentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModeId(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentModeId: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerDef: AgentProviderDefinition | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
|
||||||
input;
|
|
||||||
if (userModified) return currentModeId;
|
|
||||||
if (!provider) return "";
|
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
|
||||||
if (
|
|
||||||
typeof initialValues?.modeId === "string" &&
|
|
||||||
initialValues.modeId.length > 0 &&
|
|
||||||
validModeIds.includes(initialValues.modeId)
|
|
||||||
) {
|
|
||||||
return initialValues.modeId;
|
|
||||||
}
|
|
||||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
return providerPrefs.mode;
|
|
||||||
}
|
|
||||||
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelField(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentModel: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
availableModels: AgentModelDefinition[] | null;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
|
||||||
input;
|
|
||||||
if (userModified) return currentModel;
|
|
||||||
if (!provider) return "";
|
|
||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
|
||||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
|
||||||
if (initialModel) {
|
|
||||||
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
|
||||||
}
|
|
||||||
if (preferredModel) {
|
|
||||||
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThinkingOption(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentThinkingOptionId: string;
|
|
||||||
modelId: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
|
||||||
input;
|
|
||||||
if (!provider) return "";
|
|
||||||
if (userModified) return currentThinkingOptionId;
|
|
||||||
const initialThinkingOptionId =
|
|
||||||
typeof initialValues?.thinkingOptionId === "string"
|
|
||||||
? initialValues.thinkingOptionId.trim()
|
|
||||||
: "";
|
|
||||||
const effectiveModelId = modelId.trim();
|
|
||||||
const preferredThinking = effectiveModelId
|
|
||||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
|
||||||
: "";
|
|
||||||
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
|
||||||
if (preferredThinking.length > 0) return preferredThinking;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFormState(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
preferences: FormPreferences | null,
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
userModified: UserModifiedFields,
|
|
||||||
currentState: FormState,
|
|
||||||
validServerIds: Set<string>,
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
|
||||||
): FormState {
|
|
||||||
const result = { ...currentState };
|
|
||||||
|
|
||||||
result.provider = resolveProvider({
|
|
||||||
currentProvider: result.provider,
|
|
||||||
userModified: userModified.provider,
|
|
||||||
initialValues,
|
|
||||||
preferences,
|
|
||||||
allowedProviderMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
|
||||||
const providerPrefs = result.provider
|
|
||||||
? preferences?.providerPreferences?.[result.provider]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
result.modeId = resolveModeId({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.modeId,
|
|
||||||
currentModeId: result.modeId,
|
|
||||||
initialValues,
|
|
||||||
providerDef,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.model = resolveModelField({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.model,
|
|
||||||
currentModel: result.model,
|
|
||||||
initialValues,
|
|
||||||
providerPrefs,
|
|
||||||
availableModels,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.thinkingOptionId = resolveThinkingOption({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.thinkingOptionId,
|
|
||||||
currentThinkingOptionId: result.thinkingOptionId,
|
|
||||||
modelId: result.model,
|
|
||||||
initialValues,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.provider && availableModels) {
|
|
||||||
result.thinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels,
|
|
||||||
modelId: result.model,
|
|
||||||
requestedThinkingOptionId: result.thinkingOptionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
|
||||||
result.serverId = initialValues.serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
|
||||||
result.workingDir = initialValues.workingDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function combineInitialValues(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
initialServerId: string | null,
|
|
||||||
): FormInitialValues | undefined {
|
|
||||||
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
|
||||||
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
|
||||||
|
|
||||||
// If nobody provided initial values or an explicit serverId, let preferences drive defaults.
|
|
||||||
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasExplicitServerId) {
|
|
||||||
return { ...initialValues, serverId: initialValues?.serverId };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverIdFromOptions !== undefined) {
|
|
||||||
return { ...initialValues, serverId: serverIdFromOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
return initialValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
|
|
||||||
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
|
||||||
|
|
||||||
function buildProviderDefinitionMap(
|
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
providerDefinitions.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProviderDefinitionMapForStatuses(args: {
|
|
||||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
|
||||||
}): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
if (!args.snapshotEntries?.length) {
|
|
||||||
return buildProviderDefinitionMap(args.providerDefinitions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingProviders = new Set(
|
|
||||||
args.snapshotEntries
|
|
||||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
|
||||||
.map((entry) => entry.provider),
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildProviderDefinitionMap(
|
|
||||||
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
|
||||||
|
|
||||||
function shouldAutoSelectServerId(input: {
|
function shouldAutoSelectServerId(input: {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isCreateFlow: boolean;
|
isCreateFlow: boolean;
|
||||||
@@ -434,59 +101,6 @@ function shouldAutoSelectServerId(input: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
|
||||||
return (
|
|
||||||
prev.serverId !== next.serverId ||
|
|
||||||
prev.provider !== next.provider ||
|
|
||||||
prev.modeId !== next.modeId ||
|
|
||||||
prev.model !== next.model ||
|
|
||||||
prev.thinkingOptionId !== next.thinkingOptionId ||
|
|
||||||
prev.workingDir !== next.workingDir
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModelForProvider(input: {
|
|
||||||
providerModels: AgentModelDefinition[] | null;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
}): string {
|
|
||||||
const { providerModels, providerPrefs } = input;
|
|
||||||
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
|
||||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
|
||||||
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
|
||||||
return preferredModel;
|
|
||||||
}
|
|
||||||
return defaultModelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModeForProvider(input: {
|
|
||||||
providerDef: AgentProviderDefinition | undefined;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
}): string {
|
|
||||||
const { providerDef, providerPrefs } = input;
|
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
|
||||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
return providerPrefs.mode;
|
|
||||||
}
|
|
||||||
return providerDef?.defaultModeId ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextThinkingOptionForProvider(input: {
|
|
||||||
providerModels: AgentModelDefinition[] | null;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
modelId: string;
|
|
||||||
}): string {
|
|
||||||
const { providerModels, providerPrefs, modelId } = input;
|
|
||||||
const preferredThinking = modelId
|
|
||||||
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
|
||||||
: "";
|
|
||||||
return resolveThinkingOptionId({
|
|
||||||
availableModels: providerModels,
|
|
||||||
modelId,
|
|
||||||
requestedThinkingOptionId: preferredThinking,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSelectedProviderModes(input: {
|
function resolveSelectedProviderModes(input: {
|
||||||
selectedEntry: ProviderSnapshotEntry | null;
|
selectedEntry: ProviderSnapshotEntry | null;
|
||||||
provider: AgentProvider | null;
|
provider: AgentProvider | null;
|
||||||
@@ -552,34 +166,35 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
|
||||||
// Build a set of valid server IDs for preference validation
|
|
||||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||||
|
|
||||||
// Track which fields the user has explicitly modified
|
const [{ form: formState, userModified }, dispatch] = useReducer(
|
||||||
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
|
resolveAgentForm,
|
||||||
|
initialServerId,
|
||||||
|
(serverId) => ({
|
||||||
|
form: {
|
||||||
|
serverId,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
userModified: INITIAL_USER_MODIFIED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Form state
|
const reducerStateRef = useRef({ form: formState, userModified });
|
||||||
const [formState, setFormState] = useState<FormState>(() => ({
|
|
||||||
serverId: initialServerId,
|
|
||||||
provider: null,
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
}));
|
|
||||||
const formStateRef = useRef(formState);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formStateRef.current = formState;
|
reducerStateRef.current = { form: formState, userModified };
|
||||||
}, [formState]);
|
}, [formState, userModified]);
|
||||||
|
|
||||||
// Track if we've done initial resolution (to avoid flickering)
|
|
||||||
const hasResolvedRef = useRef(false);
|
const hasResolvedRef = useRef(false);
|
||||||
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||||
|
|
||||||
// Reset user modifications when form becomes invisible
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
setUserModified(INITIAL_USER_MODIFIED);
|
dispatch({ type: "RESET" });
|
||||||
hasResolvedRef.current = false;
|
hasResolvedRef.current = false;
|
||||||
hydrationPreferencesRef.current = null;
|
hydrationPreferencesRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -644,12 +259,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const modeOptions = snapshotSelectedProviderModes;
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||||
|
|
||||||
// Combine initialValues with initialServerId for resolution
|
const combinedInitialValues = useMemo(
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
() => combineInitialValues(initialValues, initialServerId),
|
||||||
return combineInitialValues(initialValues, initialServerId);
|
[initialValues, initialServerId],
|
||||||
}, [initialValues, initialServerId]);
|
);
|
||||||
|
|
||||||
// Resolve form state when data sources change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isCreateFlow) {
|
if (!isVisible || !isCreateFlow) {
|
||||||
return;
|
return;
|
||||||
@@ -664,19 +278,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||||
|
|
||||||
const resolved = resolveFormState(
|
dispatch({
|
||||||
combinedInitialValues,
|
type: "RESOLVE",
|
||||||
hydrationPreferences,
|
initialValues: combinedInitialValues,
|
||||||
|
preferences: hydrationPreferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
||||||
formStateRef.current,
|
});
|
||||||
validServerIds,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasFormStateChanged(formStateRef.current, resolved)) {
|
|
||||||
setFormState(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasResolvedRef.current = true;
|
hasResolvedRef.current = true;
|
||||||
}, [
|
}, [
|
||||||
@@ -686,15 +294,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
combinedInitialValues,
|
combinedInitialValues,
|
||||||
preferences,
|
preferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
|
||||||
validServerIds,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
|
||||||
// - no URL override
|
|
||||||
// - no stored preference applied
|
|
||||||
// - user hasn't manually picked a host in this session
|
|
||||||
const onlineServerIdsKey = onlineServerIds.join("|");
|
const onlineServerIdsKey = onlineServerIds.join("|");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canAutoSelectServerId = shouldAutoSelectServerId({
|
const canAutoSelectServerId = shouldAutoSelectServerId({
|
||||||
@@ -704,14 +306,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
hasResolved: hasResolvedRef.current,
|
hasResolved: hasResolvedRef.current,
|
||||||
userModifiedServerId: userModified.serverId,
|
userModifiedServerId: userModified.serverId,
|
||||||
initialServerId: combinedInitialValues?.serverId,
|
initialServerId: combinedInitialValues?.serverId,
|
||||||
currentServerId: formStateRef.current.serverId,
|
currentServerId: reducerStateRef.current.form.serverId,
|
||||||
});
|
});
|
||||||
if (!canAutoSelectServerId) return;
|
if (!canAutoSelectServerId) return;
|
||||||
|
|
||||||
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
|
|
||||||
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
|
dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate });
|
||||||
}, [
|
}, [
|
||||||
combinedInitialValues?.serverId,
|
combinedInitialValues?.serverId,
|
||||||
isCreateFlow,
|
isCreateFlow,
|
||||||
@@ -723,10 +325,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// User setters - mark fields as modified and persist to preferences
|
|
||||||
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
|
||||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
@@ -738,24 +338,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs });
|
dispatch({
|
||||||
const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs });
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
|
||||||
providerModels,
|
|
||||||
providerPrefs,
|
|
||||||
modelId: nextModelId,
|
|
||||||
});
|
|
||||||
|
|
||||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
|
||||||
void updatePreferences({ provider });
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
provider,
|
provider,
|
||||||
modeId: nextModeId,
|
providerModels,
|
||||||
model: nextModelId,
|
providerDef,
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
providerPrefs,
|
||||||
}));
|
});
|
||||||
|
void updatePreferences({ provider });
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -774,20 +364,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels: providerModels,
|
|
||||||
modelId: nextModelId,
|
|
||||||
requestedThinkingOptionId: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
dispatch({
|
||||||
...prev,
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
provider,
|
provider,
|
||||||
model: nextModelId,
|
modelId,
|
||||||
modeId: providerDef?.defaultModeId ?? "",
|
providerDef,
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
providerModels,
|
||||||
}));
|
});
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -803,9 +387,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
const provider = reducerStateRef.current.form.provider;
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -823,23 +406,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
const provider = reducerStateRef.current.form.provider;
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels,
|
|
||||||
modelId: nextModelId,
|
|
||||||
requestedThinkingOptionId: userModified.thinkingOptionId
|
|
||||||
? formStateRef.current.thinkingOptionId
|
|
||||||
: "",
|
|
||||||
});
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
model: nextModelId,
|
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
|
||||||
}));
|
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -851,15 +422,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[availableModels, updatePreferences, userModified.thinkingOptionId],
|
[availableModels, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
const modelId = formStateRef.current.model;
|
|
||||||
if (provider && modelId) {
|
if (provider && modelId) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -878,16 +447,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
dispatch({ type: "SET_WORKING_DIR", value });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setWorkingDirFromUser = useCallback((value: string) => {
|
const setWorkingDirFromUser = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
dispatch({ type: "SET_WORKING_DIR_FROM_USER", value });
|
||||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSelectedServerId = useCallback((value: string | null) => {
|
const setSelectedServerId = useCallback((value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
dispatch({ type: "SET_SERVER_ID", value });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshProviderModels = useCallback(() => {
|
const refreshProviderModels = useCallback(() => {
|
||||||
@@ -895,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [refreshSnapshot]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
const refetchProviderModelsIfStale = useCallback(() => {
|
const refetchProviderModelsIfStale = useCallback(() => {
|
||||||
refetchSnapshotIfStale(formStateRef.current.provider);
|
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
|
||||||
}, [refetchSnapshotIfStale]);
|
}, [refetchSnapshotIfStale]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
@@ -993,15 +561,4 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
|
||||||
export type CreateAgentInitialValues = FormInitialValues;
|
export type CreateAgentInitialValues = FormInitialValues;
|
||||||
|
|
||||||
export const __private__ = {
|
|
||||||
buildProviderDefinitionMap,
|
|
||||||
buildProviderDefinitionMapForStatuses,
|
|
||||||
combineInitialValues,
|
|
||||||
mergeSelectedComposerPreferences,
|
|
||||||
resolveDefaultModel,
|
|
||||||
resolveFormState,
|
|
||||||
resolveThinkingOptionId,
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user