fix(app): preserve pending form edits without repeated updates

Ignore equivalent pending inputs so freshly built fork defaults do not keep the view rendering. Apply fork defaults only to their provider and model, preserving mode and thinking choices made while discovery is pending.
This commit is contained in:
paseo-bot[bot]
2026-11-07 22:51:32 +00:00
parent 4f7201665e
commit 9e0c9e8537
2 changed files with 123 additions and 12 deletions
@@ -1371,3 +1371,109 @@ it("resolves modeless providers once their empty catalog is ready", () => {
expect(ready.form).toMatchObject({ provider: "pi", model: "" });
expect(ready.resolution.status).toBe("completed");
});
it("ignores equivalent pending inputs with freshly built fork defaults", () => {
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: { provider: "codex", model: "gpt-5.3-codex", modeId: "full-access" },
preferences: null,
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const pending = resolveAgentForm(makeState(), inputs);
expect(resolveAgentForm(pending, { ...inputs, initialValues: { ...inputs.initialValues } })).toBe(
pending,
);
});
it("keeps the new provider's mode and thinking when switching away from pending fork defaults", () => {
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet",
label: "Sonnet",
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
},
];
const claudePrefs = { mode: "bypassPermissions", thinkingByModel: { sonnet: "high" } };
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: {
provider: "codex",
model: "gpt-5.3-codex",
modeId: "full-access",
thinkingOptionId: "low",
},
preferences: { providerPreferences: { claude: claudePrefs } },
allowedProviderMap: bothProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([
["codex", null],
["claude", claudeModels],
]),
};
const pending = resolveAgentForm(makeState(), inputs);
const switched = resolveAgentForm(pending, {
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider: "claude",
modelId: "sonnet",
providerDef: TEST_CLAUDE_DEFINITION,
providerModels: claudeModels,
providerPrefs: claudePrefs,
});
expect(switched.form).toMatchObject({
provider: "claude",
model: "sonnet",
modeId: "bypassPermissions",
thinkingOptionId: "high",
});
expect(resolveAgentForm(switched, inputs).form).toEqual(switched.form);
});
it("keeps a new model's thinking preference when pending fork defaults finish resolving", () => {
const models: AgentModelDefinition[] = [
...CODEX_MODELS,
{ ...CODEX_MODELS[0], id: "next-model", defaultThinkingOptionId: "low" },
];
const preferences = {
providerPreferences: { codex: { thinkingByModel: { "next-model": "xhigh" } } },
};
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: { provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
preferences,
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const pending = resolveAgentForm(makeState(), inputs);
const edited = resolveAgentForm(pending, {
type: "SET_MODEL_FROM_USER",
modelId: "next-model",
availableModels: models,
providerPrefs: preferences.providerPreferences.codex,
});
expect(edited.form.thinkingOptionId).toBe("xhigh");
const ready = resolveAgentForm(edited, {
...inputs,
providerModelsByProvider: makeProviderModelsByProvider([["codex", models]]),
});
expect(ready.form).toEqual(edited.form);
});
@@ -345,8 +345,11 @@ function resolveThinkingOption(input: {
} = input;
if (!provider) return "";
if (userModified) return currentThinkingOptionId;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const matchesInitialModel =
!initialModel || resolveCanonicalModelId(availableModels, initialModel) === modelId;
const initialThinkingOptionId =
typeof initialValues?.thinkingOptionId === "string"
matchesInitialModel && typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const preferredThinking = resolvePreferredThinkingOptionId({
@@ -376,6 +379,10 @@ export function resolveFormState(
preferences,
});
const providerInitialValues =
initialValues?.provider && initialValues.provider !== result.provider
? undefined
: initialValues;
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
@@ -385,7 +392,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.modeId,
currentModeId: result.modeId,
initialValues,
initialValues: providerInitialValues,
providerDef,
providerPrefs,
});
@@ -394,7 +401,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.model,
currentModel: result.model,
initialValues,
initialValues: providerInitialValues,
providerPrefs,
availableModels,
});
@@ -404,7 +411,7 @@ export function resolveFormState(
userModified: userModified.thinkingOptionId,
currentThinkingOptionId: result.thinkingOptionId,
modelId: result.model,
initialValues,
initialValues: providerInitialValues,
providerPrefs,
availableModels,
});
@@ -535,14 +542,12 @@ function completeResolution(
);
const waitingForModels =
resolved.provider !== null && action.providerModelsByProvider.get(resolved.provider) === null;
const nextState = {
...state,
resolution: waitingForModels
? PENDING_AGENT_FORM_RESOLUTION
: ({ status: "completed" } as const),
};
if (!hasFormStateChanged(state.form, resolved)) return nextState;
return { ...nextState, form: resolved };
const resolution = waitingForModels
? PENDING_AGENT_FORM_RESOLUTION
: ({ status: "completed" } as const);
const form = hasFormStateChanged(state.form, resolved) ? resolved : state.form;
if (form === state.form && resolution.status === state.resolution.status) return state;
return { ...state, resolution, form };
}
function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) {