diff --git a/packages/app/src/hooks/resolve-agent-form.test.ts b/packages/app/src/hooks/resolve-agent-form.test.ts new file mode 100644 index 000000000..96e75b3a4 --- /dev/null +++ b/packages/app/src/hooks/resolve-agent-form.test.ts @@ -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 { + 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 = {}, + modified: Partial = {}, +): 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(["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(["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(["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(["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(), + ); + + 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(["ready"]), + }); + + expect([...map.keys()]).toEqual(["codex"]); + }); + }); +}); diff --git a/packages/app/src/hooks/resolve-agent-form.ts b/packages/app/src/hooks/resolve-agent-form.ts new file mode 100644 index 000000000..adb73f34a --- /dev/null +++ b/packages/app/src/hooks/resolve-agent-form.ts @@ -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[AgentProvider]; + +export const RESOLVABLE_PROVIDER_STATUSES = new Set([ + "ready", + "loading", +]); +export const SELECTABLE_PROVIDER_STATUSES = new Set(["ready"]); + +export type AgentFormAction = + | { + type: "RESOLVE"; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + availableModels: AgentModelDefinition[] | null; + allowedProviderMap: Map; + } + | { 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; +}): 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 { + return new Map( + providerDefinitions.map((definition) => [definition.id, definition]), + ); +} + +export function buildProviderDefinitionMapForStatuses(args: { + snapshotEntries: ProviderSnapshotEntry[] | undefined; + providerDefinitions: AgentProviderDefinition[]; + statuses: ReadonlySet; +}): Map { + 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 | 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, +): 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 }; + } +} diff --git a/packages/app/src/hooks/use-agent-form-state.live.test.ts b/packages/app/src/hooks/use-agent-form-state.live.test.ts deleted file mode 100644 index ddb44077d..000000000 --- a/packages/app/src/hooks/use-agent-form-state.live.test.ts +++ /dev/null @@ -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(); - 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("", { - 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"); - }); - }); -}); diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts deleted file mode 100644 index 7c9c8145a..000000000 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ /dev/null @@ -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 { - 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(), - 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(), - 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(), - 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(), - 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(), - 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(), - 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(), - 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(), - 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(), - 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(["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(), - 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(["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(), - 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(["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(["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(), - 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(), - new Map(), - ); - - expect(resolved.provider).toBe("codex"); - }); - }); -}); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index ed6931272..052d58b7f 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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 { AgentMode, @@ -13,49 +13,26 @@ import { useFormPreferences, mergeProviderPreferences, type FormPreferences, - type ProviderPreferences, } 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 interface FormInitialValues { - serverId?: string | null; - provider?: AgentProvider; - modeId?: string | null; - model?: string | null; - thinkingOptionId?: string | null; - workingDir?: string; -} +export type { FormInitialValues } from "./resolve-agent-form"; -// Tracks which fields the user has explicitly modified in this session -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 { +export interface UseAgentFormStateOptions { initialServerId?: string | null; initialValues?: FormInitialValues; isVisible?: boolean; @@ -97,316 +74,6 @@ export interface UseAgentFormStateResult { persistFormPreferences: () => Promise; } -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; -}): 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 | 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[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[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[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, - allowedProviderMap: Map, -): 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(["ready", "loading"]); -const SELECTABLE_PROVIDER_STATUSES = new Set(["ready"]); - -function buildProviderDefinitionMap( - providerDefinitions: AgentProviderDefinition[], -): Map { - return new Map( - providerDefinitions.map((definition) => [definition.id, definition]), - ); -} - -function buildProviderDefinitionMapForStatuses(args: { - snapshotEntries: ProviderSnapshotEntry[] | undefined; - providerDefinitions: AgentProviderDefinition[]; - statuses: ReadonlySet; -}): Map { - 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[AgentProvider]; - function shouldAutoSelectServerId(input: { isVisible: boolean; isCreateFlow: boolean; @@ -434,59 +101,6 @@ function shouldAutoSelectServerId(input: { 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: { selectedEntry: ProviderSnapshotEntry | null; provider: AgentProvider | null; @@ -552,34 +166,35 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const daemons = useHosts(); - // Build a set of valid server IDs for preference validation const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); - // Track which fields the user has explicitly modified - const [userModified, setUserModified] = useState(INITIAL_USER_MODIFIED); + const [{ form: formState, userModified }, dispatch] = useReducer( + resolveAgentForm, + initialServerId, + (serverId) => ({ + form: { + serverId, + provider: null, + modeId: "", + model: "", + thinkingOptionId: "", + workingDir: "", + }, + userModified: INITIAL_USER_MODIFIED, + }), + ); - // Form state - const [formState, setFormState] = useState(() => ({ - serverId: initialServerId, - provider: null, - modeId: "", - model: "", - thinkingOptionId: "", - workingDir: "", - })); - const formStateRef = useRef(formState); + const reducerStateRef = useRef({ form: formState, userModified }); useEffect(() => { - formStateRef.current = formState; - }, [formState]); + reducerStateRef.current = { form: formState, userModified }; + }, [formState, userModified]); - // Track if we've done initial resolution (to avoid flickering) const hasResolvedRef = useRef(false); const hydrationPreferencesRef = useRef(null); - // Reset user modifications when form becomes invisible useEffect(() => { if (!isVisible) { - setUserModified(INITIAL_USER_MODIFIED); + dispatch({ type: "RESET" }); hasResolvedRef.current = false; hydrationPreferencesRef.current = null; } @@ -644,12 +259,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const modeOptions = snapshotSelectedProviderModes; const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading; - // Combine initialValues with initialServerId for resolution - const combinedInitialValues = useMemo((): FormInitialValues | undefined => { - return combineInitialValues(initialValues, initialServerId); - }, [initialValues, initialServerId]); + const combinedInitialValues = useMemo( + () => combineInitialValues(initialValues, initialServerId), + [initialValues, initialServerId], + ); - // Resolve form state when data sources change useEffect(() => { if (!isVisible || !isCreateFlow) { return; @@ -664,19 +278,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } const hydrationPreferences = hydrationPreferencesRef.current ?? preferences; - const resolved = resolveFormState( - combinedInitialValues, - hydrationPreferences, + dispatch({ + type: "RESOLVE", + initialValues: combinedInitialValues, + preferences: hydrationPreferences, availableModels, - userModified, - formStateRef.current, - validServerIds, - snapshotResolvableProviderDefinitionMap, - ); - - if (hasFormStateChanged(formStateRef.current, resolved)) { - setFormState(resolved); - } + allowedProviderMap: snapshotResolvableProviderDefinitionMap, + }); hasResolvedRef.current = true; }, [ @@ -686,15 +294,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg combinedInitialValues, preferences, availableModels, - userModified, - validServerIds, 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("|"); useEffect(() => { const canAutoSelectServerId = shouldAutoSelectServerId({ @@ -704,14 +306,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg hasResolved: hasResolvedRef.current, userModifiedServerId: userModified.serverId, initialServerId: combinedInitialValues?.serverId, - currentServerId: formStateRef.current.serverId, + currentServerId: reducerStateRef.current.form.serverId, }); if (!canAutoSelectServerId) return; const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null; if (!candidate) return; - setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate })); + dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate }); }, [ combinedInitialValues?.serverId, isCreateFlow, @@ -723,10 +325,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg validServerIds, ]); - // User setters - mark fields as modified and persist to preferences const setSelectedServerIdFromUser = useCallback((value: string | null) => { - setFormState((prev) => ({ ...prev, serverId: value })); - setUserModified((prev) => ({ ...prev, serverId: true })); + dispatch({ type: "SET_SERVER_ID_FROM_USER", value }); }, []); const setProviderFromUser = useCallback( @@ -738,24 +338,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const providerDef = selectableProviderDefinitionMap.get(provider); const providerPrefs = preferences?.providerPreferences?.[provider]; - const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs }); - const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs }); - const nextThinkingOptionId = pickNextThinkingOptionForProvider({ - providerModels, - providerPrefs, - modelId: nextModelId, - }); - - setUserModified((prev) => ({ ...prev, provider: true })); - void updatePreferences({ provider }); - - setFormState((prev) => ({ - ...prev, + dispatch({ + type: "SET_PROVIDER_FROM_USER", provider, - modeId: nextModeId, - model: nextModelId, - thinkingOptionId: nextThinkingOptionId, - })); + providerModels, + providerDef, + providerPrefs, + }); + void updatePreferences({ provider }); }, [ allProviderModels, @@ -774,20 +364,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const providerModels = allProviderModels.get(provider) ?? null; const normalizedModelId = normalizeSelectedModelId(modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); - const nextThinkingOptionId = resolveThinkingOptionId({ - availableModels: providerModels, - modelId: nextModelId, - requestedThinkingOptionId: "", - }); - setFormState((prev) => ({ - ...prev, + dispatch({ + type: "SET_PROVIDER_AND_MODEL_FROM_USER", provider, - model: nextModelId, - modeId: providerDef?.defaultModeId ?? "", - thinkingOptionId: nextThinkingOptionId, - })); - setUserModified((prev) => ({ ...prev, provider: true, model: true })); + modelId, + providerDef, + providerModels, + }); void updatePreferences((current) => mergeSelectedComposerPreferences({ preferences: current, @@ -803,9 +387,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const setModeFromUser = useCallback( (modeId: string) => { - setFormState((prev) => ({ ...prev, modeId })); - setUserModified((prev) => ({ ...prev, modeId: true })); - const provider = formStateRef.current.provider; + dispatch({ type: "SET_MODE_FROM_USER", modeId }); + const provider = reducerStateRef.current.form.provider; if (provider) { void updatePreferences((current) => mergeSelectedComposerPreferences({ @@ -823,23 +406,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const setModelFromUser = useCallback( (modelId: string) => { - const normalizedModelId = normalizeSelectedModelId(modelId); - const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels); - 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; + dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels }); + const provider = reducerStateRef.current.form.provider; if (provider) { + const normalizedModelId = normalizeSelectedModelId(modelId); + const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels); void updatePreferences((current) => mergeSelectedComposerPreferences({ preferences: current, @@ -851,15 +422,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [availableModels, updatePreferences, userModified.thinkingOptionId], + [availableModels, updatePreferences], ); const setThinkingOptionFromUser = useCallback( (thinkingOptionId: string) => { - setFormState((prev) => ({ ...prev, thinkingOptionId })); - setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); - const provider = formStateRef.current.provider; - const modelId = formStateRef.current.model; + dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId }); + const { provider, model: modelId } = reducerStateRef.current.form; if (provider && modelId) { void updatePreferences((current) => mergeSelectedComposerPreferences({ @@ -878,16 +447,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); const setWorkingDir = useCallback((value: string) => { - setFormState((prev) => ({ ...prev, workingDir: value })); + dispatch({ type: "SET_WORKING_DIR", value }); }, []); const setWorkingDirFromUser = useCallback((value: string) => { - setFormState((prev) => ({ ...prev, workingDir: value })); - setUserModified((prev) => ({ ...prev, workingDir: true })); + dispatch({ type: "SET_WORKING_DIR_FROM_USER", value }); }, []); const setSelectedServerId = useCallback((value: string | null) => { - setFormState((prev) => ({ ...prev, serverId: value })); + dispatch({ type: "SET_SERVER_ID", value }); }, []); const refreshProviderModels = useCallback(() => { @@ -895,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, [refreshSnapshot]); const refetchProviderModelsIfStale = useCallback(() => { - refetchSnapshotIfStale(formStateRef.current.provider); + refetchSnapshotIfStale(reducerStateRef.current.form.provider); }, [refetchSnapshotIfStale]); const persistFormPreferences = useCallback(async () => { @@ -993,15 +561,4 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } -// Re-export for backwards compatibility export type CreateAgentInitialValues = FormInitialValues; - -export const __private__ = { - buildProviderDefinitionMap, - buildProviderDefinitionMapForStatuses, - combineInitialValues, - mergeSelectedComposerPreferences, - resolveDefaultModel, - resolveFormState, - resolveThinkingOptionId, -};