mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7ade90fd |
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user