Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 1d545a6628 fix(app): preserve thinking defaults in legacy fork drafts
Resolve the legacy default model placeholder through the same canonical model lookup used for selection and identity comparisons.
2026-11-07 22:55:37 +00:00
paseo-bot[bot] 9e0c9e8537 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.
2026-11-07 22:51:32 +00:00
paseo-bot[bot]andMatt Cowger 4f7201665e fix(app): resolve remembered models when host catalogs arrive
Keep opening model resolution pending until its selected catalog is ready, so a loading snapshot cannot freeze an unavailable preference. Preserve user edits and error presentation while awaiting recovery.

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-11-07 22:25:32 +00:00
Matt Cowger 20120d111b fix(app): select available host model by default 2026-11-07 13:27:25 -08:00
4 changed files with 260 additions and 23 deletions
+3
View File
@@ -47,6 +47,9 @@ The component renders state and dispatches intent. That is all it does.
4. **Resolution is explicit model state, per host** (`idle | pending |
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
state you can render, not an effect race.
Create-agent model preferences are shared across hosts, so resolve remembered
models against the selected host's ready catalog before applying them. Keep
explicit initial values and user selections intact.
5. **Displays are owned state.** The selected option's label is captured at
selection/seed time (`setProject(value, display)`), never re-derived from a
live options list — list churn must not flicker or blank a selection.
@@ -113,7 +113,7 @@ function buildProviderModelsByProvider(
for (const entry of snapshotEntries ?? []) {
map.set(
entry.provider,
entry.status === "ready" ? filterSelectableModels(entry.models ?? null) : null,
entry.status === "ready" ? filterSelectableModels(entry.models ?? []) : null,
);
}
return map;
@@ -249,8 +249,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const modelSelectorProviders = snapshotModelSelectorProviders;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const selectedProviderHasError =
snapshotSelectedEntry?.status === "error" || snapshotSelectedEntry?.status === "unavailable";
const isModelSelectionLoading =
isConnected &&
!selectedProviderHasError &&
(resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading);
const isAllModelsLoading = isModelSelectionLoading;
@@ -131,6 +131,34 @@ describe("resolveDefaultModel", () => {
});
});
describe("remembered model availability", () => {
it("uses the selected host's default when the remembered model belongs to another host", () => {
const preferences = {
provider: "codex" as const,
providerPreferences: { codex: { model: "remote/model" } },
};
const models: AgentModelDefinition[] = [
{ provider: "codex", id: "local/first", label: "First" },
{ provider: "codex", id: "local/default", label: "Default", isDefault: true },
];
const resolve = (availableModels: AgentModelDefinition[] | null) =>
resolveFormState(
undefined,
preferences,
availableModels,
INITIAL_USER_MODIFIED,
makeState().form,
codexProviderMap,
).model;
expect(resolve(null)).toBe("remote/model");
expect(resolve(models)).toBe("local/default");
expect(resolve([{ provider: "codex", id: "remote/model", label: "Remote" }])).toBe(
"remote/model",
);
});
});
describe("model aliases", () => {
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
const resolved = resolveFormState(
@@ -791,17 +819,16 @@ describe("resolveFormState", () => {
});
});
it("keeps the explicit model when a refreshed catalogue no longer lists it", () => {
it("keeps a user-selected model when a refreshed catalog no longer lists it", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex", providerPreferences: { codex: { model: "gpt-6-astra" } } },
{ provider: "codex" },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState().form,
{ ...INITIAL_USER_MODIFIED, model: true },
makeState({ provider: "codex", model: "gpt-6-astra" }).form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-6-astra");
// Label/persistence lookup must not reinterpret the submitted ID as another model.
expect(resolveEffectiveModel(CODEX_MODELS, resolved.model)).toBeNull();
});
@@ -1269,3 +1296,197 @@ it("owns input readiness, reopening and user edits in the reducer", () => {
state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true });
expect(state.form).toMatchObject({ provider: "codex", model: "astra" });
});
it("resolves a remembered model against the new host after its catalog finishes loading", () => {
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "second-host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: undefined,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "remote/model" } },
},
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const firstHost = resolveAgentForm(makeState(), {
...inputs,
serverId: "first-host",
providerModelsByProvider: makeProviderModelsByProvider([
["codex", [{ provider: "codex", id: "remote/model", label: "Remote" }]],
]),
});
const opening = resolveAgentForm(firstHost, inputs);
expect(opening.form.model).toBe("remote/model");
expect(opening.resolution.status).toBe("pending");
const ready = resolveAgentForm(opening, {
...inputs,
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
});
expect(ready.form.model).toBe("gpt-5.3-codex");
expect(ready.form.thinkingOptionId).toBe("xhigh");
expect(ready.resolution.status).toBe("completed");
});
it("retains user input while the opening catalog is pending", () => {
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: undefined,
preferences: { provider: "codex", providerPreferences: { codex: { model: "remote/model" } } },
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const opening = resolveAgentForm(makeState(), inputs);
const edited = resolveAgentForm(opening, {
type: "SET_MODEL_FROM_USER",
modelId: "manual/model",
availableModels: null,
providerPrefs: undefined,
});
const ready = resolveAgentForm(edited, {
...inputs,
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
});
expect(ready.form.model).toBe("manual/model");
expect(ready.resolution.status).toBe("completed");
});
it("resolves modeless providers once their empty catalog is ready", () => {
const ready = resolveAgentForm(makeState(), {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: { provider: "pi" },
providerModelsByProvider: makeProviderModelsByProvider([["pi", []]]),
allowedProviderMap: buildProviderDefinitionMap([TEST_PI_DEFINITION]),
});
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);
});
it("preserves saved thinking when a legacy default-model fork resolves", () => {
const resolved = resolveFormState(
{ provider: "codex", model: "default", thinkingOptionId: "low" },
{ providerPreferences: { codex: { thinkingByModel: { "gpt-5.3-codex": "xhigh" } } } },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState().form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("low");
});
@@ -141,7 +141,11 @@ function resolveCanonicalModelId(
): string {
const normalizedModelId = normalizeSelectedModelId(modelId);
if (!normalizedModelId || !availableModels) return normalizedModelId;
return findModelByReference(availableModels, normalizedModelId)?.id ?? "";
const model = findModelByReference(availableModels, normalizedModelId);
if (model) return model.id;
// COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06.
// Older drafts used "default" before providers exposed concrete model IDs.
return normalizedModelId === "default" ? resolveDefaultModelId(availableModels) : "";
}
export function resolveEffectiveModel(
@@ -304,22 +308,16 @@ function resolveModelField(input: {
if (!provider) return "";
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
// COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06.
// Older drafts used "default" before providers exposed concrete model IDs.
if ((initialModel || preferredModel) === "default" && availableModels?.length) {
return (
findModelByReference(availableModels, "default")?.id || resolveDefaultModelId(availableModels)
);
}
if (initialModel) {
return !availableModels
? initialModel
: resolveCanonicalModelId(availableModels, initialModel) || initialModel;
}
if (preferredModel) {
return !availableModels
return !availableModels?.length
? preferredModel
: resolveCanonicalModelId(availableModels, preferredModel) || preferredModel;
: resolveCanonicalModelId(availableModels, preferredModel) ||
resolveDefaultModelId(availableModels);
}
return "";
}
@@ -344,8 +342,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({
@@ -375,6 +376,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]
@@ -384,7 +389,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.modeId,
currentModeId: result.modeId,
initialValues,
initialValues: providerInitialValues,
providerDef,
providerPrefs,
});
@@ -393,7 +398,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.model,
currentModel: result.model,
initialValues,
initialValues: providerInitialValues,
providerPrefs,
availableModels,
});
@@ -403,7 +408,7 @@ export function resolveFormState(
userModified: userModified.thinkingOptionId,
currentThinkingOptionId: result.thinkingOptionId,
modelId: result.model,
initialValues,
initialValues: providerInitialValues,
providerPrefs,
availableModels,
});
@@ -532,9 +537,14 @@ function completeResolution(
state.form,
action.allowedProviderMap,
);
const nextState = { ...state, resolution: { status: "completed" } as const };
if (!hasFormStateChanged(state.form, resolved)) return nextState;
return { ...nextState, form: resolved };
const waitingForModels =
resolved.provider !== null && action.providerModelsByProvider.get(resolved.provider) === null;
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) {