Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] aa7ade90fd fix(server): retain feature definitions in stored agent snapshots
Stored fetches dropped saved features that unread and archive updates
already published. Project them through the existing normalizer so both
interfaces expose the same definitions without reopening the runtime.
2026-11-07 21:31:12 +00:00
6 changed files with 61 additions and 260 deletions
-3
View File
@@ -47,9 +47,6 @@ 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,
entry.status === "ready" ? filterSelectableModels(entry.models ?? null) : null,
);
}
return map;
@@ -249,11 +249,8 @@ 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,34 +131,6 @@ 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(
@@ -819,16 +791,17 @@ describe("resolveFormState", () => {
});
});
it("keeps a user-selected model when a refreshed catalog no longer lists it", () => {
it("keeps the explicit model when a refreshed catalogue no longer lists it", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex" },
{ provider: "codex", providerPreferences: { codex: { model: "gpt-6-astra" } } },
CODEX_MODELS,
{ ...INITIAL_USER_MODIFIED, model: true },
makeState({ provider: "codex", model: "gpt-6-astra" }).form,
INITIAL_USER_MODIFIED,
makeState().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();
});
@@ -1296,197 +1269,3 @@ 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,11 +141,7 @@ function resolveCanonicalModelId(
): string {
const normalizedModelId = normalizeSelectedModelId(modelId);
if (!normalizedModelId || !availableModels) return normalizedModelId;
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) : "";
return findModelByReference(availableModels, normalizedModelId)?.id ?? "";
}
export function resolveEffectiveModel(
@@ -308,16 +304,22 @@ 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?.length
return !availableModels
? preferredModel
: resolveCanonicalModelId(availableModels, preferredModel) ||
resolveDefaultModelId(availableModels);
: resolveCanonicalModelId(availableModels, preferredModel) || preferredModel;
}
return "";
}
@@ -342,11 +344,8 @@ 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 =
matchesInitialModel && typeof initialValues?.thinkingOptionId === "string"
typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const preferredThinking = resolvePreferredThinkingOptionId({
@@ -376,10 +375,6 @@ 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]
@@ -389,7 +384,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.modeId,
currentModeId: result.modeId,
initialValues: providerInitialValues,
initialValues,
providerDef,
providerPrefs,
});
@@ -398,7 +393,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.model,
currentModel: result.model,
initialValues: providerInitialValues,
initialValues,
providerPrefs,
availableModels,
});
@@ -408,7 +403,7 @@ export function resolveFormState(
userModified: userModified.thinkingOptionId,
currentThinkingOptionId: result.thinkingOptionId,
modelId: result.model,
initialValues: providerInitialValues,
initialValues,
providerPrefs,
availableModels,
});
@@ -537,14 +532,9 @@ function completeResolution(
state.form,
action.allowedProviderMap,
);
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 };
const nextState = { ...state, resolution: { status: "completed" } as const };
if (!hasFormStateChanged(state.form, resolved)) return nextState;
return { ...nextState, form: resolved };
}
function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) {
@@ -485,6 +485,43 @@ describe("toAgentPayload", () => {
});
});
describe("buildStoredAgentPayload features", () => {
it.each([null, "2026-11-01T00:00:00.000Z"])(
"preserves saved feature definitions like live snapshots (archivedAt=%s)",
(archivedAt) => {
const features: AgentFeature[] = [
createFeature({ value: false }),
{
type: "select",
id: "service_tier",
label: "Service tier",
value: "default",
options: [
{ id: "default", label: "Normal" },
{ id: "fast", label: "Fast" },
],
},
];
const agent = createManagedAgent({ features });
const record = { ...toStoredAgentRecord(agent), archivedAt };
const payload = buildStoredAgentPayload(record, [agent.provider]);
expect(payload.features).toEqual(features);
expect(payload.features).toEqual(toAgentPayload(agent).features);
expect(payload.features).not.toBe(record.features);
expect(payload.features?.[0]).not.toBe(record.features?.[0]);
},
);
it("uses an empty feature list for older records without saved definitions", () => {
const record = toStoredAgentRecord(createManagedAgent());
delete record.features;
expect(buildStoredAgentPayload(record, [record.provider]).features).toEqual([]);
});
});
describe("toRecentProviderSessionDescriptorPayload", () => {
it("projects provider import rows to provider-opaque public recent sessions", () => {
const session: ImportableProviderSession & { provider: string } = {
@@ -263,6 +263,7 @@ export function buildStoredAgentPayload(
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
features: normalizeFeatures(record.features),
pendingPermissions: [],
persistence,
title: record.title ?? null,